diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..420a7b3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "overrides": [ + { + "files": "*.sol", + "options": { + "printWidth": 120, + "singleQuote": false + } + } + ] +} diff --git a/contracts/StateReceiver.sol b/contracts/StateReceiver.sol index 0575c49..eba25be 100644 --- a/contracts/StateReceiver.sol +++ b/contracts/StateReceiver.sol @@ -1,8 +1,8 @@ pragma solidity ^0.5.11; -import { RLPReader } from "solidity-rlp/contracts/RLPReader.sol"; - -import { System } from "./System.sol"; +import {RLPReader} from "solidity-rlp/contracts/RLPReader.sol"; +import {System} from "./System.sol"; +import {IStateReceiver} from "./IStateReceiver.sol"; contract StateReceiver is System { using RLPReader for bytes; @@ -10,34 +10,98 @@ contract StateReceiver is System { uint256 public lastStateId; + bytes32 public failedStateSyncsRoot; + mapping(bytes32 => bool) public nullifier; + + mapping(uint256 => bytes) public failedStateSyncs; + + address public rootSetter; + uint256 public leafCount; + uint256 public replayCount; + uint256 public constant TREE_DEPTH = 16; + event StateCommitted(uint256 indexed stateId, bool success); + event StateSyncReplay(uint256 indexed stateId); + + constructor(address _rootSetter) public { + rootSetter = _rootSetter; + } - function commitState(uint256 syncTime, bytes calldata recordBytes) external onlySystem returns(bool success) { + function commitState(uint256 syncTime, bytes calldata recordBytes) external onlySystem returns (bool success) { // parse state data RLPReader.RLPItem[] memory dataList = recordBytes.toRlpItem().toList(); uint256 stateId = dataList[0].toUint(); - require( - lastStateId + 1 == stateId, - "StateIds are not sequential" - ); + require(lastStateId + 1 == stateId, "StateIds are not sequential"); lastStateId++; - address receiver = dataList[1].toAddress(); bytes memory stateData = dataList[2].toBytes(); // notify state receiver contract, in a non-revert manner if (isContract(receiver)) { uint256 txGas = 5000000; + bytes memory data = abi.encodeWithSignature("onStateReceive(uint256,bytes)", stateId, stateData); // solium-disable-next-line security/no-inline-assembly assembly { success := call(txGas, receiver, 0, add(data, 0x20), mload(data), 0, 0) } emit StateCommitted(stateId, success); + if (!success) failedStateSyncs[stateId] = abi.encode(receiver, stateData); } } + function replayFailedStateSync(uint256 stateId) external { + bytes memory stateSyncData = failedStateSyncs[stateId]; + require(stateSyncData.length != 0, "!found"); + delete failedStateSyncs[stateId]; + + (address receiver, bytes memory stateData) = abi.decode(stateSyncData, (address, bytes)); + emit StateSyncReplay(stateId); + IStateReceiver(receiver).onStateReceive(stateId, stateData); // revertable + } + + function setRootAndLeafCount(bytes32 _root, uint256 _leafCount) external { + require(msg.sender == rootSetter, "!rootSetter"); + require(failedStateSyncsRoot == bytes32(0), "!zero"); + failedStateSyncsRoot = _root; + leafCount = _leafCount; + } + + function replayHistoricFailedStateSync( + bytes32[TREE_DEPTH] calldata proof, + uint256 leafIndex, + uint256 stateId, + address receiver, + bytes calldata data + ) external { + require(leafIndex < 2 ** TREE_DEPTH, "invalid leafIndex"); + require(++replayCount <= leafCount, "end"); + bytes32 root = failedStateSyncsRoot; + require(root != bytes32(0), "!root"); + + bytes32 leafHash = keccak256(abi.encode(stateId, receiver, data)); + bytes32 zeroHash = 0x28cf91ac064e179f8a42e4b7a20ba080187781da55fd4f3f18870b7a25bacb55; // keccak256(abi.encode(uint256(0), address(0), new bytes(0))); + require(leafHash != zeroHash && !nullifier[leafHash], "used"); + nullifier[leafHash] = true; + + require(root == _getRoot(proof, leafIndex, leafHash), "!proof"); + + emit StateSyncReplay(stateId); + IStateReceiver(receiver).onStateReceive(stateId, data); + } + + function _getRoot(bytes32[TREE_DEPTH] memory proof, uint256 index, bytes32 leafHash) private pure returns (bytes32) { + bytes32 node = leafHash; + + for (uint256 height = 0; height < TREE_DEPTH; height++) { + if (((index >> height) & 1) == 1) node = keccak256(abi.encodePacked(proof[height], node)); + else node = keccak256(abi.encodePacked(node, proof[height])); + } + + return node; + } + // check if address is contract - function isContract(address _addr) private view returns (bool){ + function isContract(address _addr) private view returns (bool) { uint32 size; // solium-disable-next-line security/no-inline-assembly assembly { diff --git a/contracts/test/TestReenterer.sol b/contracts/test/TestReenterer.sol new file mode 100644 index 0000000..7be2da0 --- /dev/null +++ b/contracts/test/TestReenterer.sol @@ -0,0 +1,19 @@ +pragma solidity ^0.5.11; + +contract TestReenterer { + uint256 public reenterCount; + + function onStateReceive(uint256 id, bytes calldata _data) external { + if (reenterCount++ == 0) { + (bool success, bytes memory ret) = msg.sender.call( + abi.encodeWithSignature("replayFailedStateSync(uint256)", id) + ); + // bubble up revert for tests + if (!success) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + } +} diff --git a/contracts/test/TestRevertingReceiver.sol b/contracts/test/TestRevertingReceiver.sol new file mode 100644 index 0000000..c82232f --- /dev/null +++ b/contracts/test/TestRevertingReceiver.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.5.11; + +contract TestRevertingReceiver { + bool public shouldIRevert = true; + function onStateReceive(uint256 _id, bytes calldata _data) external { + if (shouldIRevert) revert("TestRevertingReceiver"); + } + + function toggle() external { + shouldIRevert = !shouldIRevert; + } +} diff --git a/contracts/test/TestStateReceiver.sol b/contracts/test/TestStateReceiver.sol index a555e8f..fe0809c 100644 --- a/contracts/test/TestStateReceiver.sol +++ b/contracts/test/TestStateReceiver.sol @@ -4,4 +4,6 @@ pragma experimental ABIEncoderV2; import {StateReceiver} from "../StateReceiver.sol"; import {TestSystem} from "./TestSystem.sol"; -contract TestStateReceiver is StateReceiver, TestSystem {} +contract TestStateReceiver is StateReceiver, TestSystem { + constructor(address _rootSetter) public StateReceiver(_rootSetter) {} +} diff --git a/migrations/2_genesis_contracts_deploy.js b/migrations/2_genesis_contracts_deploy.js index 8943e09..1c211e9 100644 --- a/migrations/2_genesis_contracts_deploy.js +++ b/migrations/2_genesis_contracts_deploy.js @@ -10,6 +10,8 @@ const SafeMath = artifacts.require('SafeMath') const StateReciever = artifacts.require('StateReceiver') const TestStateReceiver = artifacts.require('TestStateReceiver') const TestCommitState = artifacts.require('TestCommitState') +const TestReenterer = artifacts.require('TestReenterer') +const TestRevertingReceiver = artifacts.require('TestRevertingReceiver') const System = artifacts.require('System') const ValidatorVerifier = artifacts.require('ValidatorVerifier') @@ -44,13 +46,17 @@ module.exports = async function (deployer, network) { deployer.link(e.lib, e.contracts) } - console.log("Deploying contracts...") + const rootSetter = deployer.networks[network].from + + console.log("Deploying contracts with rootSetter %s...", rootSetter) await deployer.deploy(BorValidatorSet) await deployer.deploy(TestBorValidatorSet) - await deployer.deploy(StateReciever) - await deployer.deploy(TestStateReceiver) + await deployer.deploy(StateReciever, rootSetter) + await deployer.deploy(TestStateReceiver, rootSetter) await deployer.deploy(System) await deployer.deploy(ValidatorVerifier) await deployer.deploy(TestCommitState) + await deployer.deploy(TestReenterer) + await deployer.deploy(TestRevertingReceiver) }) } diff --git a/package-lock.json b/package-lock.json index 00afb62..f919ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "genesis-contracts", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -17,7 +18,7 @@ "ganache-cli": "^6.12.2", "nunjucks": "^3.2.0", "openzeppelin-solidity": "2.2.0", - "solidity-rlp": "^2.0.0", + "solidity-rlp": "2.0.8", "truffle": "^5.6.3", "web3": "1.7.4" } @@ -8622,9 +8623,9 @@ } }, "node_modules/solidity-rlp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/solidity-rlp/-/solidity-rlp-2.0.1.tgz", - "integrity": "sha512-zBCnThsO5x3JI4ZPUUnrx0MK2zGxhaxfwiZ3Wwm8lJ+v12WTJjUW9k+lVT5H06AhgHNbxDahEg8CIQvEl1vPLQ==" + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/solidity-rlp/-/solidity-rlp-2.0.8.tgz", + "integrity": "sha512-gzYzHoFKRH1ydJeCfzm3z/BvKrZGK/V9+qbOlNbBcRAYeizjCdDNhLTTE8iIJrHqsRrZRSOo+7mhbnxoBoZvJQ==" }, "node_modules/spark-md5": { "version": "3.0.2", @@ -17831,9 +17832,9 @@ } }, "solidity-rlp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/solidity-rlp/-/solidity-rlp-2.0.1.tgz", - "integrity": "sha512-zBCnThsO5x3JI4ZPUUnrx0MK2zGxhaxfwiZ3Wwm8lJ+v12WTJjUW9k+lVT5H06AhgHNbxDahEg8CIQvEl1vPLQ==" + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/solidity-rlp/-/solidity-rlp-2.0.8.tgz", + "integrity": "sha512-gzYzHoFKRH1ydJeCfzm3z/BvKrZGK/V9+qbOlNbBcRAYeizjCdDNhLTTE8iIJrHqsRrZRSOo+7mhbnxoBoZvJQ==" }, "spark-md5": { "version": "3.0.2", diff --git a/package.json b/package.json index 3ad1e9c..c20b147 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "ganache-cli": "^6.12.2", "nunjucks": "^3.2.0", "openzeppelin-solidity": "2.2.0", - "solidity-rlp": "^2.0.0", + "solidity-rlp": "2.0.8", "truffle": "^5.6.3", "web3": "1.7.4" } diff --git a/scripts/run-test.sh b/scripts/run-test.sh index 9f6a103..4aa82f0 100644 --- a/scripts/run-test.sh +++ b/scripts/run-test.sh @@ -28,6 +28,8 @@ start_testrpc npm run truffle:migrate "$@" +export CI=true + if [ "$SOLIDITY_COVERAGE" = true ]; then npm run truffle:coverage "$@" else diff --git a/test/StateReceiver.test.js b/test/StateReceiver.test.js index 39027ca..d6ba73f 100644 --- a/test/StateReceiver.test.js +++ b/test/StateReceiver.test.js @@ -1,108 +1,541 @@ +const { assert } = require('chai') const ethUtils = require('ethereumjs-util') +const { keccak256, randomHex } = require('web3-utils') const TestStateReceiver = artifacts.require('TestStateReceiver') const TestCommitState = artifacts.require('TestCommitState') +const TestReenterer = artifacts.require('TestReenterer') +const TestRevertingReceiver = artifacts.require('TestRevertingReceiver') +const SparseMerkleTree = require('./util/merkle') +const { getLeaf, fetchFailedStateSyncs } = require('./util/fetchLeaf') +const { expectRevert } = require('./util/assertions') + const BN = ethUtils.BN +const zeroAddress = '0x' + '0'.repeat(40) +const zeroHash = '0x' + '0'.repeat(64) +const zeroLeaf = getLeaf(0, zeroAddress, '0x') +const randomAddress = () => ethUtils.toChecksumAddress(randomHex(20)) +const randomInRange = (x, y = 0) => Math.floor(Math.random() * (x - y) + y) +const randomGreaterThan = (x = 0) => Math.floor(Math.random() * x + x) +const randomBytes = () => randomHex(randomInRange(68)) +const randomProof = (height) => + new Array(height).fill(0).map(() => randomHex(32)) + +const FUZZ_WEIGHT = process.env.CI == 'true' ? 128 : 20 contract('StateReceiver', async (accounts) => { - describe('commitState()', async () => { - let testStateReceiver - let testCommitStateAddr - - before(async function () { - testStateReceiver = await TestStateReceiver.deployed() - await testStateReceiver.setSystemAddress(accounts[0]) - testCommitState = await TestCommitState.deployed() - testCommitStateAddr = testCommitState.address - }) - it('fail with a dummy record data', async () => { - let recordBytes = "dummy-data" - recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) - try { - const result = await testStateReceiver.commitState(0,recordBytes) - assert.fail("Should not pass because of incorrect validator data") - } catch (error) { - assert(error.message.search('revert') >= 0, "Expected revert, got '" + error + "' instead") - } - }) - it('commit the state #1 (stateID #1) and check id & data', async () => { - const dummyAddr = "0x0000000000000000000000000000000000000000" - const stateData = web3.eth.abi.encodeParameters( - ['address', 'address', 'uint256', 'uint256'], - [dummyAddr, accounts[0], 0, 0]) - let stateID = 1 - let recordBytes = [stateID, testCommitStateAddr, stateData] - recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) - let result = await testStateReceiver.commitState.call(0,recordBytes) - assert.isTrue(result) - result = await testStateReceiver.commitState(0,recordBytes) - const id = await testCommitState.id() - const data = await testCommitState.data() - assertBigNumberEquality(id, new BN(stateID)) - assert.strictEqual(data, stateData) - - // check for the StateCommitted event - assert.strictEqual(result.logs[0].event, "StateCommitted") - assert.strictEqual(result.logs[0].args.stateId.toNumber(), stateID) - assert.strictEqual(result.logs[0].args.success, true) - }) + describe('commitState()', async () => { + let testStateReceiver + let testCommitStateAddr - it('commit the state #2 (stateID #2) and check id & data', async () => { - const dummyAddr = "0x0000000000000000000000000000000000000001" - const stateData = web3.eth.abi.encodeParameters( - ['address', 'address', 'uint256', 'uint256'], - [dummyAddr, accounts[0], 0, 0]) - let stateID = 2 - let recordBytes = [stateID, testCommitStateAddr, stateData] - recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) - let result = await testStateReceiver.commitState.call(0,recordBytes) - assert.isTrue(result) - result = await testStateReceiver.commitState(0,recordBytes) - const id = await testCommitState.id() - const data = await testCommitState.data() - assertBigNumberEquality(id, new BN(stateID)) - assert.strictEqual(data, stateData) - }) + before(async function () { + testStateReceiver = await TestStateReceiver.deployed() + await testStateReceiver.setSystemAddress(accounts[0]) + testCommitState = await TestCommitState.deployed() + testCommitStateAddr = testCommitState.address + }) + it('fail with a dummy record data', async () => { + let recordBytes = 'dummy-data' + recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) + try { + const result = await testStateReceiver.commitState(0, recordBytes) + assert.fail('Should not pass because of incorrect validator data') + } catch (error) { + assert( + error.message.search('revert') >= 0, + "Expected revert, got '" + error + "' instead" + ) + } + }) + it('commit the state #1 (stateID #1) and check id & data', async () => { + const dummyAddr = '0x0000000000000000000000000000000000000000' + const stateData = web3.eth.abi.encodeParameters( + ['address', 'address', 'uint256', 'uint256'], + [dummyAddr, accounts[0], 0, 0] + ) + let stateID = 1 + let recordBytes = [stateID, testCommitStateAddr, stateData] + recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) + let result = await testStateReceiver.commitState.call(0, recordBytes) + assert.isTrue(result) + result = await testStateReceiver.commitState(0, recordBytes) + const id = await testCommitState.id() + const data = await testCommitState.data() + assertBigNumberEquality(id, new BN(stateID)) + assert.strictEqual(data, stateData) - it('should revert (calling from a non-system address', async () => { - const dummyAddr = "0x0000000000000000000000000000000000000001" - const stateData = web3.eth.abi.encodeParameters( - ['address', 'address', 'uint256', 'uint256'], - [dummyAddr, accounts[0], 0, 0]) - const stateID = 3 - let recordBytes = [stateID, testCommitStateAddr, stateData] - recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) - try { - await testStateReceiver.commitState(0,recordBytes, - {from: accounts[1]}) - assert.fail("Non-System Address was able to commit state") - } catch (error) { - assert(error.message.search('Not System Addess!') >= 0, "Expected (Not System Addess), got '" + error + "' instead") - } - }) - it('Infinite loop: ', async () => { - const stateData = web3.eth.abi.encodeParameters( - ['address', 'address', 'uint256', 'uint256'], - // num iterations = 10000, will make the onStateReceive call go out of gas but not revert - [testCommitStateAddr, accounts[0], 0, 10000]) - const stateID = 3 - let recordBytes = [stateID, testCommitStateAddr, stateData] - recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) - const result = await testStateReceiver.commitState(0,recordBytes) - assert.strictEqual(result.receipt.status, true) - - // check for the StateCommitted event with success === false - assert.strictEqual(result.logs[0].event, "StateCommitted") - assert.strictEqual(result.logs[0].args.stateId.toNumber(), stateID) - assert.strictEqual(result.logs[0].args.success, false) + // check for the StateCommitted event + assert.strictEqual(result.logs[0].event, 'StateCommitted') + assert.strictEqual(result.logs[0].args.stateId.toNumber(), stateID) + assert.strictEqual(result.logs[0].args.success, true) + + assert.isNull(await testStateReceiver.failedStateSyncs(stateID)) + }) + + it('commit the state #2 (stateID #2) and check id & data', async () => { + const dummyAddr = '0x0000000000000000000000000000000000000001' + const stateData = web3.eth.abi.encodeParameters( + ['address', 'address', 'uint256', 'uint256'], + [dummyAddr, accounts[0], 0, 0] + ) + let stateID = 2 + let recordBytes = [stateID, testCommitStateAddr, stateData] + recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) + let result = await testStateReceiver.commitState.call(0, recordBytes) + assert.isTrue(result) + result = await testStateReceiver.commitState(0, recordBytes) + const id = await testCommitState.id() + const data = await testCommitState.data() + assertBigNumberEquality(id, new BN(stateID)) + assert.strictEqual(data, stateData) + + assert.isNull(await testStateReceiver.failedStateSyncs(stateID)) + }) + + it('should revert (calling from a non-system address', async () => { + const dummyAddr = '0x0000000000000000000000000000000000000001' + const stateData = web3.eth.abi.encodeParameters( + ['address', 'address', 'uint256', 'uint256'], + [dummyAddr, accounts[0], 0, 0] + ) + const stateID = 3 + let recordBytes = [stateID, testCommitStateAddr, stateData] + recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) + try { + await testStateReceiver.commitState(0, recordBytes, { + from: accounts[1] }) + assert.fail('Non-System Address was able to commit state') + } catch (error) { + assert( + error.message.search('Not System Addess!') >= 0, + "Expected (Not System Addess), got '" + error + "' instead" + ) + } + assert.isNull(await testStateReceiver.failedStateSyncs(stateID)) + }) + + it('Infinite loop: ', async () => { + const stateData = web3.eth.abi.encodeParameters( + ['address', 'address', 'uint256', 'uint256'], + // num iterations = 100000, will make the onStateReceive call go out of gas but not revert + [testCommitStateAddr, accounts[0], 0, 100000] + ) + const stateID = 3 + let recordBytes = [stateID, testCommitStateAddr, stateData] + recordBytes = ethUtils.bufferToHex(ethUtils.rlp.encode(recordBytes)) + let result = await testStateReceiver.commitState.call(0, recordBytes) + assert.isFalse(result) + result = await testStateReceiver.commitState(0, recordBytes) + assert.strictEqual(result.receipt.status, true) + + // check for the StateCommitted event with success === false + assert.strictEqual(result.logs[0].event, 'StateCommitted') + assert.strictEqual(result.logs[0].args.stateId.toNumber(), stateID) + assert.strictEqual(result.logs[0].args.success, false) + + const failedStateSyncData = await testStateReceiver.failedStateSyncs( + stateID + ) + assert.strictEqual( + failedStateSyncData, + web3.eth.abi.encodeParameters( + ['address', 'bytes'], + [testCommitStateAddr, stateData] + ) + ) + }) + }) + + describe('replayFailedStateSync()', async () => { + let testStateReceiver + let testReenterer + let testRevertingReceiver + + let revertingStateId + + before(async function () { + testStateReceiver = await TestStateReceiver.deployed() + await testStateReceiver.setSystemAddress(accounts[0]) + testCommitState = await TestCommitState.deployed() + testCommitStateAddr = testCommitState.address + testReenterer = await TestReenterer.deployed() + testRevertingReceiver = await TestRevertingReceiver.deployed() + }) + it('should commit failed state sync to mapping', async () => { + const stateID = (await testStateReceiver.lastStateId()).toNumber() + 1 + const stateData = randomBytes() + const recordBytes = ethUtils.bufferToHex( + ethUtils.rlp.encode([stateID, testRevertingReceiver.address, stateData]) + ) + let res = await testStateReceiver.commitState.call(0, recordBytes) + assert.isFalse(res) + res = await testStateReceiver.commitState(0, recordBytes) + + assert.strictEqual(res.logs[0].args.success, false) + assert.strictEqual(res.logs[0].args.stateId.toNumber(), stateID) + assert.strictEqual( + await testStateReceiver.failedStateSyncs(stateID), + web3.eth.abi.encodeParameters( + ['address', 'bytes'], + [testRevertingReceiver.address, stateData] + ) + ) + assert.strictEqual( + (await testStateReceiver.lastStateId()).toNumber(), + stateID + ) + assert.isTrue(await testRevertingReceiver.shouldIRevert()) + revertingStateId = stateID + }) + + it('should revert on reverting replay', async () => { + const stateID = revertingStateId + assert.isTrue(await testRevertingReceiver.shouldIRevert()) + await expectRevert( + testStateReceiver.replayFailedStateSync(stateID), + 'TestRevertingReceiver' + ) + }) + + it('should not block commit state flow', async () => { + const stateID = revertingStateId + assertBigNumberEquality(await testStateReceiver.lastStateId(), stateID) + assert.isNotNull(await testStateReceiver.failedStateSyncs(stateID)) + await testRevertingReceiver.toggle() + assert.isFalse(await testRevertingReceiver.shouldIRevert()) + + const res = await testStateReceiver.commitState( + 0, + ethUtils.bufferToHex( + ethUtils.rlp.encode([ + stateID + 1, + testRevertingReceiver.address, + randomBytes() + ]) + ) + ) + assert.strictEqual(res.logs[0].args.success, true) + assert.isNull(await testStateReceiver.failedStateSyncs(stateID + 1)) + + // reset contract for subsequent tests + await testRevertingReceiver.toggle() + assert.isTrue(await testRevertingReceiver.shouldIRevert()) + }) + + it('should remove commit on successful replay', async () => { + const stateID = revertingStateId + await testRevertingReceiver.toggle() + assert.isFalse(await testRevertingReceiver.shouldIRevert()) + const res = await testStateReceiver.replayFailedStateSync(stateID) + + assert.isNull(await testStateReceiver.failedStateSyncs(stateID)) + assert.strictEqual(res.logs[0].event, 'StateSyncReplay') + assert.strictEqual(res.logs[0].args.stateId.toNumber(), stateID) + + await expectRevert( + testStateReceiver.replayFailedStateSync(stateID), + '!found' + ) + }) + + it('should not allow replay from receiver', async () => { + const stateID = (await testStateReceiver.lastStateId()).toNumber() + 1 + const stateData = randomBytes() + + const recordBytes = ethUtils.bufferToHex( + ethUtils.rlp.encode([stateID, testReenterer.address, stateData]) + ) + let res = await testStateReceiver.commitState.call(0, recordBytes) + assert.isFalse(res) + res = await testStateReceiver.commitState(0, recordBytes) + + assert.strictEqual(res.logs[0].event, 'StateCommitted') + assert.strictEqual(res.logs[0].args.stateId.toNumber(), stateID) + assert.strictEqual(res.logs[0].args.success, false) + + assert.strictEqual( + await testStateReceiver.failedStateSyncs(stateID), + web3.eth.abi.encodeParameters( + ['address', 'bytes'], + [testReenterer.address, stateData] + ) + ) + + await expectRevert( + testStateReceiver.replayFailedStateSync(stateID), + '!found' + ) + }) + }) + + describe('replayHistoricFailedStateSync()', async () => { + let testStateReceiver + let testRevertingReceiver + + let fromBlock, toBlock + let tree + let failedStateSyncs = [] + + beforeEach(async function () { + testStateReceiver = await TestStateReceiver.new(accounts[0]) + await testStateReceiver.setSystemAddress(accounts[0]) + testRevertingReceiver = await TestRevertingReceiver.new() + + await testRevertingReceiver.toggle() + assert.isFalse(await testRevertingReceiver.shouldIRevert()) + + // setup some failed state syncs before setting the root + tree = new SparseMerkleTree( + (await testStateReceiver.TREE_DEPTH()).toNumber() + ) + failedStateSyncs = [] + + fromBlock = await web3.eth.getBlockNumber() + + let stateId = (await testStateReceiver.lastStateId()).toNumber() + 1 + for (let i = 0; i < FUZZ_WEIGHT; i++) { + const stateData = randomBytes() + const recordBytes = ethUtils.bufferToHex( + ethUtils.rlp.encode([ + stateId, + testRevertingReceiver.address, + stateData + ]) + ) + + assert.isFalse(await testRevertingReceiver.shouldIRevert()) + // every third state sync succeeds for variance + if (i % 3 === 0) { + const res = await testStateReceiver.commitState(0, recordBytes) + assert.strictEqual(res.logs[0].args.success, true) + } else { + await testRevertingReceiver.toggle() + const res = await testStateReceiver.commitState(0, recordBytes) + await testRevertingReceiver.toggle() + assert.strictEqual(res.logs[0].args.success, false) + tree.add(getLeaf(stateId, testRevertingReceiver.address, stateData)) + failedStateSyncs.push([ + stateId, + testRevertingReceiver.address, + stateData + ]) + } + stateId++ + } + + // set the rootAndClaimCount + await testStateReceiver.setRootAndLeafCount( + tree.getRoot(), + tree.leafCount + ) + assert.equal(failedStateSyncs.length, tree.leafCount) + + toBlock = await web3.eth.getBlockNumber() + }) + it('only rootSetter can set root & leaf count, only once', async () => { + assert.equal(await testStateReceiver.rootSetter(), accounts[0]) + await expectRevert( + testStateReceiver.setRootAndLeafCount(tree.getRoot(), tree.leafCount, { + from: accounts[1] + }), + '!rootSetter' + ) + await expectRevert( + testStateReceiver.setRootAndLeafCount(tree.getRoot(), tree.leafCount), + '!zero' + ) + }) + it('should not replay zero leaf or invalid proof', async () => { + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + randomProof(tree.height), + tree.leafCount + 1, + // zero leaf + 0, + zeroAddress, + '0x' + ), + 'used' + ) + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + randomProof(tree.height), + randomInRange(tree.leafCount), + randomHex(32), + randomAddress(), + randomHex(80) + ), + '!proof' + ) + + const leadIdx = randomInRange(tree.leafCount) + const [stateId, receiver, stateData] = failedStateSyncs[leadIdx] + const leaf = getLeaf(stateId, receiver, stateData) + + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + tree.getProofTreeByValue(leaf), + (leadIdx + 1) % (1 << tree.height), // random leaf index + stateId, + receiver, + stateData + ), + '!proof' + ) + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + tree.getProofTreeByValue(leaf), + leadIdx, + randomHex(32), + receiver, + stateData + ), + '!proof' + ) + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + tree.getProofTreeByValue(leaf), + leadIdx, + stateId, + randomAddress(), + stateData + ), + '!proof' + ) + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + tree.getProofTreeByValue(leaf), + leadIdx, + stateId, + receiver, + randomHex(80) // different from 68 const used + ), + '!proof' + ) + }) + it('should replay all failed state syncs', async () => { + const shuffledFailedStateSyncs = failedStateSyncs + .map((x, i) => [i, x]) // preserve index + .sort(() => Math.random() - 0.5) // shuffle + let replayed = 0 + + for (const [ + leafIndex, + [stateId, receiver, stateData] + ] of shuffledFailedStateSyncs) { + const leaf = getLeaf(stateId, receiver, stateData) + assert.isFalse(await testStateReceiver.nullifier(leaf)) + const proof = tree.getProofTreeByValue(leaf) + const res = await testStateReceiver.replayHistoricFailedStateSync( + proof, + leafIndex, + stateId, + receiver, + stateData + ) + assert.strictEqual(res.logs[0].event, 'StateSyncReplay') + assert.strictEqual(res.logs[0].args.stateId.toNumber(), stateId) + assert.strictEqual( + (await testStateReceiver.replayCount()).toNumber(), + ++replayed + ) + assert.isTrue(await testStateReceiver.nullifier(leaf)) + + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + proof, + leafIndex, + stateId, + receiver, + stateData + ), + replayed == failedStateSyncs.length ? 'end' : 'used' + ) + } + + // should not allow replaying again + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + randomProof(tree.height), + randomInRange(tree.leafCount), + randomHex(32), + randomAddress(), + randomHex(68) + ), + 'end' + ) + }) + it('should not replay nullified state sync', async () => { + const idx = randomInRange(tree.leafCount) + const [stateId, receiver, stateData] = failedStateSyncs[idx] + const leaf = getLeaf(stateId, receiver, stateData) + const proof = tree.getProofTreeByValue(leaf) + + assert.isFalse(await testStateReceiver.nullifier(leaf)) + + const res = await testStateReceiver.replayHistoricFailedStateSync( + proof, + idx, + stateId, + receiver, + stateData + ) + assert.strictEqual(res.logs[0].event, 'StateSyncReplay') + assert.strictEqual(res.logs[0].args.stateId.toNumber(), stateId) + + assert.isTrue(await testStateReceiver.nullifier(leaf)) + + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + proof, + idx, + stateId, + receiver, + stateData + ), + 'used' + ) + }) + it('should be able to fetch failed state syncs from logs', async () => { + const logs = await fetchFailedStateSyncs( + web3.currentProvider, + fromBlock, + testStateReceiver.address, + toBlock - fromBlock + ) + assert.strictEqual(logs.length, failedStateSyncs.length) + for (let i = 0; i < logs.length; i++) { + const log = logs[i] + const [stateId] = failedStateSyncs[i] + assert.strictEqual( + log.topics[0], + keccak256('StateCommitted(uint256,bool)') + ) + assert.strictEqual(BigInt(log.topics[1]).toString(), stateId.toString()) + assert.strictEqual(log.data, '0x' + '0'.repeat(64)) + } + }) + it('should revert if value of leaf index is out of bounds', async () => { + const invalidLeafIndex = randomGreaterThan(2 ** (await testStateReceiver.TREE_DEPTH()).toNumber()); + await expectRevert( + testStateReceiver.replayHistoricFailedStateSync( + randomProof(tree.height), + invalidLeafIndex, + randomHex(32), + randomAddress(), + randomHex(68) + ), + 'invalid leafIndex' + ) }) + }) }) function assertBigNumberEquality(num1, num2) { - if (!BN.isBN(num1)) num1 = web3.utils.toBN(num1.toString()) - if (!BN.isBN(num2)) num2 = web3.utils.toBN(num2.toString()) - assert( - num1.eq(num2), - `expected ${num1.toString(10)} and ${num2.toString(10)} to be equal` - ) - } + if (!BN.isBN(num1)) num1 = web3.utils.toBN(num1.toString()) + if (!BN.isBN(num2)) num2 = web3.utils.toBN(num2.toString()) + assert( + num1.eq(num2), + `expected ${num1.toString(10)} and ${num2.toString(10)} to be equal` + ) +} diff --git a/test/util/assertions.js b/test/util/assertions.js new file mode 100644 index 0000000..15b159e --- /dev/null +++ b/test/util/assertions.js @@ -0,0 +1,16 @@ +async function expectRevert(tx, revertData) { + try { + await tx + assert.fail(`expected revert ${revertData} not received`) + } catch (e) { + assert.include( + e.message, + revertData, + `expected to revert with ${revertData} but got ${e.message} instead` + ) + } +} + +module.exports = { + expectRevert +} diff --git a/test/util/fetchLeaf.js b/test/util/fetchLeaf.js new file mode 100644 index 0000000..dadef79 --- /dev/null +++ b/test/util/fetchLeaf.js @@ -0,0 +1,60 @@ +const Eth = require('web3-eth') +const AbiCoder = require('web3-eth-abi') +const { keccak256 } = require('web3-utils') + +const abi = AbiCoder + +function getLeaf(stateID, receiverAddress, stateData) { + return keccak256( + abi.encodeParameters( + ['uint256', 'address', 'bytes'], + [stateID, receiverAddress, stateData] + ) + ) +} + +const eventLog0 = keccak256('StateCommitted(uint256,bool)') // 0x5a22725590b0a51c923940223f7458512164b1113359a735e86e7f27f44791ee + +async function fetchFailedStateSyncs( + web3Provider, + startBlock = 0, + stateReceiver = '0x0000000000000000000000000000000000001001', + blockRange = 50000 +) { + const eth = new Eth(web3Provider) + const currentBlock = await eth.getBlockNumber() + let data = [] + fromBlock = currentBlock - blockRange + toBlock = currentBlock + let empty = 0 + + while (true) { + const logs = await eth.getPastLogs({ + fromBlock, + toBlock, + address: stateReceiver, + topics: [eventLog0] + }) + fromBlock -= blockRange + toBlock -= blockRange + data.push(logs) + + if (fromBlock < startBlock || (logs.length === 0 && ++empty === 5)) break + } + + // filter failed state syncs + data = data + .flat() + .filter( + (log) => + log.data !== + '0x0000000000000000000000000000000000000000000000000000000000000001' + ) + + // can use heimdall api to fetch L1 state sync data + // https://heimdall-api.polygon.technology/clerk/event-record/{stateId} + + return data +} + +module.exports = { getLeaf, fetchFailedStateSyncs } diff --git a/test/util/merkle.js b/test/util/merkle.js new file mode 100644 index 0000000..d7d5f51 --- /dev/null +++ b/test/util/merkle.js @@ -0,0 +1,106 @@ +const AbiCoder = require('web3-eth-abi') +const { keccak256 } = require('web3-utils') + +const abi = AbiCoder + +class SparseMerkleTree { + constructor(height) { + if (height <= 1) { + throw new Error('invalid height, must be greater than 1') + } + this.height = height + this.zeroHashes = this.generateZeroHashes(height) + const tree = [] + for (let i = 0; i <= height; i++) { + tree.push([]) + } + this.tree = tree + this.leafCount = 0 + this.dirty = false + } + + add(leaf) { + this.dirty = true + this.leafCount++ + this.tree[0].push(leaf) + } + + calcBranches() { + for (let i = 0; i < this.height; i++) { + const parent = this.tree[i + 1] + const child = this.tree[i] + for (let j = 0; j < child.length; j += 2) { + const leftNode = child[j] + const rightNode = + j + 1 < child.length ? child[j + 1] : this.zeroHashes[i] + parent[j / 2] = keccak256( + abi.encodeParameters(['bytes32', 'bytes32'], [leftNode, rightNode]) + ) + } + } + this.dirty = false + } + + getProofTreeByIndex(index) { + if (this.dirty) this.calcBranches() + const proof = [] + let currentIndex = index + for (let i = 0; i < this.height; i++) { + currentIndex = + currentIndex % 2 === 1 ? currentIndex - 1 : currentIndex + 1 + if (currentIndex < this.tree[i].length) + proof.push(this.tree[i][currentIndex]) + else proof.push(this.zeroHashes[i]) + currentIndex = Math.floor(currentIndex / 2) + } + + return proof + } + + getProofTreeByValue(value) { + const index = this.tree[0].indexOf(value) + if (index === -1) throw new Error('value not found') + return this.getProofTreeByIndex(index) + } + + getRoot() { + if (this.tree[0][0] === undefined) { + // No leafs in the tree, calculate root with all leafs to 0 + return keccak256( + abi.encodeParameters( + ['bytes32', 'bytes32'], + [this.zeroHashes[this.height - 1], this.zeroHashes[this.height - 1]] + ) + ) + } + if (this.dirty) this.calcBranches() + + return this.tree[this.height][0] + } + + generateZeroHashes(height) { + // keccak256(abi.encode(uint256(0), address(0), new bytes(0))); + const zeroHashes = [ + keccak256( + abi.encodeParameters( + ['uint256', 'address', 'bytes'], + [0, '0x' + '0'.repeat(40), '0x'] + ) + ) + ] + for (let i = 1; i < height; i++) { + zeroHashes.push( + keccak256( + abi.encodeParameters( + ['bytes32', 'bytes32'], + [zeroHashes[i - 1], zeroHashes[i - 1]] + ) + ) + ) + } + + return zeroHashes + } +} + +module.exports = SparseMerkleTree diff --git a/truffle-config.js b/truffle-config.js index 60e1e82..6cc25c2 100644 --- a/truffle-config.js +++ b/truffle-config.js @@ -79,7 +79,8 @@ module.exports = { reporter: "eth-gas-reporter", reporterOptions: { src: "./contracts", - url: "http://localhost:8545", + url: "http://localhost:8545", + timeout: 0 }, },