diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c6af37ad..37830da38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,10 @@ lockfileVersion: '6.0' -packageExtensionsChecksum: 9dc1d9a9f36cc677d4731dfbd58b6aa1 +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +packageExtensionsChecksum: b400ff0a8142e3d62da00aca2c1f88eb importers: @@ -3137,6 +3141,16 @@ packages: '@babel/helper-plugin-utils': 7.21.5 dev: false + /@babel/plugin-syntax-flow@7.21.4(@babel/core@7.21.0): + resolution: {integrity: sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.0 + '@babel/helper-plugin-utils': 7.21.5 + dev: false + /@babel/plugin-syntax-flow@7.21.4(@babel/core@7.21.8): resolution: {integrity: sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==} engines: {node: '>=6.9.0'} @@ -4605,6 +4619,23 @@ packages: '@babel/parser': 7.21.9 '@babel/types': 7.21.5 + /@babel/traverse@7.21.2: + resolution: {integrity: sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.9 + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-function-name': 7.21.0 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.21.9 + '@babel/types': 7.21.5 + debug: 4.3.4(supports-color@6.1.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + /@babel/traverse@7.21.2(supports-color@5.5.0): resolution: {integrity: sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==} engines: {node: '>=6.9.0'} @@ -4621,6 +4652,7 @@ packages: globals: 11.12.0 transitivePeerDependencies: - supports-color + dev: false /@babel/traverse@7.21.5: resolution: {integrity: sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==} @@ -4677,6 +4709,7 @@ packages: /@commitlint/config-validator@17.4.4: resolution: {integrity: sha512-bi0+TstqMiqoBAQDvdEP4AFh0GaKyLFlPPEObgI29utoKEYoPQTvF0EYqIwYYLEoJYhj5GfMIhPHJkTJhagfeg==} engines: {node: '>=v14'} + requiresBuild: true dependencies: '@commitlint/types': 17.4.4 ajv: 8.12.0 @@ -4686,6 +4719,7 @@ packages: /@commitlint/execute-rule@17.4.0: resolution: {integrity: sha512-LIgYXuCSO5Gvtc0t9bebAMSwd68ewzmqLypqI2Kke1rqOqqDbMpYcYfoPfFlv9eyLIh4jocHWwCK5FS7z9icUA==} engines: {node: '>=v14'} + requiresBuild: true dev: false optional: true @@ -4717,6 +4751,7 @@ packages: /@commitlint/resolve-extends@17.4.4: resolution: {integrity: sha512-znXr1S0Rr8adInptHw0JeLgumS11lWbk5xAWFVno+HUFVN45875kUtqjrI6AppmD3JI+4s0uZlqqlkepjJd99A==} engines: {node: '>=v14'} + requiresBuild: true dependencies: '@commitlint/config-validator': 17.4.4 '@commitlint/types': 17.4.4 @@ -4730,6 +4765,7 @@ packages: /@commitlint/types@17.4.4: resolution: {integrity: sha512-amRN8tRLYOsxRr6mTnGGGvB5EmW/4DDjLMgiwK3CCVEmN6Sr/6xePGEpWaspKkckILuUORCwe6VfDBw6uj4axQ==} engines: {node: '>=v14'} + requiresBuild: true dependencies: chalk: 4.1.2 dev: false @@ -4738,6 +4774,7 @@ packages: /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + requiresBuild: true dependencies: '@jridgewell/trace-mapping': 0.3.9 dev: false @@ -5273,7 +5310,7 @@ packages: '@babel/preset-typescript': 7.21.0(@babel/core@7.21.0) '@babel/runtime': 7.21.0 '@babel/runtime-corejs3': 7.21.0 - '@babel/traverse': 7.21.2(supports-color@5.5.0) + '@babel/traverse': 7.21.2 '@docusaurus/cssnano-preset': 2.3.1 '@docusaurus/logger': 2.3.1 '@docusaurus/mdx-loader': 2.3.1(@docusaurus/types@2.3.1)(esbuild@0.14.7)(react-dom@18.2.0)(react@18.2.0)(webpack-cli@3.3.12) @@ -6849,6 +6886,7 @@ packages: /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + requiresBuild: true dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.15 @@ -7742,18 +7780,22 @@ packages: /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + requiresBuild: true dev: false /@tsconfig/node12@1.0.11: resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + requiresBuild: true dev: false /@tsconfig/node14@1.0.3: resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + requiresBuild: true dev: false /@tsconfig/node16@1.0.3: resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} + requiresBuild: true dev: false /@types/aria-query@5.0.1: @@ -10182,6 +10224,7 @@ packages: /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + requiresBuild: true dev: false /arg@5.0.2: @@ -10519,7 +10562,7 @@ packages: dependencies: '@babel/code-frame': 7.18.6 '@babel/parser': 7.21.2 - '@babel/traverse': 7.21.2(supports-color@5.5.0) + '@babel/traverse': 7.21.2 '@babel/types': 7.21.2 eslint: 7.32.0 eslint-visitor-keys: 1.3.0 @@ -12344,6 +12387,7 @@ packages: /cosmiconfig-typescript-loader@4.3.0(@types/node@20.2.4)(cosmiconfig@8.1.0)(ts-node@10.9.1)(typescript@4.9.5): resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} engines: {node: '>=12', npm: '>=6'} + requiresBuild: true peerDependencies: '@types/node': '*' cosmiconfig: '>=7' @@ -12391,6 +12435,7 @@ packages: /cosmiconfig@8.1.0: resolution: {integrity: sha512-0tLZ9URlPGU7JsKq0DQOQ3FoRsYX8xDZ7xMiATQfaiGMz7EHowNkbU9u1coAOmnh9p/1ySpm0RB3JNWRXM5GCg==} engines: {node: '>=14'} + requiresBuild: true dependencies: import-fresh: 3.3.0 js-yaml: 4.1.0 @@ -12432,6 +12477,7 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + requiresBuild: true dev: false /cross-fetch@2.2.5: @@ -13402,6 +13448,7 @@ packages: dependencies: ms: 2.1.2 supports-color: 5.5.0 + dev: false /debug@4.3.4(supports-color@6.1.0): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -13709,6 +13756,7 @@ packages: /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + requiresBuild: true dev: false /diffie-hellman@5.0.3: @@ -14624,8 +14672,8 @@ packages: '@babel/plugin-transform-react-jsx': ^7.14.9 eslint: ^8.1.0 dependencies: - '@babel/plugin-syntax-flow': 7.21.4(@babel/core@7.21.8) - '@babel/plugin-transform-react-jsx': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-syntax-flow': 7.21.4(@babel/core@7.21.0) + '@babel/plugin-transform-react-jsx': 7.21.5(@babel/core@7.21.0) eslint: 8.41.0 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -16386,6 +16434,7 @@ packages: /growly@1.3.0: resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} + requiresBuild: true optional: true /gud@1.0.0: @@ -19727,6 +19776,7 @@ packages: /lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + requiresBuild: true dev: false optional: true @@ -19746,6 +19796,7 @@ packages: /lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + requiresBuild: true dev: false optional: true @@ -24983,6 +25034,7 @@ packages: /resolve-global@1.0.0: resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} engines: {node: '>=8'} + requiresBuild: true dependencies: global-dirs: 0.1.1 dev: false @@ -25690,6 +25742,7 @@ packages: /shellwords@0.1.1: resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + requiresBuild: true optional: true /side-channel@1.0.4: @@ -27009,6 +27062,7 @@ packages: /ts-node@10.9.1(@types/node@12.20.55)(typescript@4.9.5): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true + requiresBuild: true peerDependencies: '@swc/core': '>=1.2.50' '@swc/wasm': '>=1.2.50' @@ -27691,6 +27745,7 @@ packages: /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + requiresBuild: true dev: false /v8-compile-cache@2.3.0: @@ -29149,6 +29204,7 @@ packages: /yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + requiresBuild: true dev: false /yocto-queue@0.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index afed93166..2a34bfeeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,4 @@ packages: - "zilliqa/js/zilliqa" - "examples/zilliqa-js/latest-block" - "examples/zilliqa-js/react-zilliqa-js" + - "zilliqa/products/bridge" diff --git a/products/bridge/.gitignore b/products/bridge/.gitignore new file mode 100644 index 000000000..4725ed22c --- /dev/null +++ b/products/bridge/.gitignore @@ -0,0 +1,4 @@ +artifacts +cache +node_modules +typechain-types diff --git a/products/bridge/README.md b/products/bridge/README.md new file mode 100644 index 000000000..2093dd11d --- /dev/null +++ b/products/bridge/README.md @@ -0,0 +1,195 @@ +# Universal Cross-Chain Bridge (UCCB) + +UCCB allows bridged contracts deployed on the Zilliqa chain to call contracts on other EVM chains. These asynchronous remote calls can be read-only or can modify the state of the target contract. The result of the calls - values returned by the target contract or errors that occured during the calls - are delivered to the caller contract on the Zilliqa chain. The remote calls get dispatched and their results get delivered back to the caller contract only if the supermajority of the Zilliqa validators confirmed them. The confirmations are validator signatures collected on the Zilliqa chain. The bridge can be used bidirectionally i.e. contracts on other EVM chains can also call contracts deployed on the Zilliqa chain. + +Note that this is not a trustless bridge. You should only use it if you trust the Zilliqa PoS validators as you will do when using the Zilliqa 2.0 chain. + +## Overview + +Contracts that want to make remote calls must be deployed on the remote chains at the same address as on Ziliqa. We call the contract deployed on the remote chain a **twin** **contract**. Its role is to forward the remote calls to target contracts from the address of the caller contract deployed on the Zilliqa chain. + +Remote calls are relayed by a **relayer** **contract** on the Zilliqa chain and dispatched to the twin by another relayer contract on the remote chain. The caller contract sends the target address, the call data and the callback function selector used for handling the result to the relayer. The relayer contract emits a `Relayed` event containing these data and a nonce. When validators see the event in a finalized block, they confirm the relayed call by signing the hash of the data included in the event. + +![Relaying and dispatching remote calls](docs/diagrams/bridge1.png "Relaying and dispatching remote calls") + +The relayer contract on the Zilliqa chain is also a **collector** **contract** which receives validator signatures, verifies them against the current validator set and emits an `Echoed` event containing the signed hash and the signature. The validator set is retrieved by calling the collector's `getValidators()` function. When the Zilliqa consensus leader sees that events containing signatures by the supermajority of the validators got finalized, it can dispatch the relayed call by sending it to the relayer on the remote chain along with the signatures of the supermajority. Before doing so, it filters `Dispatched` events emitted by the relayer contract up to the latest block of the remote chain to find out if the previous leaders have already attempted to dispatch the call. If it does not find any event, it submits a transaction to the relay contract and passes the relayed call, its target, callback and nonce as parameters along with the signatures of the supermajorty of validators. The relayer contract verifies the signatures against the current validator set and invokes the twin contract, which sends the call data to the target contract with a gas limit that ensures there is anough gas left to complete the transaction by emitting an event that contains the success flag and the values returned by the target contract or an error that occured. + +When the Zilliqa validators see the finalized event, they sign the hash of the success flag, the result and the nonce, and submit their signatures to the collector contract on the Zilliqa chain. When events containing signatures of the supermajority of the validators get finalized, the next leader can submit a transaction that resumes the execution of the caller contract by passing it the result of the remote call. Before doing so, it filters `Resumed` events up to the latest block to check if another leader has already attempted to deliver the result to the caller contract. The success flag and the result are delivered as parameters of a call to a function that was specified by the caller contract as the callback handler. + +![Delivering the result of remote calls](docs/diagrams/bridge2.png "Delivering the result of remote calls") + +During the entire process, Zilliqa validators must keep track of pending remote calls. Remote calls can be uniquely identified based on the caller contract and the nonce. + +### Read-only Calls + +The caller contract can also request a read-only remote call. In this case, the Zilliqa validators can perform the remote call independently from each other, and submit their signed success flags and results to the collector contract on the Zilliqa chain. As soon as the supermajority of them confirmed the result, a leader with deliver it to the caller contract. + +![Read-only remote calls](docs/diagrams/bridge3.png "Read-only remote calls") + +### Limitations + +There are a few things to keep in mind when designing applications using the protocol: + +- the nonce prevents reply attacks but does not impose the ordering of remote calls +- twins have different states on both chains (but they can use remote calls targeted at themselves to exchange relevant storage variables) +- remote calls and the transactions that requested them are not executed atomically (if a remote call does not succeed, the caller contract can request a retry in the callback function) +- if the fallback function reverts, validators will not retry to deliver the result by calling it again + +### Deployment + +To ensure that twins have the same address on both chains, the prototype deploys them using the same EOA with nonces synced accross chains. This shoud be replaced by counterfactual deployment using a factory contract in the future. + +![Deployment of bridges contracts](docs/diagrams/bridge0.png "Deployment of bridges contracts") + +### Incentives + +There must be incentives for the validators to confirm and dispatch remote calls and deliver their results. The rewards will be managed by the relay contracts on both chains and will be assigned to validators based on their signatures confirming remote calls and results. Rewards will be deducted from deposits someone must make in advance on behalf of the twins, but twin contracts can use payable function or other means to enforce reimbursement of the costs of remote calls by the users. Furthermore, the relayer cotracts on the respective chain refund the gas used for dispatching remote calls and delivering results to the first validator submitting the corresponding transactions. + +## Setup + +Clone this repository and install Hardhat and other dependencies: + +`npm install` + +Start two standalone Hardhat networks: + +`npx hardhat node` + +`npx hardhat node --port 8546` + +Restart the networks if the tests are getting slower. + +Alternative, you can change the settings of `net1` and `net2` in `hardhat.config.js` to use the networks of your choice. + +If you want to run the tests with accounts other than the default accounts used by Hardhat networks, don't forget to replace the validator set which is currently hardcoded in the `getValidators()` function in `Bridge.sol`. + +Run the tests: + +`npx hardhat test` + +The tests deploy the relayers, twins and target contracts on both networks and mimic the bevavior of validators reacting to events, confirming and dispatching remote calls, and confirming and delivering their results. + +## Usage + +Bridged contracts must inherit from `Bridged` and receive the relayer address in a call to `setRelayer()` before using remote calls. Afterwards, the bridged contract can request remote calls by calling + +```js +uint nonce = relay(target, abi.encodeWithSignature("", ), readonly, this..selector); +``` + +where `` is the string form of the selector of a function implemented by the target contract that will be called with the list of `` as specified, and `` is the name of a function implemented by the current contract that will receive the result of the remote call: + +```js +function (bool success, bytes calldata result, uint nonce) public onlyRelayer { + if (success) { + () = abi.decode(result, ()); + ... + } else { + bytes4 sig = bytes4(res[:4]); + bytes memory err = bytes(res[4:]); + string memory str = abi.decode(err, (string)); + ... + } +} +``` + +where `` is a list of the data types of the values returned by the function of the target contract specified above and `` is a list of local variables to store the decoded values. The data types of `` must be the same as specified in ``. The `sig` variable contains the first 4 bytes of `keccak256("Error(string)")` or the same for a custom error signature. The `err` variable contains the ABI encoded parameters defined by the custom error or the single `string` value which is decoded and stored in the `str` variable in case there are no other parameters. The `nonce` can be used to assign the result values to a remote call instance as multiple transactions can trigger simultaneous remote calls which are identical except for the nonce. The `onlyRelayer` modifier ensures that the function can only be called by the relayer. + +## Testing + +The `utils.js` file contains functions that can be used to mimic the behavior of validator during testing: + +- `obtainCalls()` to retrieve relayed calls from the events emitted by the relayer +- `confirmCall()` to submit signatures confirming a relayed call +- `queryCall()` to call a requested view function of a target contract +- `dispatchCall()` to dispatch a relayed call to a target contract in a transaction +- `confirmResult()` to submit signatures confirming the results of a relayed call +- `deliverResuult()` to deliver the results by calling the specified callback function + +Furthermore, the `switchNetwork()` function allows to switch between the Zilliqa chain (1) and the remote chain (2). Due to changing the network used by the Hardhat Runtime Environment, there are a few limitations with regards to Hardhat tests: + +- The Hardhat gas reporter doesn't work, it prints an empty table. However, the relevant transactions can be easily identified in the output of the standalone networks based on console.log followed by the function names, to find out how much gas they consumed. +- Hardhat fixtures can't be used as the logs on the chain that was not selected by the switch are preserved across tests and make the tests interfere with each other. + +## Security + +Some of the anticipated vulnerabilities and attack vectors: + +- the leader on the Zilliqa 2.0 chain rotates every 2s, but the blocks of the other EVM chain take longer (e.g. 12s for the Ethereum Mainnet), therefore if the first leader dispatches a call, the next few leaders could miss the Dispatched event in the pending block on the other EVM chain + - at worst, multiple honest validators attempt to dispatch the same call and pay a small fee as the relayer will only refund the first one and revert the redundant transactions +- a malicious leader can neglect to dispatch the call and allege it didn't know the blocks containing the echos had already been finalized + - sooner or later a honest leader will dispatch the signatures so malicious leaders can only delay but not prevent it +- a malicious validator can dispatch a call without being the leader or before the blocks containing the echoes get finalized + - the malicious validator dispatching the call won't earn more than the other validators that provided a signature, it only gets refunded the portion of the gas fees that were spent during dispatching the call but not before or after that +- twin contracts that can call malicious contracts or be malicious themselves and try to drain the balance of the leader dispatching the call + - the relayer calls the twin contract with gas limited to the twin's deposit, and as the twin can't cause the transaction to revert, the relayer will always refund the gas fees from the twin's deposit + +## Performance + +Cross-chain calls incur both a delay and a cost overhead. + +When sending from Zilliqa 2.0 to another chain, the **minimum delay** is 5 blocks because + +- the finalization of the event emitted by the relayer contract takes +2 block cycles +- the finalization of the signatures submitted by the validators take +2 block cycles + +In the opposite direction, the finality of the other chain applies at the first point above. + +The **gas overhead** is as follows: + +- approx. 33k gas per validator signature submitted on Zilliqa 2.0 costs 0.15 ZIL or 0.0025 USD +- approx. 123k + 4.2k gas per signature verified on the target chain + +On Ethereum as target chain, the second point above would currently cost around 0.00086 ETH or 1.41 USD + 0.00003 ETH or 0.05 USD per signature + +## Future Improvements + +Features to be implemented include: + +- **Validator compensation**: manage deposits made on behalf of twin contracts, deduct payments for relayed calls and assign them as rewards to validators based on their confirmations, enable withdrawal of rewards, and refund the gas fee to the first validator dispatching the relayed call or delivering its result +- **Bridge deployment**: add a factory for counterfactual deployment of bridged contracts at the same address on all chains (which requires the initcode passed to the factory to contain the same argument values on all chains) +- **Proof of Stake support**: validator signatures should be weighted by their stake, and the supermajority met when more than 2/3 of the stake held by the validator set has confirmed a relayed call or its result +- **Multichain support**: add the chainid as an argument to the relay requests and events to let the validators know on which chain to dispatch, along with the relayer address to ensure that the call gets dispatched via the prefered relayer contract +- **Gas settings**: add gas limit and gas price to the relay requests and events that shall be applied in the calls on the target chain +- **Multicall support**: implement support for multicalls i.e. multiple targets (with the same chainid) and calls relayed to them +- **Batching support**: implement batching of relay events so that validators can confirm all of them at once by signing the root of the Merkle tree containing all events from the same finalized block + +## Applications + +Everything described above is the bottom layer in the following design: + +![UCCB layers](docs/diagrams/bridge.png "UCCB layers") + +The middle layer are cross-chain applications that use the cross-chain conctract calls enabled by the bottom layer. Typical examples are token bridges introduced in more detail below. The top layer are existing fungible tokens and NFTs that can use the specific token bridge. + +### `ERC20` token bridge + +- the token holder approves the bridge contract and calls it to bridge the approved amount +- the bridge contract transfers the amount to itself and triggers a remote call to its twin +- the twin contract mints the same amount of the wrapped `ERC20` token to the token holder's address +- the bridge contract emits an event in the callback function to inform the token holder +- to transfer an amount back to the Zilliqa chain, a remote call in the other direction is used and the wrapped token is burned on the remote chain and the same amount of the original token is unlocked on Zilliqa + ![ERC20 token bridge](docs/diagrams/erc20.png "ERC20 token bridge") + +### `ERC721` or `ERC1155` token bridges can be implemented with a shortcut + +- the token holder transfers the token to the bridge contract which implements `ERC721Receiver` or `ERC1155Receiver` respectively +- the bridge contract triggers a remote call to its twin when `onER721Received()` or `onERC1155Received()` is called +- the rest works as described for `ERC20` above + ![ERC721 token bridge](docs/diagrams/erc721.png "ERC721 token bridge") + +### Native token bridge + +- the token holder calls a payable function of a the bridge contract to transfer some amount +- the bridge contract triggers a remote call to its twin +- the rest works as described for `ERC20` above + ![Native token bridge](docs/diagrams/native.png "Native token bridge") + +### Atomic token swaps + +- user 1 approves the swap contract on Zilliqa and calls it to swap the approved amount against some amount of another token on the other chain before the swap expires +- the swap contract transfers the approved amount of user 1 token to itself and triggers a remote call to its twin, which stores the pending swap +- user 2 approves the twin contract on the other chain and calls it to swap the approved amount against the token of user 1 +- the twin contract checks the expiration and whether the approved token and amount are as required, transfers the amount to user 1 and returns true as response +- the swap contract on Zilliqa receives the response of its twin and transfers the locked amount to user 2 +- if the swap contract does not receive a response from its twin before the expiration, user 1 can claim the approved amount and the swap contract sends it back to user 1 diff --git a/products/bridge/contracts/Bridge.sol b/products/bridge/contracts/Bridge.sol new file mode 100644 index 000000000..3a10d0f1e --- /dev/null +++ b/products/bridge/contracts/Bridge.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "hardhat/console.sol"; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +//import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +using ECDSA for bytes32; +using ECDSA for bytes; //using MessageHashUtils for bytes; + +contract Bridged { + address relayer; + + function setRelayer(address _relayer) public { + // TODO: restrict the use of this function + relayer = _relayer; + } + + modifier onlyRelayer() { + require(msg.sender == relayer, "Must be called by relayer"); + _; + } + + function dispatched( + address target, + bytes memory call + ) public payable onlyRelayer returns (bool success, bytes memory response) { + console.log("dispatched()"); + (success, response) = target.call{value: msg.value, gas: 100000}(call); + } + + function queried( + address target, + bytes memory call + ) public view onlyRelayer returns (bool success, bytes memory response) { + console.log("queried()"); + (success, response) = target.staticcall{gas: 100000}(call); + } + + function relay( + address target, + bytes memory call, + bool readonly, + bytes4 callback + ) internal returns (uint nonce) { + nonce = Relayer(relayer).relay(target, call, readonly, callback); + } +} + +contract Relayer { + function getValidators() public view returns (address[] memory validators) { + validators = new address[](18); + validators[0] = address(0x70997970C51812dc3A010C7d01b50e0d17dc79C8); + validators[1] = address(0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC); + validators[2] = address(0x90F79bf6EB2c4f870365E785982E1f101E93b906); + validators[3] = address(0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65); + validators[4] = address(0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc); + validators[5] = address(0x976EA74026E726554dB657fA54763abd0C3a0aa9); + validators[6] = address(0x14dC79964da2C08b23698B3D3cc7Ca32193d9955); + validators[7] = address(0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f); + validators[8] = address(0xa0Ee7A142d267C1f36714E4a8F75612F20a79720); + validators[9] = address(0xBcd4042DE499D14e55001CcbB24a551F3b954096); + validators[10] = address(0x71bE63f3384f5fb98995898A86B02Fb2426c5788); + validators[11] = address(0xFABB0ac9d68B0B445fB7357272Ff202C5651694a); + validators[12] = address(0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec); + validators[13] = address(0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097); + validators[14] = address(0xcd3B766CCDd6AE721141F452C550Ca635964ce71); + validators[15] = address(0x2546BcD3c84621e976D8185a91A922aE77ECEc30); + validators[16] = address(0xbDA5747bFD65F08deb54cb465eB87D40e51B197E); + validators[17] = address(0xdD2FD4581271e230360230F9337D5c0430Bf44C0); + /* validators[18] = address(0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199); + */ + } + + mapping(address => uint) nonces; + mapping(address => mapping(uint => bool)) dispatched; + mapping(address => mapping(uint => bool)) resumed; + + event Relayed( + address caller, + address target, + bytes call, + bool readonly, + bytes4 callback, + uint nonce + ); + + function relay( + address target, + bytes memory call, + bool readonly, + bytes4 callback + ) public returns (uint) { + emit Relayed( + msg.sender, + target, + call, + readonly, + callback, + nonces[msg.sender] + ); + nonces[msg.sender]++; + return nonces[msg.sender]; + } + + event Dispatched( + address indexed caller, + bytes4 callback, + bool success, + bytes response, + uint indexed nonce + ); + + function dispatch( + address caller, + address target, + bytes memory call, + bytes4 callback, + uint nonce, + uint16[] memory indices, + bytes[] memory signatures + ) public { + require(!dispatched[caller][nonce], "Already dispatched"); + address[] memory validators = getValidators(); + require(3 * indices.length > 2 * validators.length, "No supermajority"); + bytes memory message = abi.encode( + caller, + target, + call, + false, + callback, + nonce + ); + bytes32 hash = message.toEthSignedMessageHash(); + for (uint i = 0; i < signatures.length; i++) { + require(i == 0 || indices[i] > indices[i - 1], "Wrong index"); + address signer = hash.recover(signatures[i]); + require(signer == validators[indices[i]], "Wrong validator"); + } + require(caller.code.length > 0); + (bool success, bytes memory response) = Bridged(caller).dispatched( + target, + call + ); + emit Dispatched(caller, callback, success, response, nonce); + dispatched[caller][nonce] = true; + } + + function query( + address caller, + address target, + bytes memory call + ) public view returns (bool success, bytes memory response) { + require(caller.code.length > 0); + (success, response) = Bridged(caller).queried(target, call); + } + + event Resumed( + address indexed caller, + bytes call, + bool success, + bytes response, + uint indexed nonce + ); + + function resume( + address caller, + bytes4 callback, + bool success, + bytes memory response, + uint nonce, + uint16[] memory indices, + bytes[] memory signatures + ) public payable { + require(!resumed[caller][nonce], "Already resumed"); + address[] memory validators = getValidators(); + require(3 * indices.length > 2 * validators.length, "No supermajority"); + bytes memory message = abi.encode( + caller, + callback, + success, + response, + nonce + ); + bytes32 hash = message.toEthSignedMessageHash(); + for (uint i = 0; i < signatures.length; i++) { + require(i == 0 || indices[i] > indices[i - 1], "Wrong index"); + address signer = hash.recover(signatures[i]); + require(signer == validators[indices[i]], "Wrong validator"); + } + bytes memory call = abi.encodeWithSelector( + callback, + success, + response, + nonce + ); + (bool success2, bytes memory response2) = caller.call{ + value: msg.value, + gas: 100000 + }(call); + emit Resumed(caller, call, success2, response2, nonce); + resumed[caller][nonce] = true; + } +} + +contract CollectorRelayer is Relayer { + event Echoed(bytes32 indexed hash, uint16 index, bytes signature); + + function echo(bytes32 hash, uint16 index, bytes memory signature) public { + address[] memory validators = getValidators(); + address signer = hash.recover(signature); + require(signer == validators[index], "Wrong validator"); + emit Echoed(hash, index, signature); + } +} diff --git a/products/bridge/contracts/ERC20Bridge.sol b/products/bridge/contracts/ERC20Bridge.sol new file mode 100644 index 000000000..0e90b7b88 --- /dev/null +++ b/products/bridge/contracts/ERC20Bridge.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "./Bridge.sol"; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; + +contract MyToken is ERC20, ERC20Burnable { + address bridge; + + constructor(address _bridge) ERC20("MyToken", "MTK") { + bridge = _bridge; + _mint(msg.sender, 1000); + } + + function mint(address to, uint256 amount) public { + require(msg.sender == bridge, "Not the bridge"); + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + require(msg.sender == bridge, "Not the bridge"); + burnFrom(from, amount); + } +} + +contract ERC20Bridge is Bridged { + + event Started(address, address, uint); + + function bridge(address token, address owner, uint value) public { + MyToken(token).transferFrom(owner, address(this), value); + uint nonce = relay(token, abi.encodeWithSignature("mint(address,uint256)", owner, value), false, this.finish.selector); + emit Started(token, owner, value); + } + + function exit(address token, address owner, uint value) public { + MyToken(token).burn(owner, value); + uint nonce = relay(token, abi.encodeWithSignature("transfer(address,uint256)", owner, value), false, this.finish.selector); + emit Started(token, owner, value); + } + + event Succeeded(); + event Failed(string); + + function finish(bool success, bytes calldata res, uint nonce) public onlyRelayer { + if (success) { + emit Succeeded(); + } else { + bytes4 sig = bytes4(res[:4]); + bytes memory err = bytes(res[4:]); + emit Failed(abi.decode(err, (string))); + } + } +} + diff --git a/products/bridge/contracts/Test.sol b/products/bridge/contracts/Test.sol new file mode 100644 index 000000000..492e88072 --- /dev/null +++ b/products/bridge/contracts/Test.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "./Bridge.sol"; + +contract Twin is Bridged { + function start(address target, uint num, bool readonly) public { + uint nonce = relay( + target, + abi.encodeWithSignature("test(uint256)", num), + readonly, + this.finish.selector + ); + console.log("start()", nonce); + } + + event Succeeded(uint); + event Failed(string); + + function finish( + bool success, + bytes calldata res, + uint nonce + ) public onlyRelayer { + console.log("finish()", nonce); + if (success) { + uint num = abi.decode(res, (uint)); + emit Succeeded(num); + } else { + bytes4 sig = bytes4(res[:4]); + bytes memory err = bytes(res[4:]); + emit Failed(abi.decode(err, (string))); + } + } +} + +contract Target { + function test(uint num) public pure returns (uint) { + console.log("test()"); + require(num < 1000, "Too large"); + return num + 1; + } +} diff --git a/products/bridge/docs/diagrams/bridge.png b/products/bridge/docs/diagrams/bridge.png new file mode 100644 index 000000000..f0d4d23b1 Binary files /dev/null and b/products/bridge/docs/diagrams/bridge.png differ diff --git a/products/bridge/docs/diagrams/bridge0.png b/products/bridge/docs/diagrams/bridge0.png new file mode 100644 index 000000000..f65085d59 Binary files /dev/null and b/products/bridge/docs/diagrams/bridge0.png differ diff --git a/products/bridge/docs/diagrams/bridge1.png b/products/bridge/docs/diagrams/bridge1.png new file mode 100644 index 000000000..2029650bc Binary files /dev/null and b/products/bridge/docs/diagrams/bridge1.png differ diff --git a/products/bridge/docs/diagrams/bridge2.png b/products/bridge/docs/diagrams/bridge2.png new file mode 100644 index 000000000..a8d374320 Binary files /dev/null and b/products/bridge/docs/diagrams/bridge2.png differ diff --git a/products/bridge/docs/diagrams/bridge3.png b/products/bridge/docs/diagrams/bridge3.png new file mode 100644 index 000000000..d43c3c2f7 Binary files /dev/null and b/products/bridge/docs/diagrams/bridge3.png differ diff --git a/products/bridge/docs/diagrams/erc20.png b/products/bridge/docs/diagrams/erc20.png new file mode 100644 index 000000000..1aadedc6a Binary files /dev/null and b/products/bridge/docs/diagrams/erc20.png differ diff --git a/products/bridge/docs/diagrams/erc721.png b/products/bridge/docs/diagrams/erc721.png new file mode 100644 index 000000000..f9a2c4e03 Binary files /dev/null and b/products/bridge/docs/diagrams/erc721.png differ diff --git a/products/bridge/docs/diagrams/native.png b/products/bridge/docs/diagrams/native.png new file mode 100644 index 000000000..fb73d1ceb Binary files /dev/null and b/products/bridge/docs/diagrams/native.png differ diff --git a/products/bridge/hardhat.config.ts b/products/bridge/hardhat.config.ts new file mode 100644 index 000000000..386101263 --- /dev/null +++ b/products/bridge/hardhat.config.ts @@ -0,0 +1,27 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + mocha: { + timeout: 2000000, + }, + networks: { + net1: { + url: `http://127.0.0.1:8545/`, + }, + net2: { + url: `http://127.0.0.1:8546/`, + }, + }, +}; + +export default config; diff --git a/products/bridge/package.json b/products/bridge/package.json new file mode 100644 index 000000000..24a35a40f --- /dev/null +++ b/products/bridge/package.json @@ -0,0 +1,23 @@ +{ + "name": "hardhat-project", + "scripts": { + "compile": "hardhat compile", + "node": "hardhat node", + "node:two": "hardhat node --port 8546", + "test": "hardhat test" + }, + "devDependencies": { + "@nomicfoundation/hardhat-toolbox": "^3.0.0", + "@types/chai": "^4.3.9", + "@types/mocha": "^10.0.3", + "@types/node": "^20.8.10", + "chai": "^4.3.10", + "ethers": "^6.8.1", + "hardhat": "^2.19.0", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + }, + "dependencies": { + "@openzeppelin/contracts": "^5.0.0" + } +} diff --git a/products/bridge/scripts/deploy.js b/products/bridge/scripts/deploy.js new file mode 100644 index 000000000..670537368 --- /dev/null +++ b/products/bridge/scripts/deploy.js @@ -0,0 +1,18 @@ +// We require the Hardhat Runtime Environment explicitly here. This is optional +// but useful for running the script in a standalone fashion through `node