-
Notifications
You must be signed in to change notification settings - Fork 75
feat: Implement ZkSync Adapter and SpokePool #180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3c32e71
d6a6e7a
91feed3
0dac30f
233d4d6
cb00b19
625582f
6473e2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,3 +9,5 @@ typechain-types | |
| #Hardhat files | ||
| cache | ||
| artifacts | ||
| cache-zk | ||
| artifacts-zk | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // SPDX-License-Identifier: GPL-3.0-only | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "./SpokePool.sol"; | ||
|
|
||
| interface ZkBridgeLike { | ||
| function withdraw( | ||
| address _to, | ||
| address _l2Token, | ||
| uint256 _amount | ||
| ) external; | ||
| } | ||
|
|
||
| /** | ||
| * @notice ZkSync specific SpokePool, intended to be compiled with `@matterlabs/hardhat-zksync-solc`. | ||
| */ | ||
| contract ZkSync_SpokePool is SpokePool { | ||
| // On Ethereum, avoiding constructor parameters and putting them into constants reduces some of the gas cost | ||
| // upon contract deployment. On zkSync the opposite is true: deploying the same bytecode for contracts, | ||
| // while changing only constructor parameters can lead to substantial fee savings. So, the following params | ||
| // are all set by passing in constructor params where possible. | ||
|
|
||
| // However, this contract is expected to be deployed only once to ZkSync. Therefore, we should consider the cost | ||
| // of reading mutable vs immutable storage. On Ethereum, mutable storage is more expensive than immutable bytecode. | ||
| // But, we also want to be able to upgrade certain state variables. | ||
|
|
||
| // Bridge used to withdraw ERC20's to L1: https://github.com/matter-labs/v2-testnet-contracts/blob/3a0651357bb685751c2163e4cc65a240b0f602ef/l2/contracts/bridge/L2ERC20Bridge.sol | ||
| ZkBridgeLike public zkErc20Bridge; | ||
|
|
||
| // Bridge used to send ETH to L1: https://github.com/matter-labs/v2-testnet-contracts/blob/3a0651357bb685751c2163e4cc65a240b0f602ef/l2/contracts/bridge/L2ETHBridge.sol | ||
| ZkBridgeLike public zkEthBridge; | ||
|
|
||
| event SetZkBridges(address indexed erc20Bridge, address indexed ethBridge); | ||
| event ZkSyncTokensBridged(address indexed l2Token, address target, uint256 numberOfTokensBridged); | ||
|
|
||
| /** | ||
| * @notice Construct the ZkSync SpokePool. | ||
| * @param _zkErc20Bridge Address of L2 ERC20 gateway. Can be reset by admin. | ||
| * @param _zkEthBridge Address of L2 ETH gateway. Can be reset by admin. | ||
| * @param _crossDomainAdmin Cross domain admin to set. Can be changed by admin. | ||
| * @param _hubPool Hub pool address to set. Can be changed by admin. | ||
| * @param _wethAddress Weth address for this network to set. | ||
| * @param timerAddress Timer address to set. | ||
| */ | ||
| constructor( | ||
| ZkBridgeLike _zkErc20Bridge, | ||
| ZkBridgeLike _zkEthBridge, | ||
| address _crossDomainAdmin, | ||
| address _hubPool, | ||
| address _wethAddress, | ||
| address timerAddress | ||
| ) SpokePool(_crossDomainAdmin, _hubPool, _wethAddress, timerAddress) { | ||
| _setZkBridges(_zkErc20Bridge, _zkEthBridge); | ||
| } | ||
|
|
||
| modifier onlyFromCrossDomainAdmin() { | ||
| // Formal msg.sender of L1 --> L2 message will be L1 sender. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just want to be super clear here. The msg.sender that initiates a bridge transaction on L1 will have the transaction on L2 reflect that exact same msg.sender address? There's no aliasing, like on arbitrum? Consider the following scenario:
Is that possible? My understanding was that Arbitrum created aliases to prevent this scenario. Does Zk-Sync have something to stop that from happening?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm confirming with zksync dev support but yes this is my understanding as of now. Also, empirically this authentication has worked for goerli --> zkSync goerli messages |
||
| require(msg.sender == crossDomainAdmin, "Invalid sender"); | ||
| _; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns chain ID for this network. | ||
| * @dev ZKSync doesn't yet support the CHAIN_ID opcode so we override this, but it will be supported by mainnet | ||
| * launch supposedly: https://v2-docs.zksync.io/dev/zksync-v2/temp-limits.html#temporarily-simulated-by-constant-values | ||
nicholaspai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| */ | ||
| function chainId() public pure override returns (uint256) { | ||
| return 280; | ||
| } | ||
|
|
||
| /******************************************************** | ||
| * ZKSYNC-SPECIFIC CROSS-CHAIN ADMIN FUNCTIONS * | ||
| ********************************************************/ | ||
|
|
||
| /** | ||
| * @notice Change L2 token bridge addresses. Callable only by admin. | ||
| * @param _zkErc20Bridge New address of L2 ERC20 gateway. | ||
| * @param _zkEthBridge New address of L2 ETH gateway. | ||
| */ | ||
| function setZkBridges(ZkBridgeLike _zkErc20Bridge, ZkBridgeLike _zkEthBridge) public onlyAdmin nonReentrant { | ||
| _setZkBridges(_zkErc20Bridge, _zkEthBridge); | ||
| } | ||
|
|
||
| /************************************** | ||
| * INTERNAL FUNCTIONS * | ||
| **************************************/ | ||
|
|
||
| function _bridgeTokensToHubPool(RelayerRefundLeaf memory relayerRefundLeaf) internal override { | ||
| (relayerRefundLeaf.l2TokenAddress == address(wrappedNativeToken) ? zkEthBridge : zkErc20Bridge).withdraw( | ||
| hubPool, | ||
| // Note: If ETH, must use 0x0: https://github.com/matter-labs/v2-testnet-contracts/blob/3a0651357bb685751c2163e4cc65a240b0f602ef/l2/contracts/bridge/L2ETHBridge.sol#L57 | ||
| relayerRefundLeaf.l2TokenAddress == address(wrappedNativeToken) | ||
| ? address(0) | ||
| : relayerRefundLeaf.l2TokenAddress, | ||
| relayerRefundLeaf.amountToReturn | ||
| ); | ||
|
|
||
| emit ZkSyncTokensBridged(relayerRefundLeaf.l2TokenAddress, hubPool, relayerRefundLeaf.amountToReturn); | ||
| } | ||
|
|
||
| function _setZkBridges(ZkBridgeLike _zkErc20Bridge, ZkBridgeLike _zkEthBridge) internal { | ||
| zkErc20Bridge = _zkErc20Bridge; | ||
| zkEthBridge = _zkEthBridge; | ||
| emit SetZkBridges(address(_zkErc20Bridge), address(_zkEthBridge)); | ||
| } | ||
|
|
||
| function _requireAdminSender() internal override onlyFromCrossDomainAdmin {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "../interfaces/AdapterInterface.sol"; | ||
| import "../interfaces/WETH9.sol"; | ||
|
|
||
| import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
| import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
|
|
||
| // Importing `Operations` contract which has the `QueueType` type | ||
| import "@matterlabs/zksync-contracts/l1/contracts/zksync/Operations.sol"; | ||
|
|
||
| interface ZkSyncLike { | ||
| function requestL2Transaction( | ||
| address _contractAddressL2, | ||
| bytes calldata _calldata, | ||
| uint256 _ergsLimit, | ||
| bytes[] calldata _factoryDeps, | ||
| QueueType _queueType | ||
| ) external payable returns (bytes32 txHash); | ||
| } | ||
|
|
||
| interface ZkBridgeLike { | ||
| function deposit( | ||
| address _to, | ||
| address _l1Token, | ||
| uint256 _amount, | ||
| QueueType _queueType | ||
| ) external payable returns (bytes32 txHash); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Contract containing logic to send messages from L1 to ZkSync. | ||
| * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be | ||
| * called via delegatecall, which will execute this contract's logic within the context of the originating contract. | ||
| * For example, the HubPool will delegatecall these functions, therefore its only necessary that the HubPool's methods | ||
| * that call this contract's logic guard against reentrancy. | ||
| */ | ||
|
|
||
| // solhint-disable-next-line contract-name-camelcase | ||
| contract ZkSync_Adapter is AdapterInterface { | ||
| using SafeERC20 for IERC20; | ||
|
|
||
| // We need to pay a fee to submit transactions to the L1 --> L2 priority queue: | ||
| // https://v2-docs.zksync.io/dev/zksync-v2/l1-l2-interop.html#priority-queue | ||
|
|
||
| // The fee for a transactionis equal to `txBaseCost * gasPrice` where `txBaseCost` depends on the ergsLimit | ||
| // (ergs = gas on ZkSync) and the calldata length. More details here: | ||
| // https://v2-docs.zksync.io/dev/guide/l1-l2.html#using-contract-interface-in-your-project | ||
|
|
||
| // Generally, the ergsLimit and l2GasPrice params are a bit hard to set and may change in the future once ZkSync | ||
| // is deployed to mainnet. On testnet, gas price is set to 0 and gas used is 0 so its hard to accurately forecast. | ||
| uint256 public immutable l2GasPrice = 1e9; | ||
|
|
||
| uint32 public immutable ergsLimit = 1_000_000; | ||
|
|
||
| // Hardcode WETH address for L1 since it will not change: | ||
| WETH9 public immutable l1Weth = WETH9(0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6); | ||
|
|
||
| // Hardcode the following ZkSync system contract addresses to save gas on construction. This adapter can be | ||
| // redeployed in the event that the following addresses change. | ||
|
|
||
| // Main contract used to send L1 --> L2 messages. Fetchable via `zks_getMainContract` method on JSON RPC. | ||
| ZkSyncLike public immutable zkSync = ZkSyncLike(0xa0F968EbA6Bbd08F28Dc061C7856C15725983395); | ||
| // Bridges to send ERC20 and ETH to L2. Fetchable via `zks_getBridgeContracts` method on JSON RPC. | ||
| ZkBridgeLike public immutable zkErc20Bridge = ZkBridgeLike(0x7786255495348c08F82C09C82352019fAdE3BF29); | ||
| ZkBridgeLike public immutable zkEthBridge = ZkBridgeLike(0xcbebcD41CeaBBC85Da9bb67527F58d69aD4DfFf5); | ||
|
|
||
| event ZkSyncMessageRelayed(bytes32 txHash); | ||
|
|
||
| /** | ||
| * @notice Send cross-chain message to target on ZkSync. | ||
| * @notice This contract must hold at least getL1CallValue() amount of ETH to send a message, or the message | ||
| * will get stuck. | ||
| * @param target Contract on Arbitrum that will receive message. | ||
| * @param message Data to send to target. | ||
| */ | ||
| function relayMessage(address target, bytes memory message) external payable override { | ||
| uint256 txBaseCost = _contractHasSufficientEthBalance(); | ||
|
|
||
| // Parameters passed to requestL2Transaction: | ||
| // _contractAddressL2 is a parameter that defines the address of the contract to be called. | ||
| // _calldata is a parameter that contains the calldata of the transaction call. It can be encoded the | ||
| // same way as on Ethereum. | ||
| // _ergsLimit is a parameter that contains the ergs limit of the transaction call. You can learn more about | ||
| // ergs and the zkSync fee system here: https://v2-docs.zksync.io/dev/zksync-v2/fee-model.html | ||
| // _factoryDeps is a list of bytecodes. It should contain the bytecode of the contract being deployed. | ||
| // If the contract being deployed is a factory contract, i.e. it can deploy other contracts, the array should also contain the bytecodes of the contracts that can be deployed by it. | ||
| // _queueType is a parameter required for the priority mode functionality. For the testnet, | ||
| // QueueType.Deque should always be supplied. | ||
| bytes32 txHash = zkSync.requestL2Transaction{ value: txBaseCost }( | ||
| target, | ||
| message, | ||
| ergsLimit, | ||
| new bytes[](0), | ||
| QueueType.Deque | ||
| ); | ||
|
|
||
| emit MessageRelayed(target, message); | ||
| emit ZkSyncMessageRelayed(txHash); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Bridge tokens to ZkSync. | ||
| * @notice This contract must hold at least getL1CallValue() amount of ETH to send a message | ||
| * or the message will get stuck. | ||
| * @param l1Token L1 token to deposit. | ||
| * @param l2Token L2 token to receive. | ||
| * @param amount Amount of L1 tokens to deposit and L2 tokens to receive. | ||
| * @param to Bridge recipient. | ||
| */ | ||
| function relayTokens( | ||
| address l1Token, | ||
| address l2Token, // l2Token is unused. | ||
| uint256 amount, | ||
| address to | ||
| ) external payable override { | ||
| uint256 txBaseCost = _contractHasSufficientEthBalance(); | ||
|
|
||
| // If the l1Token is WETH then unwrap it to ETH then send the ETH to the standard bridge along with the base | ||
| // cost. | ||
| bytes32 txHash; | ||
| if (l1Token == address(l1Weth)) { | ||
| l1Weth.withdraw(amount); | ||
| // Must set L1Token address to 0x0: https://github.com/matter-labs/v2-testnet-contracts/blob/3a0651357bb685751c2163e4cc65a240b0f602ef/l1/contracts/bridge/L1EthBridge.sol#L78 | ||
| txHash = zkEthBridge.deposit{ value: txBaseCost + amount }(to, address(0), amount, QueueType.Deque); | ||
| } else { | ||
| IERC20(l1Token).safeIncreaseAllowance(address(zkErc20Bridge), amount); | ||
| txHash = zkErc20Bridge.deposit{ value: txBaseCost }(to, l1Token, amount, QueueType.Deque); | ||
| } | ||
|
|
||
| emit TokensRelayed(l1Token, l2Token, amount, to); | ||
| emit ZkSyncMessageRelayed(txHash); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns required amount of ETH to send a message. | ||
| * @return amount of ETH that this contract needs to hold in order for relayMessage to succeed. | ||
| */ | ||
| function getL1CallValue() public pure returns (uint256) { | ||
| return l2GasPrice * ergsLimit; | ||
| } | ||
|
|
||
| function _contractHasSufficientEthBalance() internal view returns (uint256 requiredL1CallValue) { | ||
| requiredL1CallValue = getL1CallValue(); | ||
| require(address(this).balance >= requiredL1CallValue, "Insufficient ETH balance"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import "hardhat-deploy"; | ||
| import { HardhatRuntimeEnvironment } from "hardhat/types/runtime"; | ||
|
|
||
| const func = async function (hre: HardhatRuntimeEnvironment) { | ||
| const { deployments, getNamedAccounts } = hre; | ||
|
|
||
| const { deploy } = deployments; | ||
|
|
||
| const { deployer } = await getNamedAccounts(); | ||
|
|
||
| await deploy("ZkSync_Adapter", { | ||
| from: deployer, | ||
| log: true, | ||
| skipIfAlreadyDeployed: true, | ||
| args: [], | ||
| }); | ||
| }; | ||
|
|
||
| module.exports = func; | ||
| func.tags = ["ZkSyncAdapter", "mainnet"]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import "hardhat-deploy"; | ||
| import { HardhatRuntimeEnvironment } from "hardhat/types/runtime"; | ||
|
|
||
| import { L2_ADDRESS_MAP } from "./consts"; | ||
|
|
||
| const func = async function (hre: HardhatRuntimeEnvironment) { | ||
| const { companionNetworks, getChainId, getNamedAccounts, deployments } = hre; | ||
| const { deploy } = deployments; | ||
|
|
||
| const { deployer } = await getNamedAccounts(); | ||
|
|
||
| // Grab L1 addresses: | ||
| const { deployments: l1Deployments } = companionNetworks.l1; | ||
| const hubPool = await l1Deployments.get("HubPool"); | ||
| console.log(`Using l1 hub pool @ ${hubPool.address}`); | ||
|
|
||
| const chainId = parseInt(await getChainId()); | ||
|
|
||
| await deploy("ZkSync_SpokePool", { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 |
||
| from: deployer, | ||
| log: true, | ||
| skipIfAlreadyDeployed: true, | ||
| args: [ | ||
| L2_ADDRESS_MAP[chainId].zkErc20Bridge, | ||
| L2_ADDRESS_MAP[chainId].zkEthBridge, | ||
| hubPool.address, // Set hub pool as cross domain admin since it delegatecalls the ZkSync_Adapter logic. | ||
| hubPool.address, | ||
| L2_ADDRESS_MAP[chainId].l2Weth, // l2Weth | ||
| "0x0000000000000000000000000000000000000000", // timer | ||
| ], | ||
| }); | ||
| }; | ||
| module.exports = func; | ||
| func.tags = ["ZkSyncSpokePool", "zksync"]; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't expect to deploy many spoke pool contracts, right?
I think this mostly comes down to reads, since these values will be read for more than they will be deployed. Is reading directly from immutable bytecode cheaper or is reading from mutable storage cheaper? On ethereum mutable storage is far more expensive to read than immutable bytecode.
However, don't we typically make keep these bridge variables mutable to allow upgradability?
I guess I'm just a little confused about which variables this comment applies to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah we typically keep these variables mutable but perhaps the comment is misleading. I only included it for now as a consideration but I agree this contract should be rarely re-redeployed so i'll add that note