diff --git a/.gitignore b/.gitignore index 1c302326cf..071623075a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ config.toml # Solidity Compiler files cache/ -out/ \ No newline at end of file +out/ + +# Foundry cache +cache-foundry/ diff --git a/.gitmodules b/.gitmodules index 6b95c696ae..eb050c02f0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,10 @@ url = https://github.com/OpenZeppelin/openzeppelin-contracts.git [submodule "vendor/openzeppelin-contracts-upgradeable"] path = vendor/openzeppelin-contracts-upgradeable - url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git \ No newline at end of file + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git +[submodule "vendor/forge-std"] + path = vendor/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "vendor/openzeppelin-foundry-upgrades"] + path = vendor/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000000..4b7516516a --- /dev/null +++ b/foundry.toml @@ -0,0 +1,10 @@ +[profile.default] +src='zilliqa/src/contracts' +out='out' +test='tests' +cache_path='cache-foundry' +viaIR=true +auto_detect_solc=true +build_info=true +extra_output=['storageLayout'] +solc='0.8.28' diff --git a/remappings.txt b/remappings.txt index 8aca4b6da6..d04ab694b7 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,6 @@ @openzeppelin/contracts/=vendor/openzeppelin-contracts/contracts/ @openzeppelin/lib/=vendor/openzeppelin-contracts/lib/ -@openzeppelin/contracts-upgradeable/=vendor/openzeppelin-contracts-upgradeable/contracts/ \ No newline at end of file +@openzeppelin/contracts-upgradeable/=vendor/openzeppelin-contracts-upgradeable/contracts/ +@openzeppelin/foundry-upgrades=vendor/openzeppelin-foundry-upgrades/src +forge-std=vendor/forge-std/src + diff --git a/vendor/forge-std b/vendor/forge-std new file mode 160000 index 0000000000..8ba9031ffc --- /dev/null +++ b/vendor/forge-std @@ -0,0 +1 @@ +Subproject commit 8ba9031ffcbe25aa0d1224d3ca263a995026e477 diff --git a/vendor/openzeppelin-foundry-upgrades b/vendor/openzeppelin-foundry-upgrades new file mode 160000 index 0000000000..326d96b5d9 --- /dev/null +++ b/vendor/openzeppelin-foundry-upgrades @@ -0,0 +1 @@ +Subproject commit 326d96b5d9b5fa87b8cdb734f02a8f73324dad3e diff --git a/zilliqa/src/contracts/README.md b/zilliqa/src/contracts/README.md index a63848fad3..15a6e70ec9 100644 --- a/zilliqa/src/contracts/README.md +++ b/zilliqa/src/contracts/README.md @@ -17,5 +17,5 @@ ZQ_CONTRACT_TEST_BLESS=1 cargo test --features test_contract_bytecode -- contrac ## Run Solidity tests ```sh - forge test -C zilliqa/src/contracts/tests -``` \ No newline at end of file + forge test -C zilliqa/src/contracts/tests --gas-limit 2000000000000 +``` diff --git a/zilliqa/src/contracts/test/Tester.sol b/zilliqa/src/contracts/test/Tester.sol new file mode 100644 index 0000000000..c7c2702677 --- /dev/null +++ b/zilliqa/src/contracts/test/Tester.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {Test, Vm} from "forge-std/Test.sol"; + +abstract contract Tester is Test { + modifier TODO() { + vm.skip(true); + _; + } + + function quickSort( + Vm.Wallet[] memory arr, + int256 left, + int256 right + ) private pure { + int256 i = left; + int256 j = right; + if (i == j) return; + Vm.Wallet memory pivot = arr[uint256(left + (right - left) / 2)]; + while (i <= j) { + while (arr[uint(i)].addr < pivot.addr) i++; + while (pivot.addr < arr[uint(j)].addr) j--; + if (i <= j) { + (arr[uint256(i)], arr[uint256uint256uint256(j)]) = ( + arr[uint256(j)], + arr[uint256(i)] + ); + i++; + j--; + } + } + if (left < j) quickSort(arr, left, j); + if (i < right) quickSort(arr, i, right); + } + + function sign( + Vm.Wallet memory wallet, + bytes32 hashedMessage + ) public returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet, hashedMessage); + return abi.encodePacked(r, s, v); + } + + function multiSign( + Vm.Wallet[] memory wallet, + bytes32 hashedMessage + ) public returns (bytes[] memory) { + bytes[] memory signatures = new bytes[](wallet.length); + + for (uint256 i = 0; i < wallet.length; ++i) { + signatures[i] = sign(wallet[i], hashedMessage); + } + return signatures; + } + + function sort( + Vm.Wallet[] memory data + ) public pure returns (Vm.Wallet[] memory) { + quickSort(data, int256(0), int256(data.length - 1)); + return data; + } +} diff --git a/zilliqa/src/contracts/tests/uccb/ChainDispatcher.t.sol b/zilliqa/src/contracts/tests/uccb/ChainDispatcher.t.sol new file mode 100644 index 0000000000..0b15107030 --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/ChainDispatcher.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {Target, ValidatorManagerFixture, IReentrancy} from "./Helpers.sol"; + +import {ChainDispatcher, IChainDispatcherEvents, IChainDispatcherErrors} from "../../uccb/ChainDispatcher.sol"; + +import {ISignatureValidatorErrors} from "../../uccb/SignatureValidator.sol"; +import {IDispatchReplayCheckerErrors} from "../../uccb/DispatchReplayChecker.sol"; + +import {OwnableUpgradeable, Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +library DispatchArgsBuilder { + struct DispatchArgs { + uint256 sourceChainId; + address target; + bytes call; + uint256 gasLimit; + uint256 nonce; + } + + function instance( + address target + ) external pure returns (DispatchArgs memory args) { + args.sourceChainId = 1; + args.target = target; + args.call = abi.encodeWithSelector(Target.work.selector, uint256(1)); + args.gasLimit = 1_000_000; + args.nonce = 1; + } + + function withCall( + DispatchArgs memory args, + bytes calldata call + ) external pure returns (DispatchArgs memory) { + args.call = call; + return args; + } +} + +contract ChainDispatcherHarness is + Initializable, + UUPSUpgradeable, + ChainDispatcher +{ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address _owner, + address _validatorManager + ) external initializer { + __ChainDispatcher_init(_owner, _validatorManager); + } + + function _authorizeUpgrade(address) internal virtual override onlyOwner {} +} + +contract DispatcherFixture is IChainDispatcherEvents, ValidatorManagerFixture { + using MessageHashUtils for bytes; + using DispatchArgsBuilder for DispatchArgsBuilder.DispatchArgs; + + address owner = vm.createWallet("Owner").addr; + + Target internal immutable target = new Target(); + ChainDispatcherHarness dispatcher; + + constructor() ValidatorManagerFixture() {} + + function setUp() external { + address implementation = address(new ChainDispatcherHarness()); + address proxy = address( + new ERC1967Proxy( + implementation, + abi.encodeWithSelector( + ChainDispatcherHarness.initialize.selector, + owner, + address(validatorManager) + ) + ) + ); + dispatcher = ChainDispatcherHarness(proxy); + } + + function signDispatch( + DispatchArgsBuilder.DispatchArgs memory args + ) public returns (bytes[] memory signatures) { + bytes32 hashedMessage = abi + .encode( + args.sourceChainId, + block.chainid, + args.target, + args.call, + args.gasLimit, + args.nonce + ) + .toEthSignedMessageHash(); + + signatures = multiSign(sort(validators), hashedMessage); + } +} + +contract ChainDispatcherTests is DispatcherFixture { + using DispatchArgsBuilder for DispatchArgsBuilder.DispatchArgs; + + function test_happyPath() external { + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)); + bytes[] memory signatures = signDispatch(args); + + vm.expectCall(address(target), args.call); + vm.expectEmit(address(dispatcher)); + emit Dispatched( + args.sourceChainId, + args.target, + true, + abi.encode(uint(2)), + args.nonce + ); + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + } + + function testRevert_badSignature() external { + // Prepare call + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)); + bytes[] memory signatures = signDispatch(args); + uint256 badNonce = args.nonce + 1; + + vm.expectRevert( + ISignatureValidatorErrors.InvalidValidatorOrSignatures.selector + ); + // Dispatch + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + badNonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), false); + } + + function testRevert_replay() external { + // Prepare call + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)); + bytes[] memory signatures = signDispatch(args); + + // Dispatch + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + // Replay + vm.expectRevert( + IDispatchReplayCheckerErrors.AlreadyDispatched.selector + ); + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + } + + function test_failedCall() external { + uint256 num = 1000; + bytes memory failedCall = abi.encodeWithSelector( + target.work.selector, + num + ); + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)) + .withCall(failedCall); + bytes[] memory signatures = signDispatch(args); + + // Dispatch + vm.expectCall(address(target), failedCall); + + bytes memory expectedError = abi.encodeWithSignature( + "Error(string)", + "Too large" + ); + vm.expectEmit(address(dispatcher)); + emit Dispatched( + args.sourceChainId, + args.target, + false, + expectedError, + args.nonce + ); + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + } + + function test_nonContractCallerWithFailedCall() external { + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(vm.addr(1001)); + bytes[] memory signatures = signDispatch(args); + + // Dispatch + bytes memory expectedError = abi.encodeWithSelector( + IChainDispatcherErrors.NonContractCaller.selector, + args.target + ); + vm.expectEmit(address(dispatcher)); + emit Dispatched( + args.sourceChainId, + args.target, + false, + expectedError, + args.nonce + ); + // Dispatch + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + } + + function test_outOfGasCall() external { + bytes memory call = abi.encodeWithSelector( + target.infiniteLoop.selector + ); + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)) + .withCall(call); + bytes[] memory signatures = signDispatch(args); + + // Dispatch + vm.expectCall(address(target), args.call); + vm.expectEmit(address(dispatcher)); + emit Dispatched( + args.sourceChainId, + args.target, + false, + hex"", // denotes out of gas + args.nonce + ); + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + assertEq(target.c(), uint256(0)); + } + + function test_reentrancy() external { + bytes memory call = abi.encodeWithSelector(target.reentrancy.selector); + DispatchArgsBuilder.DispatchArgs memory args = DispatchArgsBuilder + .instance(address(target)) + .withCall(call); + bytes[] memory signatures = signDispatch(args); + + target.setReentrancyConfig( + address(dispatcher), + abi.encodeWithSelector( + dispatcher.dispatch.selector, + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ) + ); + + // Dispatch + bytes memory expectedError = abi.encodeWithSelector( + IReentrancy.ReentrancySafe.selector + ); + vm.expectEmit(address(dispatcher)); + emit Dispatched( + args.sourceChainId, + args.target, + false, + expectedError, + args.nonce + ); + dispatcher.dispatch( + args.sourceChainId, + args.target, + args.call, + args.gasLimit, + args.nonce, + signatures + ); + assertEq(dispatcher.dispatched(args.sourceChainId, args.nonce), true); + } + + function test_updateValidatorManager() external { + address newValidatorManager = vm + .createWallet("NewValidatorManager") + .addr; + + assertEq(dispatcher.validatorManager(), address(validatorManager)); + vm.prank(owner); + dispatcher.setValidatorManager(newValidatorManager); + assertEq(dispatcher.validatorManager(), newValidatorManager); + } + + function testRevert_updateValidatorManagerWhenNotOwner() external { + address newValidatorManager = vm + .createWallet("NewValidatorManager") + .addr; + address notOwner = vm.createWallet("notOwner").addr; + + assertEq(dispatcher.validatorManager(), address(validatorManager)); + vm.prank(notOwner); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + notOwner + ) + ); + dispatcher.setValidatorManager(newValidatorManager); + assertEq(dispatcher.validatorManager(), address(validatorManager)); + } +} diff --git a/zilliqa/src/contracts/tests/uccb/DispatchReplayChecker.t.sol b/zilliqa/src/contracts/tests/uccb/DispatchReplayChecker.t.sol new file mode 100644 index 0000000000..7a89f24922 --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/DispatchReplayChecker.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {Tester} from "../../test/Tester.sol"; +import {DispatchReplayChecker, IDispatchReplayCheckerErrors} from "../../uccb/DispatchReplayChecker.sol"; + +contract DispatchReplayCheckerHarness is + UUPSUpgradeable, + DispatchReplayChecker +{ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function _authorizeUpgrade(address) internal virtual override {} + + function exposed_replayDispatchCheck( + uint256 sourceShardId, + uint256 nonce + ) external { + _replayDispatchCheck(sourceShardId, nonce); + } +} + +contract DispatchReplayCheckerTests is Tester { + DispatchReplayCheckerHarness dispatchReplayChecker = + new DispatchReplayCheckerHarness(); + + function setUp() external { + address implementation = address(new DispatchReplayCheckerHarness()); + address proxy = address(new ERC1967Proxy(implementation, "")); + dispatchReplayChecker = DispatchReplayCheckerHarness(proxy); + } + + function test_happyPath() external { + uint256 sourceShardId = 0; + uint256 nonce = 0; + + dispatchReplayChecker.exposed_replayDispatchCheck(sourceShardId, nonce); + + assertEq( + dispatchReplayChecker.dispatched(sourceShardId, nonce), + true, + "should have marked dispatched" + ); + } + + function testRevert_whenAlreadyDispatched() external { + uint256 sourceShardId = 0; + uint256 nonce = 0; + + dispatchReplayChecker.exposed_replayDispatchCheck(sourceShardId, nonce); + assertEq( + dispatchReplayChecker.dispatched(sourceShardId, nonce), + true, + "should have marked dispatched" + ); + + vm.expectRevert( + IDispatchReplayCheckerErrors.AlreadyDispatched.selector + ); + dispatchReplayChecker.exposed_replayDispatchCheck(sourceShardId, nonce); + } + + function test_sameNonceDifferentSourceShard() external { + uint256 chain1 = 0; + uint256 chain2 = 1; + uint256 nonce = 0; + + dispatchReplayChecker.exposed_replayDispatchCheck(chain1, nonce); + assertEq( + dispatchReplayChecker.dispatched(chain1, nonce), + true, + "should have marked dispatched" + ); + + dispatchReplayChecker.exposed_replayDispatchCheck(chain2, nonce); + assertEq( + dispatchReplayChecker.dispatched(chain2, nonce), + true, + "should have marked dispatched" + ); + } +} diff --git a/zilliqa/src/contracts/tests/uccb/Helpers.sol b/zilliqa/src/contracts/tests/uccb/Helpers.sol new file mode 100644 index 0000000000..7de8228901 --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/Helpers.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {ValidatorManager} from "../../uccb/ValidatorManager.sol"; +import {Tester, Vm} from "../../test/Tester.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Upgrades, Options} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +library UUPSUpgrader { + function deploy( + string memory location, + bytes memory initializerData + ) internal returns (address proxy) { + Options memory opts; + opts.unsafeSkipAllChecks = true; + + proxy = Upgrades.deployUUPSProxy(location, initializerData, opts); + } + + function upgrade( + address proxy, + string memory location, + bytes memory initializerData, + address deployer + ) internal { + Options memory opts; + opts.unsafeSkipAllChecks = true; + + Upgrades.upgradeProxy(proxy, location, initializerData, opts, deployer); + } +} + +contract TransferReentrancyTester { + address target; + bytes data; + bool public alreadyEntered = false; + + function reentrancyAttack( + address _target, + bytes calldata _data + ) external returns (bool) { + target = _target; + data = _data; + + (bool success, ) = target.call(data); + return success; + } + + receive() external payable { + if (address(target).balance > 0) { + (bool success, ) = target.call(data); + success; + } + } +} + +interface IReentrancy { + error ReentrancyVulnerability(); + error ReentrancySafe(); +} + +contract Target is IReentrancy { + uint256 public c = 0; + + function depositFee(uint256 amount) external payable { + amount; + } + + function work(uint256 num_) external pure returns (uint256) { + require(num_ < 1000, "Too large"); + return num_ + 1; + } + + function infiniteLoop() public { + while (true) { + c = c + 1; + } + } + + function finish(bool success, bytes calldata res, uint256 nonce) external {} + + function finishRevert( + bool success, + bytes calldata res, + uint256 nonce + ) external pure { + success; + res; + nonce; + revert(); + } + + bool public alreadyEntered = false; + bytes public reentrancyCalldata; + address public reentrancyTarget; + + function setReentrancyConfig(address target, bytes calldata data) external { + reentrancyTarget = target; + reentrancyCalldata = data; + } + + function reentrancy() external { + if (alreadyEntered) { + revert IReentrancy.ReentrancyVulnerability(); + } + alreadyEntered = true; + (bool success, ) = reentrancyTarget.call(reentrancyCalldata); + if (success) { + revert IReentrancy.ReentrancyVulnerability(); + } + revert IReentrancy.ReentrancySafe(); + } +} + +abstract contract ValidatorManagerFixture is Tester { + uint256 constant VALIDATOR_COUNT = 10; + + ValidatorManager validatorManager; + Vm.Wallet[] public validators = new Vm.Wallet[](VALIDATOR_COUNT); + + function generateValidatorManager( + uint256 size + ) internal returns (Vm.Wallet[] memory, ValidatorManager) { + Vm.Wallet[] memory _validators = new Vm.Wallet[](size); + address[] memory validatorAddresses = new address[](size); + + for (uint256 i = 0; i < size; ++i) { + _validators[i] = vm.createWallet(i + 1); + validatorAddresses[i] = _validators[i].addr; + } + address implementation = address(new ValidatorManager()); + address proxy = address( + new ERC1967Proxy( + implementation, + abi.encodeWithSelector( + ValidatorManager.initialize.selector, + address(this), + validatorAddresses + ) + ) + ); + + return (_validators, ValidatorManager(proxy)); + } + + constructor() { + // Setup validator manager + ( + Vm.Wallet[] memory _validators, + ValidatorManager _validatorManager + ) = generateValidatorManager(VALIDATOR_COUNT); + validators = _validators; + validatorManager = _validatorManager; + } +} + +contract TestToken is ERC20 { + constructor(uint256 initialSupply) ERC20("Test", "T") { + _mint(msg.sender, initialSupply); + } +} diff --git a/zilliqa/src/contracts/tests/uccb/Relayer.t.sol b/zilliqa/src/contracts/tests/uccb/Relayer.t.sol new file mode 100644 index 0000000000..cf71d57dca --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/Relayer.t.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {Tester} from "../../test/Tester.sol"; +import {Relayer, IRelayerEvents, IRelayer, CallMetadata} from "../../uccb/Relayer.sol"; +import {IRegistryErrors} from "../../uccb/Registry.sol"; + +import {OwnableUpgradeable, Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract RelayerHarness is + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + Relayer +{ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address _owner) external initializer { + __Ownable_init(_owner); + __Relayer_init_unchained(); + } + + function _authorizeUpgrade(address) internal virtual override onlyOwner {} +} + +interface ITest { + struct Args { + uint256 num; + } + + function foo() external; + + function fooWithMetadata( + CallMetadata calldata call, + Args calldata data + ) external; +} + +contract RelayerTests is Tester, IRelayerEvents { + RelayerHarness relayer; + address owner = vm.createWallet("Owner").addr; + address registered = vm.createWallet("Registered").addr; + + function setUp() external { + // Make deployment + address implementation = address(new RelayerHarness()); + address proxy = address( + new ERC1967Proxy( + implementation, + abi.encodeWithSelector( + RelayerHarness.initialize.selector, + owner + ) + ) + ); + relayer = RelayerHarness(proxy); + + // Preregister + vm.prank(owner); + relayer.register(registered); + + assertEq(relayer.registered(registered), true); + } + + function test_relay_happyPath() external { + uint256 nonce = 1; + uint256 targetChainId = 1; + address target = address(0x1); + bytes memory call = abi.encodeWithSelector(ITest.foo.selector); + uint256 gasLimit = 100_000; + + vm.expectEmit(address(relayer)); + vm.prank(registered); + emit IRelayerEvents.Relayed( + targetChainId, + target, + call, + gasLimit, + nonce + ); + uint256 result = relayer.relay(targetChainId, target, call, gasLimit); + + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + } + + function test_relay_identicalConsecutiveCallsHaveDifferentNonce() external { + uint256 nonce = 1; + uint256 targetChainId = 1; + address target = address(0x1); + bytes memory call = abi.encodeWithSelector(ITest.foo.selector); + uint256 gasLimit = 100_000; + + vm.expectEmit(address(relayer)); + vm.prank(registered); + emit IRelayerEvents.Relayed( + targetChainId, + target, + call, + gasLimit, + nonce + ); + uint256 result = relayer.relay(targetChainId, target, call, gasLimit); + + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + + nonce++; + + vm.expectEmit(address(relayer)); + emit IRelayerEvents.Relayed( + targetChainId, + target, + call, + gasLimit, + nonce + ); + vm.prank(registered); + result = relayer.relay(targetChainId, target, call, gasLimit); + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + } + + function test_relay_identicalConsecutiveCallsHaveDifferenceTargetChainId() + external + { + uint256 nonce = 1; + uint256 targetChainId = 1; + uint256 targetChainId2 = 2; + address target = address(0x1); + bytes memory call = abi.encodeWithSelector(ITest.foo.selector); + uint256 gasLimit = 100_000; + + vm.expectEmit(address(relayer)); + vm.prank(registered); + emit IRelayerEvents.Relayed( + targetChainId, + target, + call, + gasLimit, + nonce + ); + uint256 result = relayer.relay(targetChainId, target, call, gasLimit); + + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + + vm.expectEmit(address(relayer)); + emit IRelayerEvents.Relayed( + targetChainId2, + target, + call, + gasLimit, + nonce + ); + vm.prank(registered); + result = relayer.relay(targetChainId2, target, call, gasLimit); + + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + assertEq(relayer.nonce(targetChainId2), nonce); + } + + function test_relayWithMetadata_happyPath() external { + uint256 nonce = 1; + uint256 targetChainId = 1; + address target = address(0x1); + bytes4 callSelector = ITest.foo.selector; + bytes memory callData = abi.encode(ITest.Args(1)); + uint256 gasLimit = 100_000; + + bytes memory expectedCall = abi.encodeWithSelector( + callSelector, + CallMetadata(block.chainid, registered), + callData + ); + + vm.expectEmit(address(relayer)); + emit IRelayerEvents.Relayed( + targetChainId, + target, + expectedCall, + gasLimit, + nonce + ); + vm.prank(registered); + uint256 result = relayer.relayWithMetadata( + targetChainId, + target, + callSelector, + callData, + gasLimit + ); + + assertEq(result, nonce); + assertEq(relayer.nonce(targetChainId), nonce); + } + + function test_RevertNonRegisteredSender() external { + uint256 targetChainId = 1; + address target = address(0x1); + bytes memory call = abi.encodeWithSelector(ITest.foo.selector); + uint256 gasLimit = 100_000; + address notRegisteredSender = vm.addr(10); + + vm.prank(notRegisteredSender); + vm.expectRevert( + abi.encodeWithSelector( + IRegistryErrors.NotRegistered.selector, + notRegisteredSender + ) + ); + relayer.relay(targetChainId, target, call, gasLimit); + } + + function test_removeRegisteredSender() external { + uint256 targetChainId = 1; + address target = address(0x1); + bytes memory call = abi.encodeWithSelector(ITest.foo.selector); + uint256 gasLimit = 100_000; + + vm.prank(owner); + relayer.unregister(registered); + assertEq(relayer.registered(registered), false); + + vm.prank(registered); + vm.expectRevert( + abi.encodeWithSelector( + IRegistryErrors.NotRegistered.selector, + registered + ) + ); + relayer.relay(targetChainId, target, call, gasLimit); + } + + function test_RevertUnauthorizedRegister() external { + address notOwner = vm.createWallet("notOwner").addr; + address newRegistrant = vm.createWallet("newRegistrant").addr; + + vm.prank(notOwner); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + notOwner + ) + ); + relayer.register(newRegistrant); + } + + function test_RevertUnauthorizedUnregister() external { + address notOwner = vm.createWallet("notOwner").addr; + + vm.prank(notOwner); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + notOwner + ) + ); + relayer.unregister(registered); + } + + function test_transferOwnership() external { + address newOwner = vm.createWallet("newOwner").addr; + + vm.prank(owner); + relayer.transferOwnership(newOwner); + // Ownership should only be transferred after newOwner accepts + assertEq(relayer.owner(), owner); + + vm.prank(newOwner); + relayer.acceptOwnership(); + assertEq(relayer.owner(), newOwner); + } +} diff --git a/zilliqa/src/contracts/tests/uccb/SignatureValidator.t.sol b/zilliqa/src/contracts/tests/uccb/SignatureValidator.t.sol new file mode 100644 index 0000000000..afde94a922 --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/SignatureValidator.t.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ISignatureValidatorErrors, SignatureValidator} from "../../uccb/SignatureValidator.sol"; +import {Tester, Vm} from "../../test/Tester.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +contract SignatureValidatorHarness is Tester { + using EnumerableSet for EnumerableSet.AddressSet; + using SignatureValidator for EnumerableSet.AddressSet; + + EnumerableSet.AddressSet private _validators; + + constructor(address[] memory validators) { + uint256 validatorsLength = validators.length; + for (uint256 i = 0; i < validatorsLength; ++i) { + _validators.add(validators[i]); + } + } + + function exposed_validateMessageWithSupermajority( + bytes32 ethSignedMessageHash, + bytes[] calldata signatures + ) external view { + _validators.validateSignaturesWithSupermajority( + ethSignedMessageHash, + signatures + ); + } +} + +abstract contract SignatureValidatorFixture is Tester { + using MessageHashUtils for bytes; + using EnumerableSet for EnumerableSet.AddressSet; + using SignatureValidator for EnumerableSet.AddressSet; + + uint256 constant validatorSize = 10; + SignatureValidatorHarness internal signatureValidator; + + Vm.Wallet[] validatorsWallets = new Vm.Wallet[](validatorSize); + + constructor() { + // Setup validator manager + ( + Vm.Wallet[] memory _validatorWallets, + SignatureValidatorHarness _signatureValidator + ) = generateValidators(validatorSize); + validatorsWallets = _validatorWallets; + signatureValidator = _signatureValidator; + } + + function generateValidators( + uint256 size + ) internal returns (Vm.Wallet[] memory, SignatureValidatorHarness) { + Vm.Wallet[] memory validatorWallets = new Vm.Wallet[](size); + address[] memory validatorAddresses = new address[](size); + for (uint256 i = 0; i < size; ++i) { + validatorWallets[i] = vm.createWallet(i + 1); + validatorAddresses[i] = validatorWallets[i].addr; + } + + return ( + validatorWallets, + new SignatureValidatorHarness(validatorAddresses) + ); + } + + function exactSupermajority( + uint256 size + ) internal pure returns (uint256 supermajority) { + supermajority = (size * 2) / 3 + 1; + } + + function getValidatorSubset( + Vm.Wallet[] memory _validators, + uint256 size + ) internal pure returns (Vm.Wallet[] memory subset) { + subset = new Vm.Wallet[](size); + for (uint256 i = 0; i < size; ++i) { + subset[i] = _validators[i]; + } + } +} + +contract SignatureValidatorTests is SignatureValidatorFixture { + using MessageHashUtils for bytes; + + function test_allValidatorsSign() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + bytes[] memory signatures = multiSign( + sort(validatorsWallets), + messageHash + ); + // If it works does not do anything + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function test_exactMajoritySign() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + uint256 exactSupermajoritySize = exactSupermajority(validatorSize); + Vm.Wallet[] memory exactSupermajorityValidators = getValidatorSubset( + validatorsWallets, + exactSupermajoritySize + ); + bytes[] memory signatures = multiSign( + sort(exactSupermajorityValidators), + messageHash + ); + // If it works does not do anything + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testRevert_lessThanSupermajoritySign() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + uint256 exactSupermajoritySize = exactSupermajority(validatorSize) - 1; + Vm.Wallet[] memory exactSupermajorityValidators = getValidatorSubset( + validatorsWallets, + exactSupermajoritySize + ); + bytes[] memory signatures = multiSign( + sort(exactSupermajorityValidators), + messageHash + ); + // If it works does not do anything + vm.expectRevert(ISignatureValidatorErrors.NoSupermajority.selector); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testRevert_noSignatures() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + bytes[] memory signatures = new bytes[](0); + vm.expectRevert(ISignatureValidatorErrors.NoSupermajority.selector); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function test_emptyMessage() external { + bytes32 messageHash; + bytes[] memory signatures = multiSign( + sort(validatorsWallets), + messageHash + ); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testRevert_invalidSignature() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + bytes[] memory signatures = multiSign( + sort(validatorsWallets), + messageHash + ); + // Manipulate one of the bytes in the first signature + signatures[0][0] = 0; + vm.expectRevert( + ISignatureValidatorErrors.InvalidValidatorOrSignatures.selector + ); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testRevert_unorderedSignatures() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + // Don't sort the validators by address + bytes[] memory signatures = multiSign(validatorsWallets, messageHash); + vm.expectRevert( + ISignatureValidatorErrors.NonUniqueOrUnorderedSignatures.selector + ); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testRevert_repeatedSigners() external { + bytes32 messageHash = bytes("Hello world").toEthSignedMessageHash(); + // Don't sort the validators by address + bytes[] memory signatures = multiSign( + sort(validatorsWallets), + messageHash + ); + // Repeat first and second validator + signatures[0] = signatures[1]; + vm.expectRevert( + ISignatureValidatorErrors.NonUniqueOrUnorderedSignatures.selector + ); + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function testFuzz_message(bytes memory message) external { + bytes32 messageHash = message.toEthSignedMessageHash(); + bytes[] memory signatures = multiSign( + sort(validatorsWallets), + messageHash + ); + + // Should work regardless of validators + signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + function test_largeValidatorSet() external { + uint256 _validatorSize = 25_000; + + ( + Vm.Wallet[] memory _validatorWallet, + SignatureValidatorHarness _signatureValidator + ) = generateValidators(_validatorSize); + + bytes32 messageHash = bytes("Hello World").toEthSignedMessageHash(); + bytes[] memory signatures = multiSign( + sort(_validatorWallet), + messageHash + ); + + // Should work regardless of validators + _signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } + + /// forge-config: default.fuzz.runs = 100 + function testFuzz_signatureCount(uint256 input) external { + uint256 size = 200; + uint256 exactSupermajoritySize = exactSupermajority(size); + uint256 signaturesCount = exactSupermajoritySize + + (input % (size - exactSupermajoritySize)); + + ( + Vm.Wallet[] memory _validatorWallet, + SignatureValidatorHarness _signatureValidator + ) = generateValidators(size); + Vm.Wallet[] memory validatorSubset = getValidatorSubset( + _validatorWallet, + signaturesCount + ); + bytes32 messageHash = bytes("Hello World").toEthSignedMessageHash(); + + bytes[] memory signatures = multiSign( + sort(validatorSubset), + messageHash + ); + + // Should work regardless of validators + _signatureValidator.exposed_validateMessageWithSupermajority( + messageHash, + signatures + ); + } +} diff --git a/zilliqa/src/contracts/tests/uccb/ValidatorManager.t.sol b/zilliqa/src/contracts/tests/uccb/ValidatorManager.t.sol new file mode 100644 index 0000000000..8b4c920f3a --- /dev/null +++ b/zilliqa/src/contracts/tests/uccb/ValidatorManager.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {Tester} from "../../test/Tester.sol"; +import {ValidatorManager} from "../../uccb/ValidatorManager.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract ValidatorManagerTests is Tester { + address owner = vm.createWallet("Owner").addr; + address validator1 = vm.createWallet("Validator1").addr; + address validator2 = vm.createWallet("Validator2").addr; + ValidatorManager validatorManager; + + function setUp() external { + address[] memory validators = new address[](1); + validators[0] = validator1; + + vm.prank(owner); + address implementation = address(new ValidatorManager()); + address proxy = address( + new ERC1967Proxy( + implementation, + abi.encodeWithSelector( + ValidatorManager.initialize.selector, + address(owner), + validators + ) + ) + ); + validatorManager = ValidatorManager(proxy); + } + + function test_addValidator() external { + vm.startPrank(owner); + validatorManager.addValidator(validator2); + + assertEq(validatorManager.validatorsSize(), 2); + assertEq(validatorManager.isValidator(validator1), true); + assertEq(validatorManager.isValidator(validator2), true); + vm.stopPrank(); + } + + function test_removeValidator() external { + vm.startPrank(owner); + validatorManager.removeValidator(validator1); + + assertEq(validatorManager.validatorsSize(), 0); + assertEq(validatorManager.isValidator(validator1), false); + vm.stopPrank(); + } + + function test_revertAddValidatorIfNotOwner() external { + address nonOwner = vm.createWallet("NonOwner").addr; + + vm.prank(nonOwner); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, + nonOwner + ) + ); + validatorManager.addValidator(validator2); + } + + function test_revertRemoveValidatorIfNotOwner() external { + address nonOwner = vm.createWallet("NonOwner").addr; + + vm.prank(nonOwner); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, + nonOwner + ) + ); + validatorManager.removeValidator(validator2); + } + + function test_transferOwnership() external { + address newOwner = vm.createWallet("NewOwner").addr; + + vm.prank(owner); + validatorManager.transferOwnership(newOwner); + // Ownership should only be transferred after newOwner accepts + assertEq(validatorManager.owner(), owner); + + vm.prank(newOwner); + validatorManager.acceptOwnership(); + assertEq(validatorManager.owner(), newOwner); + } +} diff --git a/zilliqa/src/contracts/uccb/ChainDispatcher.sol b/zilliqa/src/contracts/uccb/ChainDispatcher.sol new file mode 100644 index 0000000000..acb0fb4cef --- /dev/null +++ b/zilliqa/src/contracts/uccb/ChainDispatcher.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable, Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +import {IValidatorManager} from "./ValidatorManager.sol"; +import {IDispatchReplayChecker, DispatchReplayChecker} from "./DispatchReplayChecker.sol"; + +interface IChainDispatcherEvents { + /** + * @dev Triggered when an event enters this chain + */ + event Dispatched( + uint256 indexed sourceChainId, + address indexed target, + bool success, + bytes response, + uint256 indexed nonce + ); +} + +interface IChainDispatcherErrors { + /** + * @dev The target address being called must be a contract or the call will fall through + */ + error NonContractCaller(address target); +} + +interface IChainDispatcher is + IChainDispatcherEvents, + IChainDispatcherErrors, + IDispatchReplayChecker +{ + function validatorManager() external view returns (address); + + function setValidatorManager(address validatorManager) external; + + function dispatch( + uint256 sourceChainId, + address target, + bytes calldata call, + uint256 gasLimit, + uint256 nonce, + bytes[] calldata signatures + ) external; +} + +/** + * @title ChainDispatcher + * @notice Handles everything related to receiving messages from other chains to be dispatched + * @dev This contract should be used by inherited for cross-chain messaging. It is also made upgradeable. + * + * The `dispatch` function will dispatch a message sourcing from a different chain + * It is able to relay message to any arbitrary chain that is part of the UCCB network + */ +abstract contract ChainDispatcher is + IChainDispatcher, + Initializable, + Ownable2StepUpgradeable, + DispatchReplayChecker +{ + using MessageHashUtils for bytes; + + /** + * @dev Storage of the initializable contract. + * + * It's implemented on a custom ERC-7201 namespace to reduce the risk of storage collisions + * when using with upgradeable contracts. + * + * @custom:storage-location erc7201:zilliqa.storage.ChainDispatcher + */ + struct ChainDispatcherStorage { + IValidatorManager validatorManager; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.ChainDispatcher")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CHAIN_DISPATCHER_STORAGE_POSITION = + 0x8cff60b14f9f959be48079fe56fd2ddb283fd144e381f4bd805400fbf1d0d600; + + /** + * @dev Returns a pointer to the storage namespace. + */ + function _getChainDispatcherStorage() + private + pure + returns (ChainDispatcherStorage storage $) + { + assembly { + $.slot := CHAIN_DISPATCHER_STORAGE_POSITION + } + } + + /** + * @dev Initializes the contracts with all the inherited contracts + */ + function __ChainDispatcher_init( + address _owner, + address _validatorManager + ) internal onlyInitializing { + __Ownable_init(_owner); + __ChainDispatcher_init_unchained(_validatorManager); + } + + /** + * @dev The unchained version is used to avoid repeated initializations down the inheritance path + */ + function __ChainDispatcher_init_unchained( + address _validatorManager + ) internal onlyInitializing { + _setValidatorManager(_validatorManager); + } + + /** + * @dev Returns the address of the validator manager used to validator messages + */ + function validatorManager() external view returns (address) { + ChainDispatcherStorage storage $ = _getChainDispatcherStorage(); + return address($.validatorManager); + } + + /** + * @dev Sets the validator manager + */ + function _setValidatorManager(address _validatorManager) internal { + ChainDispatcherStorage storage $ = _getChainDispatcherStorage(); + $.validatorManager = IValidatorManager(_validatorManager); + } + + /** + * @dev External function to set validator manager and permissioned by owner + */ + function setValidatorManager(address _validatorManager) external onlyOwner { + _setValidatorManager(_validatorManager); + } + + /** + * @dev Dispatches a message from another chain, it also verifies the signatures from the dispatchers + * + * The function will should not revert on the underlying call instruction made to the target contract + * and should catch all cases the it would fail. + * + * All other sources of transaction failure would come from the validation of the signatures of the call + * or due to cross-chain message replay, where the same nonce is being used repeatedly + * + * NOTE: The exception to reverting due to underlying call can be caused if the call is made to a scilla interoperability precompile + * where if this fails, it will revert the whole transaction + * + * @param sourceChainId the chainid where the message originated + * @param target the address of the contract to be called + * @param call the call data to be used in the call + * @param gasLimit the gas limit to be used in the call + * @param nonce the nonce from the relayer on the source chain + * @param signatures the signatures of the messages of the validator + */ + function dispatch( + uint256 sourceChainId, + address target, + bytes calldata call, + uint256 gasLimit, + uint256 nonce, + bytes[] calldata signatures + ) external replayDispatchGuard(sourceChainId, nonce) { + ChainDispatcherStorage storage $ = _getChainDispatcherStorage(); + + $.validatorManager.validateMessageWithSupermajority( + abi + .encode( + sourceChainId, + block.chainid, + target, + call, + gasLimit, + nonce + ) + .toEthSignedMessageHash(), + signatures + ); + + // If it is not a contract the call itself should not revert + if (target.code.length == 0) { + emit Dispatched( + sourceChainId, + target, + false, + abi.encodeWithSelector(NonContractCaller.selector, target), + nonce + ); + return; + } + + // This call will not revert the transaction + (bool success, bytes memory response) = (target).call{gas: gasLimit}( + call + ); + + emit Dispatched(sourceChainId, target, success, response, nonce); + } +} diff --git a/zilliqa/src/contracts/uccb/ChainGateway.sol b/zilliqa/src/contracts/uccb/ChainGateway.sol new file mode 100644 index 0000000000..4467e15390 --- /dev/null +++ b/zilliqa/src/contracts/uccb/ChainGateway.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +import {IChainDispatcher, ChainDispatcher} from "./ChainDispatcher.sol"; +import {IRelayer, RelayerUpgradeable} from "./Relayer.sol"; + +interface IChainGateway is IRelayer, IChainDispatcher {} + +/** + * @title ChainGateway + * @notice The main core contract that is deployed on everychain to handle cross-chain messaging + * It inherits 2 important contracts Relayer and ChainDispatcher: + * The Relayer handles outbound messages with `relay` and `relayWithMetadata` functions. + * The ChainDispatcher serves for inbound messages. + * The contract is also UUPS upgradeable. + * For future upgrades of the contract refer to: https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable + */ +contract ChainGateway is + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + RelayerUpgradeable, + ChainDispatcherUpgradeable +{ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @dev Initializer for the contract + */ + function initialize( + address _validatorManager, + address _owner + ) external initializer { + __Ownable_init(_owner); + __Relayer_init_unchained(); + __ChainDispatcher_init_unchained(_validatorManager); + } + + /** + * @dev Override used to secure the upgrade call to the contract owner + */ + function _authorizeUpgrade(address) internal virtual override onlyOwner {} +} diff --git a/zilliqa/src/contracts/uccb/DispatchReplayChecker.sol b/zilliqa/src/contracts/uccb/DispatchReplayChecker.sol new file mode 100644 index 0000000000..4412a56deb --- /dev/null +++ b/zilliqa/src/contracts/uccb/DispatchReplayChecker.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +interface IDispatchReplayCheckerErrors { + /** + * @dev Triggered to when a repeated nonce is detected on dispatch request + */ + error AlreadyDispatched(); +} + +interface IDispatchReplayChecker is IDispatchReplayCheckerErrors { + function dispatched( + uint256 sourceChainId, + uint256 nonce + ) external view returns (bool); +} + +/** + * @title DispatchReplayChecker + * @notice Prevents dispatch replay attacks by keeping track of dispatched nonces + * @dev The contract has a modifier that can be used to protect functions from replay attacks + * essentially prevent the same message from being dispatched twice + * The combination of `(sourceChainId, nonce)` form a unique key pair. + */ +abstract contract DispatchReplayChecker is IDispatchReplayChecker { + /** + * @dev Storage of the initializable contract. + * + * It's implemented on a custom ERC-7201 namespace to reduce the risk of storage collisions + * when using with upgradeable contracts. + * + * @custom:storage-location erc7201:zilliqa.storage.DispatchReplayChecker + */ + struct DispatchReplayCheckerStorage { + // sourceChainId => nonce => isDispatched + mapping(uint256 => mapping(uint256 => bool)) dispatched; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.DispatchReplayChecker")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant DISPATCH_REPLAY_CHECKER_STORAGE_POSITION = + 0xf0d7858cd36fafa025d5af5f0a6a6196668a9b0994a77eee7583c69fc18dfb00; + + /** + * @dev Returns a pointer to the storage namespace. + */ + function _getDispatchReplayCheckerStorage() + private + pure + returns (DispatchReplayCheckerStorage storage $) + { + assembly { + $.slot := DISPATCH_REPLAY_CHECKER_STORAGE_POSITION + } + } + + /** + * @dev view function to verify if a message has been dispatched + */ + function dispatched( + uint256 sourceChainId, + uint256 nonce + ) external view returns (bool) { + DispatchReplayCheckerStorage + storage $ = _getDispatchReplayCheckerStorage(); + return $.dispatched[sourceChainId][nonce]; + } + + /** + * @dev Internal function handling the replay check and reverts if the message has been dispatched + */ + function _replayDispatchCheck( + uint256 sourceChainId, + uint256 nonce + ) internal { + DispatchReplayCheckerStorage + storage $ = _getDispatchReplayCheckerStorage(); + + if ($.dispatched[sourceChainId][nonce]) { + revert AlreadyDispatched(); + } + $.dispatched[sourceChainId][nonce] = true; + } + + /** + * @dev Modifier to protect functions from replay attacks and used by child contracts + */ + modifier replayDispatchGuard(uint256 sourceShardId, uint256 nonce) { + _replayDispatchCheck(sourceShardId, nonce); + _; + } +} diff --git a/zilliqa/src/contracts/uccb/Registry.sol b/zilliqa/src/contracts/uccb/Registry.sol new file mode 100644 index 0000000000..31bfba20a8 --- /dev/null +++ b/zilliqa/src/contracts/uccb/Registry.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +interface IRegistryErrors { + /** + * @dev error thrown when modifier detects a unregistered address + */ + error NotRegistered(address targetAddress); +} + +interface IRegistryEvents { + /** + * @dev Triggered when a new address is registered + */ + event ContractRegistered(address target); + /** + * @dev Triggered when a address is removed + */ + event ContractUnregistered(address target); +} + +interface IRegistry is IRegistryErrors, IRegistryEvents { + function registered(address target) external view returns (bool); + + function register(address newTarget) external; + + function unregister(address removeTarget) external; +} + +/** + * @title Registry + * @notice Holds registered contracts that are allowed to be used by the + * contract that inherits this one + * Includes the `isRegistered` modifier that other contracts can leverage + */ +abstract contract Registry is IRegistry { + /** + * @dev Storage of the initializable contract. + * + * It's implemented on a custom ERC-7201 namespace to reduce the risk of storage collisions + * when using with upgradeable contracts. + * + * @custom:storage-location erc7201:zilliqa.storage.Registry + */ + struct RegistryStorage { + mapping(address => bool) registered; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.Registry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant REGISTRY_STORAGE_POSITION = + 0x4432bdf0e567007e5ad3c8ad839a7f885ef69723eaa659dd9f06e98a97274300; + + /** + * @dev Returns a pointer to the storage namespace. + */ + function _getRegistryStorage() + private + pure + returns (RegistryStorage storage $) + { + assembly { + $.slot := REGISTRY_STORAGE_POSITION + } + } + + /** + * @dev modifier used by contracts that inherit from this one to check if + * the given `target` is part of the registry + */ + modifier isRegistered(address target) { + RegistryStorage storage $ = _getRegistryStorage(); + if (!registered(target)) { + revert NotRegistered(target); + } + _; + } + + /** + * @dev public function returns whether `target` is part of the registry + */ + function registered(address target) public view returns (bool) { + RegistryStorage storage $ = _getRegistryStorage(); + return $.registered[target]; + } + + /** + * @dev Internal function to register a new address + * Can be exposed through child contract + */ + function _register(address newTarget) internal { + RegistryStorage storage $ = _getRegistryStorage(); + $.registered[newTarget] = true; + emit ContractRegistered(newTarget); + } + + /** + * @dev Internal function to unregister an address + * Can be exposed through child contract + */ + function _unregister(address removeTarget) internal { + RegistryStorage storage $ = _getRegistryStorage(); + $.registered[removeTarget] = false; + emit ContractUnregistered(removeTarget); + } +} diff --git a/zilliqa/src/contracts/uccb/Relayer.sol b/zilliqa/src/contracts/uccb/Relayer.sol new file mode 100644 index 0000000000..32779eb349 --- /dev/null +++ b/zilliqa/src/contracts/uccb/Relayer.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {OwnableUpgradeable, Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {Registry, IRegistry} from "./Registry.sol"; + +interface IRelayerEvents { + /** + * @dev Triggered when a outgoing message is relayed to another chain + */ + event Relayed( + uint256 indexed targetChainId, + address target, + bytes call, + uint256 gasLimit, + uint256 nonce + ); +} + +struct CallMetadata { + uint256 sourceChainId; + address sender; +} + +interface IRelayer is IRelayerEvents, IRegistry { + /** + * @dev Incorporates the extra metadata to add on relay + */ + struct CallMetadata { + uint256 sourceChainId; + address sender; + } + + function nonce(uint256 chainId) external view returns (uint256); + + function relayWithMetadata( + uint256 targetChainId, + address target, + bytes4 callSelector, + bytes calldata callData, + uint256 gasLimit + ) external returns (uint256); + + function relay( + uint256 targetChainId, + address target, + bytes calldata call, + uint256 gasLimit + ) external returns (uint256); +} + +/** + * @title Relayer + * @notice Handles everything related to outgoing messages to be dispatched on other chains + * @dev This contract should be used by inherited for cross-chain messaging. It is also made upgradeable. + * + * It is able to relay message to any arbitrary chain that is part of the UCCB network + */ +abstract contract Relayer is + IRelayer, + Initializable, + Ownable2StepUpgradeable, + Registry +{ + /** + * @dev Storage of the initializable contract. + * + * It's implemented on a custom ERC-7201 namespace to reduce the risk of storage collisions + * when using with upgradeable contracts. + * + * @custom:storage-location erc7201:zilliqa.storage.Relayer + */ + struct RelayerStorage { + // TargetChainId => Nonce + mapping(uint256 => uint256) nonce; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.Relayer")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant RELAYER_STORAGE_POSITION = + 0x814fccf6b0465c7c83d1a86cf4c4cdd0d8463969cbd4702358f5ae439f30a000; + + /** + * @dev Returns a pointer to the storage namespace. + */ + function _getRelayerStorage() + private + pure + returns (RelayerStorage storage $) + { + assembly { + $.slot := RELAYER_STORAGE_POSITION + } + } + + /** + * @dev Initializes the contracts with all the inherited contracts + */ + function __Relayer_init(address _owner) internal onlyInitializing { + __Ownable_init(_owner); + __Relayer_init_unchained(); + } + + /** + * @dev The unchained version is used to avoid repeated initializations down the inheritance path + */ + function __Relayer_init_unchained() internal onlyInitializing {} + + /** + * @dev Returns the nonce for a given chain + */ + function nonce(uint256 chainId) external view returns (uint256) { + RelayerStorage storage $ = _getRelayerStorage(); + return $.nonce[chainId]; + } + + /** + * @dev internal relay function shared by the different implementations + * + * Nonces start counting from 1 + * It is also secured by the registry set. So only approved addresses can call relay + * Eventually we can remove `isRegistered` and allow it for public use. + * This requires a proper fee system to prevent abuse. + + */ + function _relay( + uint256 targetChainId, + address target, + bytes memory call, + uint256 gasLimit + ) internal isRegistered(_msgSender()) returns (uint256) { + RelayerStorage storage $ = _getRelayerStorage(); + uint256 _nonce = ++$.nonce[targetChainId]; + + emit Relayed(targetChainId, target, call, gasLimit, _nonce); + return _nonce; + } + + /** + * @dev Basic relay called by contracts on the source chain to send message to target chain + * The sender needs to encode the call data and the target chain id with abi of the callee function + * + * @param targetChainId the chain id the message is intended to be sent to + * @param target the address of the contract on the target chain to execute the call + * @param call the encoded call data to be executed on the target address on the target chain + * @param gasLimit the gas limit for the call executed on the target chain + */ + function relay( + uint256 targetChainId, + address target, + bytes calldata call, + uint256 gasLimit + ) external returns (uint256) { + return _relay(targetChainId, target, call, gasLimit); + } + + /** + * @dev Use this function to relay a call with metadata. This is useful when the dispatched function on the target chain requires the metadata + * For example they may need to verify the sender or the nonce of the transaction on the source chain. + * When packed here, we can ensure that the metadata is not tampered with + * + * NOTE: Ensure the target function conforms to the required abi format `function(CallMetadata, bytes)` + * + * @param targetChainId the chain id the message is intended to be sent to + * @param target the address of the contract on the target chain to execute the call + * @param callSelector the selector of the function to be called on target + * @param callData the calldata to be appended on the call selector + * @param gasLimit the gas limit for the call executed on the target chain + */ + function relayWithMetadata( + uint256 targetChainId, + address target, + bytes4 callSelector, + bytes calldata callData, + uint256 gasLimit + ) external returns (uint256) { + return + _relay( + targetChainId, + target, + abi.encodeWithSelector( + callSelector, + CallMetadata(block.chainid, _msgSender()), + callData + ), + gasLimit + ); + } + + /** + * @dev Able to register new addresses that can call the relayer + */ + function register(address newTarget) external override onlyOwner { + _register(newTarget); + } + + /** + * @dev Removes an address from the registry. Thus preventing them to call relayer + */ + function unregister(address removeTarget) external override onlyOwner { + _unregister(removeTarget); + } +} diff --git a/zilliqa/src/contracts/uccb/SignatureValidator.sol b/zilliqa/src/contracts/uccb/SignatureValidator.sol new file mode 100644 index 0000000000..7d1856272c --- /dev/null +++ b/zilliqa/src/contracts/uccb/SignatureValidator.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +interface ISignatureValidatorErrors { + /** + * @dev Triggers when the signatures provided are either out of order or repeated + */ + error NonUniqueOrUnorderedSignatures(); + /** + * @dev Triggers when the signature does not match any validator + * It could be due to either the signature being wrong or validator invalid + */ + error InvalidValidatorOrSignatures(); + /** + * @dev not enough signatures are provided to reach supermajority + */ + error NoSupermajority(); +} + +/** + * @title SignatureValidator + * @notice Library used on enumerable set of validators to validate signatures + * It checks if the signatures are unique, ordered and valid against the provided message hash + */ +library SignatureValidator { + using ECDSA for bytes32; + using EnumerableSet for EnumerableSet.AddressSet; + + /** + * @dev Checks for strict supermajority + */ + function isSupermajority( + EnumerableSet.AddressSet storage self, + uint256 count + ) internal view returns (bool) { + return count * 3 > self.length() * 2; + } + + /** + * @dev Checks signatures are unique, ordered and valid against message hash + * and forms a supermajority + * errors [NonUniqueOrUnorderedSignatures, InvalidValidatorOrSignatures, NoSupermajority] + * NOTE: The signatures provided must be ordered by address ascendingly otherwise validation will fail + */ + function validateSignaturesWithSupermajority( + EnumerableSet.AddressSet storage self, + bytes32 ethSignedMessageHash, + bytes[] calldata signatures + ) internal view { + address lastSigner = address(0); + uint256 signaturesLength = signatures.length; + + for (uint256 i = 0; i < signaturesLength; ) { + address signer = ethSignedMessageHash.recover(signatures[i]); + if (signer <= lastSigner) { + revert ISignatureValidatorErrors + .NonUniqueOrUnorderedSignatures(); + } + if (!self.contains(signer)) { + revert ISignatureValidatorErrors.InvalidValidatorOrSignatures(); + } + lastSigner = signer; + unchecked { + ++i; + } + } + + if (!isSupermajority(self, signaturesLength)) { + revert ISignatureValidatorErrors.NoSupermajority(); + } + } +} diff --git a/zilliqa/src/contracts/uccb/ValidatorManager.sol b/zilliqa/src/contracts/uccb/ValidatorManager.sol new file mode 100644 index 0000000000..8e823e0389 --- /dev/null +++ b/zilliqa/src/contracts/uccb/ValidatorManager.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.20; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable, Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {ISignatureValidatorErrors, SignatureValidator} from "./SignatureValidator.sol"; + +interface IValidatorManager is ISignatureValidatorErrors { + function addValidator(address user) external returns (bool); + + function removeValidator(address user) external returns (bool); + + function getValidators() external view returns (address[] memory); + + function isValidator(address user) external view returns (bool); + + function validatorsSize() external view returns (uint256); + + function validateMessageWithSupermajority( + bytes32 ethSignedMessageHash, + bytes[] calldata signatures + ) external view; +} + +/** + * @title ValidatorManager + * @notice Manages the validators for the UCCB network + * It can be used by `ChainGateway` contract to verify the signatures of the validators + * on incoming dispatch requests + */ +contract ValidatorManager is + IValidatorManager, + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SignatureValidator for EnumerableSet.AddressSet; + + /** + * @dev Storage of the initializable contract. + * + * It's implemented on a custom ERC-7201 namespace to reduce the risk of storage collisions + * when using with upgradeable contracts. + * + * @custom:storage-location erc7201:zilliqa.storage.ValidatorManager + */ + struct ValidatorManagerStorage { + EnumerableSet.AddressSet validators; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.ValidatorManager")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant VALIDATOR_MANAGER_STORAGE_POSITION = + 0x7accde04f7b3831ef9580fa40c18d71adaa2564f23664e60f2464dcc899c5400; + + /** + * @dev Returns a pointer to the storage namespace. + */ + function _getValidatorManagerStorage() + private + pure + returns (ValidatorManagerStorage storage $) + { + assembly { + $.slot := VALIDATOR_MANAGER_STORAGE_POSITION + } + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @notice Initializes the contract with the owner and the initial validators + */ + function initialize( + address _owner, + address[] calldata validators + ) external initializer { + __Ownable_init(_owner); + + uint256 validatorsLength = validators.length; + for (uint256 i = 0; i < validatorsLength; ++i) { + _addValidator(validators[i]); + } + } + + /** + * @dev Restricts update to only the owner + */ + function _authorizeUpgrade(address) internal virtual override onlyOwner {} + + /** + * @dev Internal getter for validators + */ + function _validators() + internal + view + returns (EnumerableSet.AddressSet storage) + { + ValidatorManagerStorage storage $ = _getValidatorManagerStorage(); + return $.validators; + } + + /** + * @dev internal setter to add new validator + */ + function _addValidator(address user) internal returns (bool) { + return _validators().add(user); + } + + /** + * @dev external function to add new validator restricted to owner + */ + function addValidator(address user) public onlyOwner returns (bool) { + return _addValidator(user); + } + + /** + * @dev external function to remove validator restricted to owner + */ + function removeValidator(address user) external onlyOwner returns (bool) { + return _validators().remove(user); + } + + /** + * @dev external function to get all validators + * Expensive function, avoid calling on-chain. + * Should be used off-chain only. + */ + function getValidators() external view returns (address[] memory) { + return _validators().values(); + } + + /** + * @dev getter to check if the user is part of the validator set + */ + function isValidator(address user) external view returns (bool) { + return _validators().contains(user); + } + + /** + * @dev getter to get the size of the validator set + */ + function validatorsSize() external view returns (uint256) { + return _validators().length(); + } + + /** + * @dev validators the signatures against the input hash + * Ensuring that all the signatures are from the validators + * and satisfies supermajority of the validators + * Signatures also have to be passed in ascending order of address + * No repeated signatures are allowed + * Function reverts if the signatures are not valid + */ + function validateMessageWithSupermajority( + bytes32 ethSignedMessageHash, + bytes[] calldata signatures + ) external view { + _validators().validateSignaturesWithSupermajority( + ethSignedMessageHash, + signatures + ); + } +}