From e2f5cda38a6f4e636094d68d8f4d0edf9aab5014 Mon Sep 17 00:00:00 2001 From: andersonlee725 <86676141+andersonlee725@users.noreply.github.com> Date: Tue, 4 Jan 2022 23:06:19 +0800 Subject: [PATCH] Anderson/add new forwarder contract (#440) * add minimal forwarder * rename test forwarder contract * add tests for the new forwarder * add forwarder test * rename test * fix comments * fix comments * remove responseForwarder --- contracts/Forwarder.sol | 57 +++ contracts/TestContracts.sol | 22 +- package-lock.json | 147 ++++++- package.json | 4 +- .../voteDepositProposalWithRealForwarder.js | 388 ++++++++++++++++++ ...> voteDepositProposalWithTestForwarder.js} | 2 +- test/forwarder/forwarder.js | 374 +++++++++++++++++ 7 files changed, 989 insertions(+), 5 deletions(-) create mode 100644 contracts/Forwarder.sol create mode 100644 test/contractBridge/voteDepositProposalWithRealForwarder.js rename test/contractBridge/{voteDepositProposalWithForwarder.js => voteDepositProposalWithTestForwarder.js} (99%) create mode 100644 test/forwarder/forwarder.js diff --git a/contracts/Forwarder.sol b/contracts/Forwarder.sol new file mode 100644 index 00000000..9542e69a --- /dev/null +++ b/contracts/Forwarder.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.6.12; +pragma experimental ABIEncoderV2; + +import "@openzeppelin/contracts/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/drafts/EIP712.sol"; + +/** + @notice This contract refers to Openzeppelin's MinimalForwarder contract. + */ +contract Forwarder is EIP712 { + using ECDSA for bytes32; + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + } + + bytes32 private constant _TYPEHASH = + keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"); + + mapping(address => uint256) private _nonces; + + constructor() EIP712("Forwarder", "0.0.1") public {} + + function getNonce(address from) public view returns (uint256) { + return _nonces[from]; + } + + function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { + address signer = _hashTypedDataV4( + keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) + ).recover(signature); + return _nonces[req.from] == req.nonce && signer == req.from; + } + + function execute(ForwardRequest calldata req, bytes calldata signature) + public + payable + returns (bool, bytes memory) + { + require(verify(req, signature), "MinimalForwarder: signature does not match request"); + _nonces[req.from] = req.nonce + 1; + + (bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}( + abi.encodePacked(req.data, req.from) + ); + + assert(gasleft() > req.gas / 63); + + return (success, returndata); + } +} \ No newline at end of file diff --git a/contracts/TestContracts.sol b/contracts/TestContracts.sol index 1574aa0f..c4c7f75a 100644 --- a/contracts/TestContracts.sol +++ b/contracts/TestContracts.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-3.0-only pragma solidity 0.6.12; +pragma experimental ABIEncoderV2; import "./utils/SafeCast.sol"; import "./handlers/HandlerHelpers.sol"; @@ -80,10 +81,29 @@ contract HandlerRevert is HandlerHelpers { } } -contract Forwarder { +contract TestForwarder { function execute(bytes memory data, address to, address sender) external { bytes memory callData = abi.encodePacked(data, sender); (bool success, ) = to.call(callData); require(success, "Relay call failed"); } } + +contract TestTarget { + uint public calls = 0; + uint public gasLeft; + bytes public data; + bool public burnAllGas; + fallback() external payable { + gasLeft = gasleft(); + calls++; + data = msg.data; + if (burnAllGas) { + assert(false); + } + } + + function setBurnAllGas() public { + burnAllGas = true; + } +} diff --git a/package-lock.json b/package-lock.json index 0b7f4b90..19207751 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2284,11 +2284,35 @@ "dev": true, "optional": true, "requires": { + "@graphql-tools/merge": "^8.1.0", "@graphql-tools/utils": "^8.2.0", "tslib": "~2.3.0", "value-or-promise": "1.0.10" }, "dependencies": { + "@graphql-tools/merge": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.2.1.tgz", + "integrity": "sha512-Q240kcUszhXiAYudjuJgNuLgy9CryDP3wp83NOZQezfA6h3ByYKU7xI6DiKrdjyVaGpYN3ppUmdj0uf5GaXzMA==", + "dev": true, + "optional": true, + "requires": { + "@graphql-tools/utils": "^8.5.1", + "tslib": "~2.3.0" + }, + "dependencies": { + "@graphql-tools/utils": { + "version": "8.5.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.5.5.tgz", + "integrity": "sha512-y7zRXWIUI73X+9/rf/0KzrNFMlpRKFfzLiwdbIeWwgLs+NV9vfUOoVkX8luXX6LwQxhSypHATMiwZGM2ro/wJA==", + "dev": true, + "optional": true, + "requires": { + "tslib": "~2.3.0" + } + } + } + }, "@graphql-tools/utils": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.2.2.tgz", @@ -8991,6 +9015,41 @@ } } }, + "eth-sig-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-3.0.1.tgz", + "integrity": "sha512-0Us50HiGGvZgjtWTyAI/+qTzYPMLy5Q451D0Xy68bxq1QMWdoOddDwGvsqcFT27uohKgalM9z/yxplyt+mY2iQ==", + "dev": true, + "requires": { + "ethereumjs-abi": "^0.6.8", + "ethereumjs-util": "^5.1.1", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.0" + }, + "dependencies": { + "ethereumjs-util": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-5.2.1.tgz", + "integrity": "sha512-v3kT+7zdyCm1HIqWlLNrHGqHGLpGYIhjeHxQjnDXjLT2FyGJDsd3LWMYUo7pAFRrk86CR3nUJfhC81CCoJNNGQ==", + "dev": true, + "requires": { + "bn.js": "^4.11.0", + "create-hash": "^1.1.2", + "elliptic": "^6.5.2", + "ethereum-cryptography": "^0.1.3", + "ethjs-util": "^0.1.3", + "rlp": "^2.0.0", + "safe-buffer": "^5.1.1" + } + }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true + } + } + }, "ethereum-bloom-filters": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/ethereum-bloom-filters/-/ethereum-bloom-filters-1.0.10.tgz", @@ -9039,6 +9098,33 @@ } } }, + "ethereumjs-abi": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/ethereumjs-abi/-/ethereumjs-abi-0.6.8.tgz", + "integrity": "sha512-Tx0r/iXI6r+lRsdvkFDlut0N08jWMnKRZ6Gkq+Nmw75lZe4e6o3EkSnkaBP5NF6+m5PTGAr9JP43N3LyeoglsA==", + "dev": true, + "requires": { + "bn.js": "^4.11.8", + "ethereumjs-util": "^6.0.0" + }, + "dependencies": { + "ethereumjs-util": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-6.2.1.tgz", + "integrity": "sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==", + "dev": true, + "requires": { + "@types/bn.js": "^4.11.3", + "bn.js": "^4.11.0", + "create-hash": "^1.1.2", + "elliptic": "^6.5.2", + "ethereum-cryptography": "^0.1.3", + "ethjs-util": "0.1.6", + "rlp": "^2.2.3" + } + } + } + }, "ethereumjs-testrpc": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ethereumjs-testrpc/-/ethereumjs-testrpc-6.0.3.tgz", @@ -9080,6 +9166,64 @@ } } }, + "ethereumjs-wallet": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ethereumjs-wallet/-/ethereumjs-wallet-1.0.2.tgz", + "integrity": "sha512-CCWV4RESJgRdHIvFciVQFnCHfqyhXWchTPlkfp28Qc53ufs+doi5I/cV2+xeK9+qEo25XCWfP9MiL+WEPAZfdA==", + "dev": true, + "requires": { + "aes-js": "^3.1.2", + "bs58check": "^2.1.2", + "ethereum-cryptography": "^0.1.3", + "ethereumjs-util": "^7.1.2", + "randombytes": "^2.1.0", + "scrypt-js": "^3.0.1", + "utf8": "^3.0.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "@types/bn.js": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.0.tgz", + "integrity": "sha512-QSSVYj7pYFN49kW77o2s9xTCwZ8F2xLbjLLSEVh8D2F4JUhZtPAGOFLTD+ffqksBx/u4cE/KImFjyhqCjn/LIA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "dev": true + }, + "bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==", + "dev": true + }, + "ethereumjs-util": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-7.1.3.tgz", + "integrity": "sha512-y+82tEbyASO0K0X1/SRhbJJoAlfcvq8JbrG4a5cjrOks7HS/36efU/0j2flxCPOUM++HFahk33kr/ZxyC4vNuw==", + "dev": true, + "requires": { + "@types/bn.js": "^5.1.0", + "bn.js": "^5.1.2", + "create-hash": "^1.1.2", + "ethereum-cryptography": "^0.1.3", + "rlp": "^2.2.4" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, "ethers": { "version": "4.0.49", "resolved": "https://registry.npmjs.org/ethers/-/ethers-4.0.49.tgz", @@ -20918,8 +21062,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", - "dev": true, - "optional": true + "dev": true }, "type": { "version": "1.2.0", diff --git a/package.json b/package.json index 6a5f5be0..fc025f4c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-node-polyfills": "^0.2.1", "rollup-plugin-peer-deps-external": "^2.2.3", - "typescript": "^4.2.3" + "typescript": "^4.2.3", + "eth-sig-util": "^3.0.1", + "ethereumjs-wallet": "^1.0.2" }, "peerDependencies": { "ethers": ">= 5.0.0" diff --git a/test/contractBridge/voteDepositProposalWithRealForwarder.js b/test/contractBridge/voteDepositProposalWithRealForwarder.js new file mode 100644 index 00000000..b33862cf --- /dev/null +++ b/test/contractBridge/voteDepositProposalWithRealForwarder.js @@ -0,0 +1,388 @@ +/** + * Copyright 2021 ChainSafe Systems + * SPDX-License-Identifier: LGPL-3.0-only + */ + +const TruffleAssert = require('truffle-assertions'); +const Ethers = require('ethers'); +const Wallet = require('ethereumjs-wallet').default; +const ethSigUtil = require('eth-sig-util'); + +const Helpers = require('../helpers'); + +const BridgeContract = artifacts.require("Bridge"); +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); +const ERC20HandlerContract = artifacts.require("ERC20Handler"); +const ForwarderContract = artifacts.require("Forwarder"); + +contract('Bridge - [voteProposal through forwarder]', async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const relayer1 = Wallet.generate(); + const relayer2 = Wallet.generate(); + const relayer3 = Wallet.generate(); + const relayer4 = Wallet.generate(); + const relayer1Address = relayer1.getAddressString(); + const relayer2Address = relayer2.getAddressString(); + const relayer3Address = relayer3.getAddressString(); + const relayer4Address = relayer4.getAddressString(); + const relayer1Bit = 1 << 0; + const relayer2Bit = 1 << 1; + const relayer3Bit = 1 << 2; + const depositer = Wallet.generate(); + const depositerAddress = depositer.getAddressString(); + const destinationChainRecipientAddress = accounts[4]; + const depositAmount = 10; + const expectedDepositNonce = 1; + const relayerThreshold = 3; + const expectedFinalizedEventStatus = 2; + + const STATUS = { + Inactive : '0', + Active : '1', + Passed : '2', + Executed : '3', + Cancelled : '4' + } + + const name = 'Forwarder'; + const version = '0.0.1'; + + const EIP712Domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ]; + + let domain; + const types = { + EIP712Domain, + ForwardRequest: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'gas', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + } + + let BridgeInstance; + let DestinationERC20MintableInstance; + let DestinationERC20HandlerInstance; + let ForwarderInstance; + let depositData = ''; + let depositDataHash = ''; + let resourceID = ''; + let initialResourceIDs; + let initialContractAddresses; + let burnableContractAddresses; + + let voteCallData, executeCallData; + + beforeEach(async () => { + await Promise.all([ + BridgeContract.new(destinationDomainID, [ + relayer1Address, + relayer2Address, + relayer3Address, + relayer4Address], + relayerThreshold, + 0, + 100,).then(instance => BridgeInstance = instance), + ERC20MintableContract.new("token", "TOK").then(instance => DestinationERC20MintableInstance = instance) + ]); + + resourceID = Helpers.createResourceID(DestinationERC20MintableInstance.address, originDomainID); + initialResourceIDs = [resourceID]; + initialContractAddresses = [DestinationERC20MintableInstance.address]; + burnableContractAddresses = [DestinationERC20MintableInstance.address]; + + DestinationERC20HandlerInstance = await ERC20HandlerContract.new(BridgeInstance.address); + ForwarderInstance = await ForwarderContract.new(); + + await TruffleAssert.passes(BridgeInstance.adminSetResource(DestinationERC20HandlerInstance.address, resourceID, initialContractAddresses[0])); + await TruffleAssert.passes(BridgeInstance.adminSetBurnable(DestinationERC20HandlerInstance.address, burnableContractAddresses[0])); + + depositData = Helpers.createERCDepositData(depositAmount, 20, destinationChainRecipientAddress); + depositDataHash = Ethers.utils.keccak256(DestinationERC20HandlerInstance.address + depositData.substr(2)); + + await Promise.all([ + DestinationERC20MintableInstance.grantRole(await DestinationERC20MintableInstance.MINTER_ROLE(), DestinationERC20HandlerInstance.address), + BridgeInstance.adminSetResource(DestinationERC20HandlerInstance.address, resourceID, DestinationERC20MintableInstance.address) + ]); + + voteCallData = Helpers.createCallData(BridgeInstance, 'voteProposal', ["uint8", "uint64", "bytes32", "bytes"], [originDomainID, expectedDepositNonce, resourceID, depositData]); + executeCallData = Helpers.createCallData(BridgeInstance, 'executeProposal', ["uint8", "uint64", "bytes", "bytes32", "bool"], [originDomainID, expectedDepositNonce, depositData, resourceID, true]); + await BridgeInstance.adminSetForwarder(ForwarderInstance.address, true); + + const provider = new Ethers.providers.JsonRpcProvider(); + const signer = provider.getSigner(); + + await signer.sendTransaction({to: relayer1Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: relayer2Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: relayer3Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: relayer4Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: depositerAddress, value: Ethers.utils.parseEther("0.1")}); + + domain = { + name, + version, + chainId: 1, + verifyingContract: ForwarderInstance.address, + }; + }); + + it ('[sanity] bridge configured with threshold and relayers', async () => { + assert.equal(await BridgeInstance._domainID(), destinationDomainID) + + assert.equal(await BridgeInstance._relayerThreshold(), relayerThreshold) + + assert.equal((await BridgeInstance._totalRelayers()).toString(), '4') + }) + + it('[sanity] depositProposal should be created with expected values after the vote through forwarder', async () => { + const request = { + from: relayer1Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: voteCallData + } + + const sign = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request + } + } + ) + + await ForwarderInstance.execute(request, sign); + const expectedDepositProposal = { + _yesVotes: relayer1Bit.toString(), + _yesVotesTotal: '1', + _status: '1' // Active + }; + + const depositProposal = await BridgeInstance.getProposal( + originDomainID, expectedDepositNonce, depositDataHash); + + assert.deepInclude(Object.assign({}, depositProposal), expectedDepositProposal); + }); + + it("depositProposal should be automatically executed after the vote if proposal status is changed to Passed during the vote", async () => { + const request1 = { + from: relayer1Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: voteCallData + } + const sign1 = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request1 + } + } + ) + await ForwarderInstance.execute(request1, sign1); + + const request2 = { + from: relayer2Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: voteCallData + } + const sign2 = ethSigUtil.signTypedMessage( + relayer2.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request2 + } + } + ) + await ForwarderInstance.execute(request2, sign2); + + const request3 = { + from: relayer3Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: voteCallData + } + const sign3 = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request3 + } + } + ) + await ForwarderInstance.execute(request3, sign3); // After this vote, automatically executes the proposal. + + const depositProposalAfterThirdVoteWithExecute = await BridgeInstance.getProposal( + originDomainID, expectedDepositNonce, depositDataHash); + + assert.strictEqual(depositProposalAfterThirdVoteWithExecute._status, STATUS.Executed); // Executed + }); + + it('should not create proposal because depositerAddress is not a relayer', async () => { + const request = { + from: depositerAddress, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: voteCallData + } + const sign = ethSigUtil.signTypedMessage( + depositer.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request + } + } + ) + + await ForwarderInstance.execute(request, sign); + + const expectedDepositProposal = { + _yesVotes: '0', + _yesVotesTotal: '0', + _status: '0' + }; + + const depositProposal = await BridgeInstance.getProposal( + originDomainID, expectedDepositNonce, depositDataHash); + assert.deepInclude(Object.assign({}, depositProposal), expectedDepositProposal); + }); + + it("Relayer's address that used forwarder should be marked as voted for proposal", async () => { + const relayer1_forwarder_nonce = await ForwarderInstance.getNonce(relayer1Address); + const request1 = { + from: relayer1Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: relayer1_forwarder_nonce, + data: voteCallData + } + const sign1 = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request1 + } + } + ) + await ForwarderInstance.execute(request1, sign1); + + const hasVoted = await BridgeInstance._hasVotedOnProposal.call( + Helpers.nonceAndId(expectedDepositNonce, originDomainID), depositDataHash, relayer1Address); + assert.isTrue(hasVoted); + }); + + it('Execution successful', async () => { + const relayer1_forwarder_nonce = await ForwarderInstance.getNonce(relayer1Address); + const request1 = { + from: relayer1Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: relayer1_forwarder_nonce, + data: voteCallData + } + const sign1 = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request1 + } + } + ) + await ForwarderInstance.execute(request1, sign1); + + const relayer2_forwarder_nonce = await ForwarderInstance.getNonce(relayer2Address); + const request2 = { + from: relayer2Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: relayer2_forwarder_nonce, + data: voteCallData + } + const sign2 = ethSigUtil.signTypedMessage( + relayer2.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request2 + } + } + ) + await ForwarderInstance.execute(request2, sign2); + + const relayer3_forwarder_nonce = await ForwarderInstance.getNonce(relayer3Address); + const request3 = { + from: relayer3Address, + to: BridgeInstance.address, + value: '0', + gas: '300000', + nonce: relayer3_forwarder_nonce, + data: voteCallData + } + const sign3 = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request3 + } + } + ) + + const voteWithExecuteTx_Forwarder = await ForwarderInstance.execute(request3, sign3); + const voteWithExecuteTx_Bridge = await TruffleAssert.createTransactionResult(BridgeInstance, voteWithExecuteTx_Forwarder.tx); + + TruffleAssert.eventEmitted(voteWithExecuteTx_Bridge, 'ProposalEvent', (event) => { + return event.originDomainID.toNumber() === originDomainID && + event.depositNonce.toNumber() === expectedDepositNonce && + event.status.toNumber() === expectedFinalizedEventStatus && + event.dataHash === depositDataHash + }); + }); +}); diff --git a/test/contractBridge/voteDepositProposalWithForwarder.js b/test/contractBridge/voteDepositProposalWithTestForwarder.js similarity index 99% rename from test/contractBridge/voteDepositProposalWithForwarder.js rename to test/contractBridge/voteDepositProposalWithTestForwarder.js index 0de8079e..f90b514c 100644 --- a/test/contractBridge/voteDepositProposalWithForwarder.js +++ b/test/contractBridge/voteDepositProposalWithTestForwarder.js @@ -11,7 +11,7 @@ const Helpers = require('../helpers'); const BridgeContract = artifacts.require("Bridge"); const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); const ERC20HandlerContract = artifacts.require("ERC20Handler"); -const ForwarderContract = artifacts.require("Forwarder"); +const ForwarderContract = artifacts.require("TestForwarder"); contract('Bridge - [voteProposal through forwarder]', async (accounts) => { const originDomainID = 1; diff --git a/test/forwarder/forwarder.js b/test/forwarder/forwarder.js new file mode 100644 index 00000000..485061e0 --- /dev/null +++ b/test/forwarder/forwarder.js @@ -0,0 +1,374 @@ +/** + * Copyright 2021 ChainSafe Systems + * SPDX-License-Identifier: LGPL-3.0-only + */ +const TruffleAssert = require('truffle-assertions'); +const Ethers = require('ethers'); +const Wallet = require('ethereumjs-wallet').default; +const ethSigUtil = require('eth-sig-util'); +const ForwarderContract = artifacts.require("Forwarder"); +const TestTargetContract = artifacts.require("TestTarget"); + +contract('Forwarder', async (accounts) => { + const relayer1 = Wallet.generate(); + const relayer2 = Wallet.generate(); + const relayer3 = Wallet.generate(); + const relayer1Address = relayer1.getAddressString(); + const relayer2Address = relayer2.getAddressString(); + const relayer3Address = relayer3.getAddressString(); + + const provider = new Ethers.providers.JsonRpcProvider(); + + const name = 'Forwarder'; + const version = '0.0.1'; + + const EIP712Domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ]; + + let domain; + const types = { + EIP712Domain, + ForwardRequest: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'gas', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + } + + let ForwarderInstance; + let TestTargetInstance; + + let sign; + let request; + + beforeEach(async () => { + ForwarderInstance = await ForwarderContract.new(); + TestTargetInstance = await TestTargetContract.new(); + + const signer = provider.getSigner(); + + await signer.sendTransaction({to: relayer1Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: relayer2Address, value: Ethers.utils.parseEther("0.1")}); + await signer.sendTransaction({to: relayer3Address, value: Ethers.utils.parseEther("0.1")}); + + domain = { + name, + version, + chainId: 1, + verifyingContract: ForwarderInstance.address, + }; + + request = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x' + } + + sign = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request + } + } + ) + }); + + it ('In case of invalid request(from), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer2Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('In case of invalid request(to), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer1Address, + to: relayer2Address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('In case of invalid request(value), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '1', + gas: '300000', + nonce: 0, + data: '0x' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('In case of invalid request(gas), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '0', + gas: '50000', + nonce: 0, + data: '0x' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('In case of invalid request(nonce), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 1, + data: '0x' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('In case of invalid request(data), it should not be verified and should be reverted in executing of the forwarder contract', async () => { + const request_other = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x1234' + } + + assert.equal((await ForwarderInstance.verify(request_other, sign)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('If signature is valid, but req.from != signer, it should be reverted and should not be verified', async () => { + const sign_other = ethSigUtil.signTypedMessage( + relayer2.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request + } + } + ) + + assert.equal((await ForwarderInstance.verify(request, sign_other)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request, sign_other), "MinimalForwarder: signature does not match request"); + }); + + it ('If signature is valid, but req.nonce != nonce[signer], it should be reverted and should not be verified', async () => { + const request_other = { + from: relayer1Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 10, + data: '0x' + } + + const sign_other = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: request_other + } + } + ) + + assert.equal((await ForwarderInstance.verify(request_other, sign_other)), false); + return TruffleAssert.reverts(ForwarderInstance.execute(request_other, sign_other), "MinimalForwarder: signature does not match request"); + }); + + it ('Execute should succeed even if the call to the target failed', async () => { + const new_request = { + from: relayer1Address, + to: ForwarderInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x' + } + + const new_sign = ethSigUtil.signTypedMessage( + relayer1.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: new_request + } + } + ) + + const result = await ForwarderInstance.execute.call(new_request, new_sign); + assert.equal(result[0], false); + }); + + it ('Should be failed in case of execute is called with less gas than req.gas', async () => { + const new_request = { + from: relayer3Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: '0x' + } + + const new_sign = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: new_request + } + } + ) + + await TestTargetInstance.setBurnAllGas(); + await TruffleAssert.fails(ForwarderInstance.execute(new_request, new_sign, {gas: 100000})); + }); + + it ('req.gas should be passed to the target contract', async () => { + const requestGas = 100000; + const new_request = { + from: relayer3Address, + to: TestTargetInstance.address, + value: '0', + gas: requestGas.toString(), + nonce: 0, + data: '0x' + } + + const new_sign = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: new_request + } + } + ) + + await ForwarderInstance.execute(new_request, new_sign, {gas: 200000}); + const availableGas = await TestTargetInstance.gasLeft(); + assert(availableGas > 96000); + assert(availableGas < requestGas); + }); + + it ('req.data should be passed to the target contract along with the req.from at the end', async () => { + const requestData = '0x1234'; + const new_request = { + from: relayer3Address, + to: TestTargetInstance.address, + value: '0', + gas: '300000', + nonce: 0, + data: requestData + } + + const new_sign = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: new_request + } + } + ) + await ForwarderInstance.execute(new_request, new_sign); + const callData = await TestTargetInstance.data(); + const expectedData = requestData + relayer3Address.substr(2); + assert.equal(callData, expectedData); + }); + + it ('req.value should be passed to the target contract', async () => { + const request_value = Ethers.utils.parseEther('0.1'); + const new_request = { + from: relayer3Address, + to: TestTargetInstance.address, + value: request_value.toString(), + gas: '300000', + nonce: 0, + data: '0x' + } + + const new_sign = ethSigUtil.signTypedMessage( + relayer3.getPrivateKey(), + { + data: { + types: types, + domain: domain, + primaryType: 'ForwardRequest', + message: new_request + } + } + ) + await ForwarderInstance.execute(new_request, new_sign, {value: Ethers.utils.parseEther('0.3')}); + const targetContract_balance = provider.getBalance(TestTargetInstance.address); + assert.equal((await targetContract_balance).toString(), request_value.toString()); + }); + + it ('The successful execute can not be replayed again', async () => { + await ForwarderInstance.execute(request, sign); + return TruffleAssert.reverts(ForwarderInstance.execute(request, sign), "MinimalForwarder: signature does not match request"); + }); + + it ('Only a single call to the target is performed during the execution', async () => { + await ForwarderInstance.execute(request, sign); + const calls = await TestTargetInstance.calls(); + assert.equal(calls.toNumber(), 1); + }); + + it ('In case of request is matched with signature, it should be verified', async () => { + assert.equal((await ForwarderInstance.verify(request, sign)), true); + }); + + it ('In case of request is matched with signature, it should not be reverted and nonce should be increased', async () => { + const nonce_before_execute = await ForwarderInstance.getNonce(relayer1Address); + await ForwarderInstance.execute(request, sign); + const nonce_after_execute = await ForwarderInstance.getNonce(relayer1Address); + assert.equal(nonce_after_execute.toNumber(), nonce_before_execute.toNumber() + 1); + }); +});