From 757e802096e67c98b50cac1f0c3dce62c706417f Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 13 Sep 2024 13:54:34 -0500 Subject: [PATCH 01/28] refactor: export _bridgeTokensToHubPool for Arbitrum to separate contract Signed-off-by: bennett --- .../l2/Arbitrum_WithdrawalAdapter.sol | 76 +++++++++++ .../local/Arbitrum_WithdrawalAdapter.t.sol | 129 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol create mode 100644 test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol new file mode 100644 index 000000000..309fe69f3 --- /dev/null +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: BUSL-1.1 + +// Arbitrum only supports v0.8.19 +// See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum +pragma solidity ^0.8.19; + +import "../../libraries/CircleCCTPAdapter.sol"; +import { StandardBridgeLike } from "../../Arbitrum_SpokePool.sol"; + +/** + * @notice AVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. + * @custom:security-contact bugs@across.to + */ + +/* + * @notice Interface for the Across Arbitrum_SpokePool contract. Used to access state which + * can only be modified by admin functions. + */ +interface IArbitrum_SpokePool { + function whitelistedTokens(address) external view returns (address); +} + +/** + * @title Adapter for interacting with bridges from the Arbitrum One L2 to Ethereum mainnet. + * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. + */ +contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { + IArbitrum_SpokePool public immutable spokePool; + address public immutable tokenRetriever; + address public immutable l2GatewayRouter; + + /* + * @notice constructs the withdrawal adapter. + * @param _l2Usdc address of native USDC on the L2. + * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. + * @param _spokePool address of the spoke pool on L2. + * @param _tokenRetriever L1 address of the recipient of withdrawals. + * @param _l2GatewayRouter address of the Arbitrum l2 gateway router contract. + */ + constructor( + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger, + IArbitrum_SpokePool _spokePool, + address _tokenRetriever, + address _l2GatewayRouter + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { + spokePool = _spokePool; + tokenRetriever = _tokenRetriever; + l2GatewayRouter = _l2GatewayRouter; + } + + /* + * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the `tokenRetriever`. The + * bridge will not be called if the token is not in the Arbitrum_SpokePool's `whitelistedTokens` mapping. + * @param amountToReturn amount of l2Token to send back to the token retriever. + * @param l2TokenAddress address of the l2Token to send back to the token retriever. + */ + function withdrawToken(uint256 amountToReturn, address l2TokenAddress) external { + // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. + if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { + _transferUsdc(tokenRetriever, amountToReturn); + } else { + // Check that the Ethereum counterpart of the L2 token is stored on this contract. + // Tokens will only be bridged if they are whitelisted by the spoke pool. + address ethereumTokenToBridge = spokePool.whitelistedTokens(l2TokenAddress); + require(ethereumTokenToBridge != address(0), "Uninitialized mainnet token"); + //slither-disable-next-line unused-return + StandardBridgeLike(l2GatewayRouter).outboundTransfer( + ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over. + tokenRetriever, // _to. Withdraw, over the bridge, to the l1 hub pool contract. + amountToReturn, // _amount. + "" // _data. We don't need to send any data for the bridging action. + ); + } + } +} diff --git a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol new file mode 100644 index 000000000..9d9043653 --- /dev/null +++ b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; +import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { Arbitrum_SpokePool, ITokenMessenger } from "../../../contracts/Arbitrum_SpokePool.sol"; +import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import "forge-std/console.sol"; + +contract Token_ERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 value) public virtual { + _mint(to, value); + } + + function burn(address from, uint256 value) public virtual { + _burn(from, value); + } +} + +contract ArbitrumGatewayRouter { + function outboundTransfer( + address tokenToBridge, + address recipient, + uint256 amountToReturn, + bytes memory + ) external returns (bytes memory) { + Token_ERC20(tokenToBridge).burn(msg.sender, amountToReturn); + Token_ERC20(tokenToBridge).mint(recipient, amountToReturn); + return ""; + } +} + +contract Arbitrum_WithdrawalAdapterTest is Test { + Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; + Arbitrum_SpokePool arbitrumSpokePool; + Token_ERC20 whitelistedToken; + Token_ERC20 usdc; + ArbitrumGatewayRouter gatewayRouter; + + // HubPool should receive funds. + address hubPool; + + address owner; + address aliasedOwner; + address wrappedNativeToken; + + // Token messenger is set so CCTP is activated. + ITokenMessenger tokenMessenger; + + function setUp() public { + whitelistedToken = new Token_ERC20("DAI", "DAI"); + usdc = new Token_ERC20("USDC", "USDC"); + gatewayRouter = new ArbitrumGatewayRouter(); + + tokenMessenger = ITokenMessenger(vm.addr(1)); + owner = vm.addr(2); + wrappedNativeToken = vm.addr(3); + hubPool = vm.addr(4); + aliasedOwner = _applyL1ToL2Alias(owner); + + vm.startPrank(owner); + Arbitrum_SpokePool implementation = new Arbitrum_SpokePool(wrappedNativeToken, 0, 0, usdc, tokenMessenger); + address proxy = address( + new ERC1967Proxy( + address(implementation), + abi.encodeCall(Arbitrum_SpokePool.initialize, (0, address(gatewayRouter), owner, owner)) + ) + ); + vm.stopPrank(); + + arbitrumSpokePool = Arbitrum_SpokePool(payable(proxy)); + arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter( + usdc, + tokenMessenger, + IArbitrum_SpokePool(proxy), + hubPool, + address(gatewayRouter) + ); + } + + function testWithdrawWhitelistedTokenNonCCTP() public { + assertEq(whitelistedToken.balanceOf(hubPool), 0); + assertEq(whitelistedToken.balanceOf(owner), 0); + assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + uint256 amountToBridge = uint256(block.timestamp + 100); + + // Whitelist tokens in the spoke pool and mint tokens to the withdrawal adapter. + vm.startPrank(aliasedOwner); + arbitrumSpokePool.whitelistToken(address(whitelistedToken), address(whitelistedToken)); + whitelistedToken.mint(address(arbitrumWithdrawalAdapter), amountToBridge); + vm.stopPrank(); + + // Attempt to withdraw token. + arbitrumWithdrawalAdapter.withdrawToken(amountToBridge, address(whitelistedToken)); + assertEq(whitelistedToken.balanceOf(hubPool), amountToBridge); + assertEq(whitelistedToken.balanceOf(owner), 0); + assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + } + + function testWithdrawOtherTokenNonCCTP() public { + assertEq(whitelistedToken.balanceOf(hubPool), 0); + assertEq(whitelistedToken.balanceOf(owner), 0); + assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + uint256 amountToBridge = uint256(block.timestamp + 100); + + // Mint tokens to the withdrawal adapter but do not whitelist it in the spoke pool. + whitelistedToken.mint(address(arbitrumWithdrawalAdapter), amountToBridge); + + // Attempt to withdraw token. + vm.expectRevert(); + arbitrumWithdrawalAdapter.withdrawToken(amountToBridge, address(whitelistedToken)); + assertEq(whitelistedToken.balanceOf(hubPool), 0); + assertEq(whitelistedToken.balanceOf(owner), 0); + assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), amountToBridge); + } + + function _applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { + // Allows overflows as explained above. + unchecked { + l2Address = address(uint160(l1Address) + uint160(0x1111000000000000000000000000000000001111)); + } + } +} From 1a49346a3f5143aadc9f5950063ba8e922359b50 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 13 Sep 2024 14:51:36 -0500 Subject: [PATCH 02/28] feat: add Intermediate_TokenRetriever contract for L3-L1 withdrawals Signed-off-by: bennett --- contracts/Intermediate_TokenRetriever.sol | 47 +++++++++++++++++++ .../local/Arbitrum_WithdrawalAdapter.t.sol | 27 ++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 contracts/Intermediate_TokenRetriever.sol diff --git a/contracts/Intermediate_TokenRetriever.sol b/contracts/Intermediate_TokenRetriever.sol new file mode 100644 index 000000000..418f5dd02 --- /dev/null +++ b/contracts/Intermediate_TokenRetriever.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.19; + +import "./Lockable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "@uma/core/contracts/common/implementation/MultiCaller.sol"; + +interface IBridgeAdapter { + function withdrawToken(uint256 amountToReturn, address l2Token) external; +} + +/** + * @notice Contract deployed on an arbitrary L2 to act as an intermediate contract for withdrawals from L3 to L1. + * @dev Since each network has its own bridging requirements, this contract delegates that logic to a bridge adapter contract + * which performs the necessary withdraw action. + */ +contract Intermediate_TokenRetriever is Lockable, MultiCaller { + using SafeERC20Upgradeable for IERC20Upgradeable; + + // Should be set to the bridge adapter which contains the proper logic to withdraw tokens on + // the deployed L2 + address public immutable bridgeAdapter; + + error WithdrawalFailed(address l2Token); + + /** + * @notice Constructs the Intermediate_TokenRetriever + * @param _bridgeAdapter contract which contains network's bridging logic. + */ + constructor(address _bridgeAdapter) { + //slither-disable-next-line missing-zero-check + bridgeAdapter = _bridgeAdapter; + } + + /** + * @notice delegatecalls the contract's stored bridge adapter to bridge tokens back to the defined token retriever + * @notice This follows the bridging logic of the corresponding bridge adapter. + * @param l2Token (current network's) contract address of the token to be withdrawn. + */ + function retrieve(address l2Token) public nonReentrant { + (bool success, ) = bridgeAdapter.delegatecall( + abi.encodeCall(IBridgeAdapter.withdrawToken, (IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token)) + ); + if (!success) revert WithdrawalFailed(l2Token); + } +} diff --git a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index 9d9043653..24293382b 100644 --- a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -6,6 +6,7 @@ import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { Arbitrum_SpokePool, ITokenMessenger } from "../../../contracts/Arbitrum_SpokePool.sol"; import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; +import { Intermediate_TokenRetriever } from "../../../contracts/Intermediate_TokenRetriever.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; @@ -38,6 +39,7 @@ contract ArbitrumGatewayRouter { contract Arbitrum_WithdrawalAdapterTest is Test { Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; + Intermediate_TokenRetriever tokenRetriever; Arbitrum_SpokePool arbitrumSpokePool; Token_ERC20 whitelistedToken; Token_ERC20 usdc; @@ -45,7 +47,6 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // HubPool should receive funds. address hubPool; - address owner; address aliasedOwner; address wrappedNativeToken; @@ -53,8 +54,10 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // Token messenger is set so CCTP is activated. ITokenMessenger tokenMessenger; + error WithdrawalFailed(address l2Token); + function setUp() public { - whitelistedToken = new Token_ERC20("DAI", "DAI"); + whitelistedToken = new Token_ERC20("TOKEN", "TOKEN"); usdc = new Token_ERC20("USDC", "USDC"); gatewayRouter = new ArbitrumGatewayRouter(); @@ -82,42 +85,40 @@ contract Arbitrum_WithdrawalAdapterTest is Test { hubPool, address(gatewayRouter) ); + tokenRetriever = new Intermediate_TokenRetriever(address(arbitrumWithdrawalAdapter)); } function testWithdrawWhitelistedTokenNonCCTP() public { assertEq(whitelistedToken.balanceOf(hubPool), 0); assertEq(whitelistedToken.balanceOf(owner), 0); - assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); uint256 amountToBridge = uint256(block.timestamp + 100); // Whitelist tokens in the spoke pool and mint tokens to the withdrawal adapter. vm.startPrank(aliasedOwner); arbitrumSpokePool.whitelistToken(address(whitelistedToken), address(whitelistedToken)); - whitelistedToken.mint(address(arbitrumWithdrawalAdapter), amountToBridge); + whitelistedToken.mint(address(tokenRetriever), amountToBridge); vm.stopPrank(); // Attempt to withdraw token. - arbitrumWithdrawalAdapter.withdrawToken(amountToBridge, address(whitelistedToken)); + tokenRetriever.retrieve(address(whitelistedToken)); assertEq(whitelistedToken.balanceOf(hubPool), amountToBridge); assertEq(whitelistedToken.balanceOf(owner), 0); - assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); } function testWithdrawOtherTokenNonCCTP() public { assertEq(whitelistedToken.balanceOf(hubPool), 0); assertEq(whitelistedToken.balanceOf(owner), 0); - assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), 0); + assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); uint256 amountToBridge = uint256(block.timestamp + 100); // Mint tokens to the withdrawal adapter but do not whitelist it in the spoke pool. - whitelistedToken.mint(address(arbitrumWithdrawalAdapter), amountToBridge); + whitelistedToken.mint(address(tokenRetriever), amountToBridge); // Attempt to withdraw token. - vm.expectRevert(); - arbitrumWithdrawalAdapter.withdrawToken(amountToBridge, address(whitelistedToken)); - assertEq(whitelistedToken.balanceOf(hubPool), 0); - assertEq(whitelistedToken.balanceOf(owner), 0); - assertEq(whitelistedToken.balanceOf(address(arbitrumWithdrawalAdapter)), amountToBridge); + vm.expectRevert(abi.encodeWithSelector(WithdrawalFailed.selector, address(whitelistedToken))); + tokenRetriever.retrieve(address(whitelistedToken)); } function _applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { From ac13aacfab7f8b6e3df501472ef2011b147ff7d4 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 16 Sep 2024 10:12:34 -0500 Subject: [PATCH 03/28] generalize bridge adapter contract Signed-off-by: bennett --- contracts/Intermediate_TokenRetriever.sol | 17 ++++++-- .../l2/Arbitrum_WithdrawalAdapter.sol | 42 +++++++++++++++---- .../local/Arbitrum_WithdrawalAdapter.t.sol | 34 ++++++++------- 3 files changed, 67 insertions(+), 26 deletions(-) diff --git a/contracts/Intermediate_TokenRetriever.sol b/contracts/Intermediate_TokenRetriever.sol index 418f5dd02..a393c21c9 100644 --- a/contracts/Intermediate_TokenRetriever.sol +++ b/contracts/Intermediate_TokenRetriever.sol @@ -7,7 +7,11 @@ import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeab import "@uma/core/contracts/common/implementation/MultiCaller.sol"; interface IBridgeAdapter { - function withdrawToken(uint256 amountToReturn, address l2Token) external; + function withdrawToken( + address recipient, + uint256 amountToReturn, + address l2Token + ) external; } /** @@ -21,16 +25,20 @@ contract Intermediate_TokenRetriever is Lockable, MultiCaller { // Should be set to the bridge adapter which contains the proper logic to withdraw tokens on // the deployed L2 address public immutable bridgeAdapter; + // Should be set to the L1 address which will receive withdrawn tokens. + address public immutable tokenRetriever; error WithdrawalFailed(address l2Token); /** * @notice Constructs the Intermediate_TokenRetriever * @param _bridgeAdapter contract which contains network's bridging logic. + * @param _tokenRetriever L1 address of the recipient of withdrawn tokens. */ - constructor(address _bridgeAdapter) { + constructor(address _bridgeAdapter, address _tokenRetriever) { //slither-disable-next-line missing-zero-check bridgeAdapter = _bridgeAdapter; + tokenRetriever = _tokenRetriever; } /** @@ -40,7 +48,10 @@ contract Intermediate_TokenRetriever is Lockable, MultiCaller { */ function retrieve(address l2Token) public nonReentrant { (bool success, ) = bridgeAdapter.delegatecall( - abi.encodeCall(IBridgeAdapter.withdrawToken, (IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token)) + abi.encodeCall( + IBridgeAdapter.withdrawToken, + (tokenRetriever, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token) + ) ); if (!success) revert WithdrawalFailed(l2Token); } diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index 309fe69f3..b934a99cf 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -25,8 +25,16 @@ interface IArbitrum_SpokePool { * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. */ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { + struct WithdrawalInformation { + // L1 address of the recipient. + address recipient; + // Address of l2 token to withdraw. + address l2TokenAddress; + // Amount of l2 Token to return. + uint256 amountToReturn; + } + IArbitrum_SpokePool public immutable spokePool; - address public immutable tokenRetriever; address public immutable l2GatewayRouter; /* @@ -34,31 +42,47 @@ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { * @param _l2Usdc address of native USDC on the L2. * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. * @param _spokePool address of the spoke pool on L2. - * @param _tokenRetriever L1 address of the recipient of withdrawals. * @param _l2GatewayRouter address of the Arbitrum l2 gateway router contract. */ constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, IArbitrum_SpokePool _spokePool, - address _tokenRetriever, address _l2GatewayRouter ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { spokePool = _spokePool; - tokenRetriever = _tokenRetriever; l2GatewayRouter = _l2GatewayRouter; } + /* + * @notice withdraws tokens to Ethereum given the input parameters. + * @param withdrawalInformation array containing information to withdraw a token. Includes the L1 recipient + * address, the amount to withdraw, and the token address of the L2 token to withdraw. + */ + function withdrawTokens(WithdrawalInformation[] calldata withdrawalInformation) external { + uint256 informationLength = withdrawalInformation.length; + WithdrawalInformation calldata withdrawal; + for (uint256 i = 0; i < informationLength; ++i) { + withdrawal = withdrawalInformation[i]; + withdrawToken(withdrawal.recipient, withdrawal.amountToReturn, withdrawal.l2TokenAddress); + } + } + /* * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the `tokenRetriever`. The * bridge will not be called if the token is not in the Arbitrum_SpokePool's `whitelistedTokens` mapping. - * @param amountToReturn amount of l2Token to send back to the token retriever. - * @param l2TokenAddress address of the l2Token to send back to the token retriever. + * @param recipient L1 address of the recipient. + * @param amountToReturn amount of l2Token to send back. + * @param l2TokenAddress address of the l2Token to send back. */ - function withdrawToken(uint256 amountToReturn, address l2TokenAddress) external { + function withdrawToken( + address recipient, + uint256 amountToReturn, + address l2TokenAddress + ) public { // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { - _transferUsdc(tokenRetriever, amountToReturn); + _transferUsdc(recipient, amountToReturn); } else { // Check that the Ethereum counterpart of the L2 token is stored on this contract. // Tokens will only be bridged if they are whitelisted by the spoke pool. @@ -67,7 +91,7 @@ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { //slither-disable-next-line unused-return StandardBridgeLike(l2GatewayRouter).outboundTransfer( ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over. - tokenRetriever, // _to. Withdraw, over the bridge, to the l1 hub pool contract. + recipient, // _to. Withdraw, over the bridge, to the l1 hub pool contract. amountToReturn, // _amount. "" // _data. We don't need to send any data for the bridging action. ); diff --git a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index 24293382b..a80c73aac 100644 --- a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -57,16 +57,20 @@ contract Arbitrum_WithdrawalAdapterTest is Test { error WithdrawalFailed(address l2Token); function setUp() public { + // Initialize mintable/burnable tokens. whitelistedToken = new Token_ERC20("TOKEN", "TOKEN"); usdc = new Token_ERC20("USDC", "USDC"); + // Initialize mock bridge. gatewayRouter = new ArbitrumGatewayRouter(); + // Instantiate all other addresses used in the system. tokenMessenger = ITokenMessenger(vm.addr(1)); owner = vm.addr(2); wrappedNativeToken = vm.addr(3); hubPool = vm.addr(4); aliasedOwner = _applyL1ToL2Alias(owner); + // Create the spoke pool. vm.startPrank(owner); Arbitrum_SpokePool implementation = new Arbitrum_SpokePool(wrappedNativeToken, 0, 0, usdc, tokenMessenger); address proxy = address( @@ -76,47 +80,49 @@ contract Arbitrum_WithdrawalAdapterTest is Test { ) ); vm.stopPrank(); - arbitrumSpokePool = Arbitrum_SpokePool(payable(proxy)); arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter( usdc, tokenMessenger, IArbitrum_SpokePool(proxy), - hubPool, address(gatewayRouter) ); - tokenRetriever = new Intermediate_TokenRetriever(address(arbitrumWithdrawalAdapter)); + + // Create the token retriever contract. + tokenRetriever = new Intermediate_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); } - function testWithdrawWhitelistedTokenNonCCTP() public { + function testWithdrawWhitelistedTokenNonCCTP(uint256 amountToReturn) public { + // There should be no balance in any contract/EOA. assertEq(whitelistedToken.balanceOf(hubPool), 0); assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - uint256 amountToBridge = uint256(block.timestamp + 100); - // Whitelist tokens in the spoke pool and mint tokens to the withdrawal adapter. + // Whitelist tokens in the spoke pool and simulate a L3 -> L2 withdrawal into the token retriever. vm.startPrank(aliasedOwner); arbitrumSpokePool.whitelistToken(address(whitelistedToken), address(whitelistedToken)); - whitelistedToken.mint(address(tokenRetriever), amountToBridge); vm.stopPrank(); + whitelistedToken.mint(address(tokenRetriever), amountToReturn); - // Attempt to withdraw token. + // Attempt to withdraw the token. tokenRetriever.retrieve(address(whitelistedToken)); - assertEq(whitelistedToken.balanceOf(hubPool), amountToBridge); + + // Ensure that the balances are updated (i.e. the token bridge contract was called). + assertEq(whitelistedToken.balanceOf(hubPool), amountToReturn); assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); } - function testWithdrawOtherTokenNonCCTP() public { + function testWithdrawOtherTokenNonCCTP(uint256 amountToReturn) public { + // There should be no balance in any contract/EOA. assertEq(whitelistedToken.balanceOf(hubPool), 0); assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - uint256 amountToBridge = uint256(block.timestamp + 100); - // Mint tokens to the withdrawal adapter but do not whitelist it in the spoke pool. - whitelistedToken.mint(address(tokenRetriever), amountToBridge); + // Simulate a L3 -> L2 withdrawal of an non-whitelisted token to the tokenRetriever contract. + whitelistedToken.mint(address(tokenRetriever), amountToReturn); - // Attempt to withdraw token. + // Attempt to withdraw the token. vm.expectRevert(abi.encodeWithSelector(WithdrawalFailed.selector, address(whitelistedToken))); tokenRetriever.retrieve(address(whitelistedToken)); } From 3131eed7f6f65691c99260b5529a43e54c499c21 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 16 Sep 2024 11:26:31 -0500 Subject: [PATCH 04/28] add functionality for retrieveMany. Refactor WithdrawalAdapter Signed-off-by: bennett --- ...kenRetriever.sol => L2_TokenRetriever.sol} | 36 +++++++++-- .../l2/Arbitrum_WithdrawalAdapter.sol | 35 ++--------- .../chain-adapters/l2/WithdrawalAdapter.sol | 62 +++++++++++++++++++ .../local/Arbitrum_WithdrawalAdapter.t.sol | 11 ++-- 4 files changed, 104 insertions(+), 40 deletions(-) rename contracts/{Intermediate_TokenRetriever.sol => L2_TokenRetriever.sol} (56%) create mode 100644 contracts/chain-adapters/l2/WithdrawalAdapter.sol diff --git a/contracts/Intermediate_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol similarity index 56% rename from contracts/Intermediate_TokenRetriever.sol rename to contracts/L2_TokenRetriever.sol index a393c21c9..b4e95264a 100644 --- a/contracts/Intermediate_TokenRetriever.sol +++ b/contracts/L2_TokenRetriever.sol @@ -5,6 +5,7 @@ import "./Lockable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import "@uma/core/contracts/common/implementation/MultiCaller.sol"; +import { WithdrawalAdapter } from "./chain-adapters/l2/WithdrawalAdapter.sol"; interface IBridgeAdapter { function withdrawToken( @@ -12,6 +13,8 @@ interface IBridgeAdapter { uint256 amountToReturn, address l2Token ) external; + + function withdrawTokens(WithdrawalAdapter.WithdrawalInformation[] memory) external; } /** @@ -19,7 +22,7 @@ interface IBridgeAdapter { * @dev Since each network has its own bridging requirements, this contract delegates that logic to a bridge adapter contract * which performs the necessary withdraw action. */ -contract Intermediate_TokenRetriever is Lockable, MultiCaller { +contract L2_TokenRetriever is Lockable { using SafeERC20Upgradeable for IERC20Upgradeable; // Should be set to the bridge adapter which contains the proper logic to withdraw tokens on @@ -28,7 +31,8 @@ contract Intermediate_TokenRetriever is Lockable, MultiCaller { // Should be set to the L1 address which will receive withdrawn tokens. address public immutable tokenRetriever; - error WithdrawalFailed(address l2Token); + error RetrieveFailed(address l2Token); + error RetrieveManyFailed(address[] l2Tokens); /** * @notice Constructs the Intermediate_TokenRetriever @@ -36,7 +40,6 @@ contract Intermediate_TokenRetriever is Lockable, MultiCaller { * @param _tokenRetriever L1 address of the recipient of withdrawn tokens. */ constructor(address _bridgeAdapter, address _tokenRetriever) { - //slither-disable-next-line missing-zero-check bridgeAdapter = _bridgeAdapter; tokenRetriever = _tokenRetriever; } @@ -46,13 +49,36 @@ contract Intermediate_TokenRetriever is Lockable, MultiCaller { * @notice This follows the bridging logic of the corresponding bridge adapter. * @param l2Token (current network's) contract address of the token to be withdrawn. */ - function retrieve(address l2Token) public nonReentrant { + function retrieve(address l2Token) external nonReentrant { (bool success, ) = bridgeAdapter.delegatecall( abi.encodeCall( IBridgeAdapter.withdrawToken, (tokenRetriever, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token) ) ); - if (!success) revert WithdrawalFailed(l2Token); + if (!success) revert RetrieveFailed(l2Token); + } + + /** + * @notice delegatecalls the bridge adapter to withdraw multiple different L2 tokens. + * @dev this is preferrable to multicalling `retrieve` since instead of `n` delegatecalls for `n` + * withdrawal txns, we can have 1 delegatecall for `n` withdrawal transactions. + * @param l2Tokens (current network's) contracts addresses of the l2 tokens to be withdrawn. + */ + function retrieveMany(address[] memory l2Tokens) external nonReentrant { + uint256 nWithdrawals = l2Tokens.length; + WithdrawalAdapter.WithdrawalInformation[] memory withdrawals = new WithdrawalAdapter.WithdrawalInformation[]( + nWithdrawals + ); + for (uint256 i = 0; i < nWithdrawals; ++i) { + address l2Token = l2Tokens[i]; + withdrawals[i] = WithdrawalAdapter.WithdrawalInformation( + tokenRetriever, + l2Token, + IERC20Upgradeable(l2Token).balanceOf(address(this)) + ); + } + (bool success, ) = bridgeAdapter.delegatecall(abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals))); + if (!success) revert RetrieveManyFailed(l2Tokens); } } diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index b934a99cf..984c5e3a9 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -4,8 +4,8 @@ // See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum pragma solidity ^0.8.19; -import "../../libraries/CircleCCTPAdapter.sol"; import { StandardBridgeLike } from "../../Arbitrum_SpokePool.sol"; +import "./WithdrawalAdapter.sol"; /** * @notice AVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. @@ -24,18 +24,8 @@ interface IArbitrum_SpokePool { * @title Adapter for interacting with bridges from the Arbitrum One L2 to Ethereum mainnet. * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. */ -contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { - struct WithdrawalInformation { - // L1 address of the recipient. - address recipient; - // Address of l2 token to withdraw. - address l2TokenAddress; - // Amount of l2 Token to return. - uint256 amountToReturn; - } - +contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { IArbitrum_SpokePool public immutable spokePool; - address public immutable l2GatewayRouter; /* * @notice constructs the withdrawal adapter. @@ -49,23 +39,8 @@ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { ITokenMessenger _cctpTokenMessenger, IArbitrum_SpokePool _spokePool, address _l2GatewayRouter - ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { + ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2GatewayRouter) { spokePool = _spokePool; - l2GatewayRouter = _l2GatewayRouter; - } - - /* - * @notice withdraws tokens to Ethereum given the input parameters. - * @param withdrawalInformation array containing information to withdraw a token. Includes the L1 recipient - * address, the amount to withdraw, and the token address of the L2 token to withdraw. - */ - function withdrawTokens(WithdrawalInformation[] calldata withdrawalInformation) external { - uint256 informationLength = withdrawalInformation.length; - WithdrawalInformation calldata withdrawal; - for (uint256 i = 0; i < informationLength; ++i) { - withdrawal = withdrawalInformation[i]; - withdrawToken(withdrawal.recipient, withdrawal.amountToReturn, withdrawal.l2TokenAddress); - } } /* @@ -79,7 +54,7 @@ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { address recipient, uint256 amountToReturn, address l2TokenAddress - ) public { + ) public override { // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { _transferUsdc(recipient, amountToReturn); @@ -89,7 +64,7 @@ contract Arbitrum_WithdrawalAdapter is CircleCCTPAdapter { address ethereumTokenToBridge = spokePool.whitelistedTokens(l2TokenAddress); require(ethereumTokenToBridge != address(0), "Uninitialized mainnet token"); //slither-disable-next-line unused-return - StandardBridgeLike(l2GatewayRouter).outboundTransfer( + StandardBridgeLike(l2Gateway).outboundTransfer( ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over. recipient, // _to. Withdraw, over the bridge, to the l1 hub pool contract. amountToReturn, // _amount. diff --git a/contracts/chain-adapters/l2/WithdrawalAdapter.sol b/contracts/chain-adapters/l2/WithdrawalAdapter.sol new file mode 100644 index 000000000..814a2b5a3 --- /dev/null +++ b/contracts/chain-adapters/l2/WithdrawalAdapter.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.19; + +import "../../libraries/CircleCCTPAdapter.sol"; + +/** + * @title Adapter for interacting with bridges from a generic L2 to Ethereum mainnet. + * @notice This contract is used to share L2-L1 bridging logic with other Across contracts. + */ +abstract contract WithdrawalAdapter is CircleCCTPAdapter { + struct WithdrawalInformation { + // L1 address of the recipient. + address recipient; + // Address of l2 token to withdraw. + address l2TokenAddress; + // Amount of l2 Token to return. + uint256 amountToReturn; + } + + address public immutable l2Gateway; + + /* + * @notice constructs the withdrawal adapter. + * @param _l2Usdc address of native USDC on the L2. + * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. + * @param _l2Gateway address of the network's l2 token gateway/bridge contract. + */ + constructor( + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger, + address _l2Gateway + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { + l2Gateway = _l2Gateway; + } + + /* + * @notice withdraws tokens to Ethereum given the input parameters. + * @param withdrawalInformation array containing information to withdraw a token. Includes the L1 recipient + * address, the amount to withdraw, and the token address of the L2 token to withdraw. + */ + function withdrawTokens(WithdrawalInformation[] memory withdrawalInformation) external { + uint256 informationLength = withdrawalInformation.length; + WithdrawalInformation memory withdrawal; + for (uint256 i = 0; i < informationLength; ++i) { + withdrawal = withdrawalInformation[i]; + withdrawToken(withdrawal.recipient, withdrawal.amountToReturn, withdrawal.l2TokenAddress); + } + } + + /* + * @notice implementation for withdrawing a specific token back to Ethereum. This is to be implemented + * for each different L2, since each L2 has various mappings for L1<->L2 tokens. + * @param recipient L1 address of the recipient. + * @param amountToReturn amount of l2Token to send back. + * @param l2TokenAddress address of the l2Token to send back. + */ + function withdrawToken( + address recipient, + uint256 amountToReturn, + address l2TokenAddress + ) public virtual; +} diff --git a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index a80c73aac..10561d52d 100644 --- a/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -6,7 +6,7 @@ import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { Arbitrum_SpokePool, ITokenMessenger } from "../../../contracts/Arbitrum_SpokePool.sol"; import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; -import { Intermediate_TokenRetriever } from "../../../contracts/Intermediate_TokenRetriever.sol"; +import { L2_TokenRetriever } from "../../../contracts/L2_TokenRetriever.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; @@ -39,7 +39,7 @@ contract ArbitrumGatewayRouter { contract Arbitrum_WithdrawalAdapterTest is Test { Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; - Intermediate_TokenRetriever tokenRetriever; + L2_TokenRetriever tokenRetriever; Arbitrum_SpokePool arbitrumSpokePool; Token_ERC20 whitelistedToken; Token_ERC20 usdc; @@ -54,7 +54,8 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // Token messenger is set so CCTP is activated. ITokenMessenger tokenMessenger; - error WithdrawalFailed(address l2Token); + error RetrieveFailed(address l2Token); + error RetrieveManyFailed(address[] l2Tokens); function setUp() public { // Initialize mintable/burnable tokens. @@ -89,7 +90,7 @@ contract Arbitrum_WithdrawalAdapterTest is Test { ); // Create the token retriever contract. - tokenRetriever = new Intermediate_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); + tokenRetriever = new L2_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); } function testWithdrawWhitelistedTokenNonCCTP(uint256 amountToReturn) public { @@ -123,7 +124,7 @@ contract Arbitrum_WithdrawalAdapterTest is Test { whitelistedToken.mint(address(tokenRetriever), amountToReturn); // Attempt to withdraw the token. - vm.expectRevert(abi.encodeWithSelector(WithdrawalFailed.selector, address(whitelistedToken))); + vm.expectRevert(abi.encodeWithSelector(RetrieveFailed.selector, address(whitelistedToken))); tokenRetriever.retrieve(address(whitelistedToken)); } From f9c0d62772d4eb866b9f94ba92efa4a8b77dbb35 Mon Sep 17 00:00:00 2001 From: bennett Date: Tue, 17 Sep 2024 14:37:44 -0500 Subject: [PATCH 05/28] feat: add L2 forwarder interface Signed-off-by: bennett --- contracts/chain-adapters/Blast_Adapter.sol | 2 +- contracts/chain-adapters/Lisk_Adapter.sol | 2 +- contracts/chain-adapters/Mode_Adapter.sol | 2 +- contracts/chain-adapters/Redstone_Adapter.sol | 2 +- contracts/chain-adapters/Zora_Adapter.sol | 2 +- .../interfaces/ArbitrumForwarderInterface.sol | 254 ++++++++++++++++++ contracts/libraries/CircleCCTPAdapter.sol | 2 +- 7 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol diff --git a/contracts/chain-adapters/Blast_Adapter.sol b/contracts/chain-adapters/Blast_Adapter.sol index 83fa8053c..e3cdc514d 100644 --- a/contracts/chain-adapters/Blast_Adapter.sol +++ b/contracts/chain-adapters/Blast_Adapter.sol @@ -76,7 +76,7 @@ contract Blast_Adapter is CrossDomainEnabled, AdapterInterface, CircleCCTPAdapte ) CrossDomainEnabled(_crossDomainMessenger) // Hardcode cctp messenger to 0x0 to disable CCTP bridging. - CircleCCTPAdapter(_l1Usdc, ITokenMessenger(address(0)), CircleDomainIds.UNINTIALIZED) + CircleCCTPAdapter(_l1Usdc, ITokenMessenger(address(0)), CircleDomainIds.UNINITIALIZED) { L1_WETH = _l1Weth; L1_STANDARD_BRIDGE = _l1StandardBridge; diff --git a/contracts/chain-adapters/Lisk_Adapter.sol b/contracts/chain-adapters/Lisk_Adapter.sol index 7b0e749eb..bea4f65f0 100644 --- a/contracts/chain-adapters/Lisk_Adapter.sol +++ b/contracts/chain-adapters/Lisk_Adapter.sol @@ -51,7 +51,7 @@ contract Lisk_Adapter is CrossDomainEnabled, AdapterInterface, CircleCCTPAdapter _l1Usdc, // Hardcode cctp messenger to 0x0 to disable CCTP bridging. ITokenMessenger(address(0)), - CircleDomainIds.UNINTIALIZED + CircleDomainIds.UNINITIALIZED ) { L1_WETH = _l1Weth; diff --git a/contracts/chain-adapters/Mode_Adapter.sol b/contracts/chain-adapters/Mode_Adapter.sol index 389dd6848..29e398779 100644 --- a/contracts/chain-adapters/Mode_Adapter.sol +++ b/contracts/chain-adapters/Mode_Adapter.sol @@ -51,7 +51,7 @@ contract Mode_Adapter is CrossDomainEnabled, AdapterInterface, CircleCCTPAdapter _l1Usdc, // Hardcode cctp messenger to 0x0 to disable CCTP bridging. ITokenMessenger(address(0)), - CircleDomainIds.UNINTIALIZED + CircleDomainIds.UNINITIALIZED ) { L1_WETH = _l1Weth; diff --git a/contracts/chain-adapters/Redstone_Adapter.sol b/contracts/chain-adapters/Redstone_Adapter.sol index 401c33be8..65fc07a42 100644 --- a/contracts/chain-adapters/Redstone_Adapter.sol +++ b/contracts/chain-adapters/Redstone_Adapter.sol @@ -51,7 +51,7 @@ contract Redstone_Adapter is CrossDomainEnabled, AdapterInterface, CircleCCTPAda _l1Usdc, // Hardcode cctp messenger to 0x0 to disable CCTP bridging. ITokenMessenger(address(0)), - CircleDomainIds.UNINTIALIZED + CircleDomainIds.UNINITIALIZED ) { L1_WETH = _l1Weth; diff --git a/contracts/chain-adapters/Zora_Adapter.sol b/contracts/chain-adapters/Zora_Adapter.sol index c36288fd5..d0de83ceb 100644 --- a/contracts/chain-adapters/Zora_Adapter.sol +++ b/contracts/chain-adapters/Zora_Adapter.sol @@ -51,7 +51,7 @@ contract Zora_Adapter is CrossDomainEnabled, AdapterInterface, CircleCCTPAdapter _l1Usdc, // Hardcode cctp messenger to 0x0 to disable CCTP bridging. ITokenMessenger(address(0)), - CircleDomainIds.UNINTIALIZED + CircleDomainIds.UNINITIALIZED ) { L1_WETH = _l1Weth; diff --git a/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol b/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol new file mode 100644 index 000000000..8ed8c7d73 --- /dev/null +++ b/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Staging ground for incoming and outgoing messages + * @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is + * used as native currency on L3. + * @dev Fees are paid in this token. There are certain restrictions on the native token: + * - The token can't be rebasing or have a transfer fee + * - The token must only be transferrable via a call to the token address itself + * - The token must only be able to set allowance via a call to the token address itself + * - The token must not have a callback on transfer, and more generally a user must not be able to make a transfer to themselves revert + * - The token must have a max of 2^256 - 1 wei total supply unscaled + * - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals + */ +interface ArbitrumERC20Bridge { + /** + * @notice Returns token that is escrowed in bridge on L2 side and minted on L3 as native currency. + * @dev This function doesn't exist on the generic Bridge interface. + * @return address of the native token. + */ + function nativeToken() external view returns (address); +} + +/** + * @title Inbox for user and contract originated messages + * @notice Messages created via this inbox are enqueued in the delayed accumulator + * to await inclusion in the SequencerInbox + */ +interface ArbitrumInboxLike { + /** + * @dev we only use this function to check the native token used by the bridge, so we hardcode the interface + * to return an ArbitrumERC20Bridge instead of a more generic Bridge interface. + * @return address of the bridge. + */ + function bridge() external view returns (ArbitrumERC20Bridge); + + /** + * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts + * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying + * for L2 to L3 message using a custom gas token. + * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on L3 + * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - l3CallValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. + * @param to destination L3 contract address + * @param l3CallValue call value for retryable L3 message + * @param maxSubmissionCost Max gas deducted from user's L3 balance to cover base submission fee + * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on L3. + * @param callValueRefundAddress l3Callvalue gets credited here on L3 if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on L3. + * @param gasLimit Max gas deducted from user's L3 balance to cover L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param maxFeePerGas price bid for L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost + * @param data ABI encoded data of L3 message + * @return unique message number of the retryable transaction + */ + function createRetryableTicket( + address to, + uint256 l3CallValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 gasLimit, + uint256 maxFeePerGas, + uint256 tokenTotalFeeAmount, + bytes calldata data + ) external returns (uint256); +} + +/** + * @notice Generic gateway contract for bridging standard ERC20s to Arbitrum-like networks. + */ +interface ArbitrumERC20GatewayLike { + /** + * @notice Deposit ERC20 token from Ethereum into Arbitrum-like networks. + * @dev L3 address alias will not be applied to the following types of addresses on L2: + * - an externally-owned account + * - a contract in construction + * - an address where a contract will be created + * - an address where a contract lived, but was destroyed + * @param _l2Token L2 address of ERC20 + * @param _refundTo Account, or its L3 alias if it have code in L2, to be credited with excess gas refund in L3 + * @param _to Account to be credited with the tokens in the L3 (can be the user's L3 account or a contract), + * not subject to L3 aliasing. This account, or its L3 alias if it have code in L2, will also be able to + * cancel the retryable ticket and receive callvalue refund + * @param _amount Token Amount + * @param _maxGas Max gas deducted from user's L3 balance to cover L3 execution + * @param _gasPriceBid Gas price for L3 execution + * @param _data encoded data from router and user + * @return res abi encoded inbox sequence number + */ + function outboundTransferCustomRefund( + address _l2Token, + address _refundTo, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data + ) external payable returns (bytes memory); + + /** + * @notice get ERC20 gateway for token. + * @param _token ERC20 address. + * @return address of ERC20 gateway. + */ + function getGateway(address _token) external view returns (address); +} + +/** + * @notice Contract containing logic to send messages from L2 to Arbitrum-like L3s. + * @dev This contract is meant to share code for Arbitrum L2 forwarder contracts deployed to various + * different L2 architectures (e.g. Base, Arbitrum, ZkSync, etc.). It assumes that the L3 conforms + * to an Arbitrum-like interface. + */ + +// solhint-disable-next-line contract-name-camelcase +abstract contract ArbitrumForwarderInterface { + // Amount of gas token allocated to pay for the base submission fee. The base submission fee is a parameter unique to + // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their + // ticket’s calldata in the retry buffer. (current base submission fee is queryable via + // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address + // 0x000000000000000000000000000000000000006E. + // @dev This is immutable because we don't know what precision the custom gas token has. + uint256 public immutable L3_MAX_SUBMISSION_COST; + + // L3 Gas price bid for immediate L3 execution attempt (queryable via standard eth*gasPrice RPC) + uint256 public constant L3_GAS_PRICE = 5e9; // 5 gWei + + // Native token expected to be sent in L3 message. Should be 0 for all use cases of this constant, which + // includes sending messages from L2 to L3 and sending Custom gas token ERC20's, which won't be the native token + // on the L3 by definition. + uint256 public constant L3_CALL_VALUE = 0; + + // Gas limit for L3 execution of a cross chain token transfer sent via the inbox. + uint32 public constant RELAY_TOKENS_L3_GAS_LIMIT = 300_000; + // Gas limit for L3 execution of a message sent via the inbox. + uint32 public constant RELAY_MESSAGE_L3_GAS_LIMIT = 2_000_000; + + // This address on L3 receives extra gas token that is left over after relaying a message via the inbox. + address public immutable L3_REFUND_L3_ADDRESS; + + // This is the address which receives messages and tokens on L3, assumed to be the spoke pool. + address public immutable L3_SPOKE_POOL; + + // This is the address which has permission to relay root bundles/messages to the L3 spoke pool. + address public immutable CROSS_DOMAIN_ADMIN; + + // Inbox system contract to send messages to Arbitrum-like L3s. Token bridges use this to send tokens to L3. + // https://github.com/OffchainLabs/nitro-contracts/blob/f7894d3a6d4035ba60f51a7f1334f0f2d4f02dce/src/bridge/Inbox.sol + ArbitrumInboxLike public immutable L2_INBOX; + + // Router contract to send tokens to Arbitrum. Routes to correct gateway to bridge tokens. Internally this + // contract calls the Inbox. + // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol + // Gateway used for communicating with chains that use custom gas tokens: + // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol + ArbitrumERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; + + error RescueFailed(); + + /* + * @dev All functions with this modifier must revert if msg.sender != CROSS_DOMAIN_ADMIN, but each L2 may have + * unique aliasing logic, so it is up to the forwarder contract to verify that the sender is valid. + */ + modifier onlyAdmin() { + _requireAdminSender(); + _; + } + + /** + * @notice Constructs new Adapter. + * @param _l2ArbitrumInbox Inbox helper contract to send messages to Arbitrum-like L3s. + * @param _l2ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum-like L3s. + * @param _l3RefundL3Address L3 address to receive gas refunds on after a message is relayed. + * @param _l3MaxSubmissionCost Amount of gas token allocated to pay for the base submission fee. The base + * submission fee is a parameter unique to Arbitrum retryable transactions. This value is hardcoded + * and used for all messages sent by this adapter. + * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily + * stored in this contract on L2. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + * In practice, this is the hub pool. + */ + constructor( + ArbitrumInboxLike _l2ArbitrumInbox, + ArbitrumERC20GatewayLike _l2ERC20GatewayRouter, + address _l3RefundL3Address, + uint256 _l3MaxSubmissionCost, + address _l3SpokePool, + address _crossDomainAdmin + ) { + L2_INBOX = _l2ArbitrumInbox; + L2_ERC20_GATEWAY_ROUTER = _l2ERC20GatewayRouter; + L3_REFUND_L3_ADDRESS = _l3RefundL3Address; + L3_MAX_SUBMISSION_COST = _l3MaxSubmissionCost; + L3_SPOKE_POOL = _l3SpokePool; + CROSS_DOMAIN_ADMIN = _crossDomainAdmin; + } + + /** + * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function + * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward + * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder + */ + fallback() external onlyAdmin { + _relayMessage(L3_SPOKE_POOL, msg.data); + } + + /** + * @notice This function can only be called via a rescue adapter, and is used to recover potentially stuck + * funds on this contract. + */ + function rescue( + address target, + uint256 value, + bytes memory message + ) external onlyAdmin { + (bool success, ) = target.call{ value: value }(message); + if (!success) revert RescueFailed(); + } + + /** + * @notice Bridge tokens to an Arbitrum-like L3. + * @notice This contract must hold at least getL2CallValue() amount of ETH or custom gas token + * to send a message via the Inbox successfully, or the message will get stuck. + * @notice relayTokens should only send tokens to L3_SPOKE_POOL, so no access control is required. + * @param l2Token L2 token to deposit. + * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. + */ + function relayTokens(address l2Token, uint256 amount) external virtual; + + /** + * @notice Relay a message to a contract on L2. Implementation changes on whether the + * target bridge supports a custom gas token or not. + * @notice This contract must hold at least getL2CallValue() amount of the custom gas token + * to send a message via the Inbox successfully, or the message will get stuck. + * @notice This function should be implmented differently based on whether the L2-L3 bridge + * requires custom gas tokens to fund cross-chain transactions. + */ + function _relayMessage(address target, bytes memory message) internal virtual; + + // Function to be overridden to accomodate for each L2's unique method of address aliasing. + function _requireAdminSender() internal virtual; + + /** + * @notice Returns required amount of gas token to send a message via the Inbox. + * @param l3GasLimit L3 gas limit for the message. + * @return amount of gas token that this contract needs to hold in order for relayMessage to succeed. + */ + function getL2CallValue(uint32 l3GasLimit) public view returns (uint256) { + return L3_MAX_SUBMISSION_COST + L3_GAS_PRICE * l3GasLimit; + } + + function _contractHasSufficientGasToken(uint32 l3GasLimit) internal view virtual returns (uint256); +} diff --git a/contracts/libraries/CircleCCTPAdapter.sol b/contracts/libraries/CircleCCTPAdapter.sol index 043c1a820..6403ed4c4 100644 --- a/contracts/libraries/CircleCCTPAdapter.sol +++ b/contracts/libraries/CircleCCTPAdapter.sol @@ -13,7 +13,7 @@ library CircleDomainIds { uint32 public constant Polygon = 7; // Use this value for placeholder purposes only for adapters that extend this adapter but haven't yet been // assigned a domain ID by Circle. - uint32 public constant UNINTIALIZED = type(uint32).max; + uint32 public constant UNINITIALIZED = type(uint32).max; } /** From d3f624bff2d3b263be3a310a5c9ef36a19075bc0 Mon Sep 17 00:00:00 2001 From: bennett Date: Tue, 17 Sep 2024 15:07:45 -0500 Subject: [PATCH 06/28] apply suggested changes Signed-off-by: bennett --- contracts/L2_TokenRetriever.sol | 19 +++++++++---------- .../local/Arbitrum_WithdrawalAdapter.t.sol | 6 +++--- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/contracts/L2_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol index b4e95264a..4f4eb6003 100644 --- a/contracts/L2_TokenRetriever.sol +++ b/contracts/L2_TokenRetriever.sol @@ -1,10 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.19; -import "./Lockable.sol"; -import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; -import "@uma/core/contracts/common/implementation/MultiCaller.sol"; +import { Lockable } from "./Lockable.sol"; +import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import { WithdrawalAdapter } from "./chain-adapters/l2/WithdrawalAdapter.sol"; interface IBridgeAdapter { @@ -29,7 +28,7 @@ contract L2_TokenRetriever is Lockable { // the deployed L2 address public immutable bridgeAdapter; // Should be set to the L1 address which will receive withdrawn tokens. - address public immutable tokenRetriever; + address public immutable tokenRecipient; error RetrieveFailed(address l2Token); error RetrieveManyFailed(address[] l2Tokens); @@ -37,11 +36,11 @@ contract L2_TokenRetriever is Lockable { /** * @notice Constructs the Intermediate_TokenRetriever * @param _bridgeAdapter contract which contains network's bridging logic. - * @param _tokenRetriever L1 address of the recipient of withdrawn tokens. + * @param _tokenRecipient L1 address of the recipient of withdrawn tokens. */ - constructor(address _bridgeAdapter, address _tokenRetriever) { + constructor(address _bridgeAdapter, address _tokenRecipient) { bridgeAdapter = _bridgeAdapter; - tokenRetriever = _tokenRetriever; + tokenRecipient = _tokenRecipient; } /** @@ -53,7 +52,7 @@ contract L2_TokenRetriever is Lockable { (bool success, ) = bridgeAdapter.delegatecall( abi.encodeCall( IBridgeAdapter.withdrawToken, - (tokenRetriever, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token) + (tokenRecipient, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token) ) ); if (!success) revert RetrieveFailed(l2Token); @@ -73,7 +72,7 @@ contract L2_TokenRetriever is Lockable { for (uint256 i = 0; i < nWithdrawals; ++i) { address l2Token = l2Tokens[i]; withdrawals[i] = WithdrawalAdapter.WithdrawalInformation( - tokenRetriever, + tokenRecipient, l2Token, IERC20Upgradeable(l2Token).balanceOf(address(this)) ); diff --git a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index 10561d52d..cada0b46b 100644 --- a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -4,9 +4,9 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { Arbitrum_SpokePool, ITokenMessenger } from "../../../contracts/Arbitrum_SpokePool.sol"; -import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; -import { L2_TokenRetriever } from "../../../contracts/L2_TokenRetriever.sol"; +import { Arbitrum_SpokePool, ITokenMessenger } from "../../../../contracts/Arbitrum_SpokePool.sol"; +import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; +import { L2_TokenRetriever } from "../../../../contracts/L2_TokenRetriever.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; From cea033783c93f534ca3f9d767b63bd04a02e810b Mon Sep 17 00:00:00 2001 From: bennett Date: Tue, 17 Sep 2024 19:00:37 -0500 Subject: [PATCH 07/28] sync with upstream changes Signed-off-by: bennett --- .../interfaces/ArbitrumForwarderInterface.sol | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol b/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol index 8ed8c7d73..cf6040c76 100644 --- a/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol +++ b/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol @@ -35,6 +35,33 @@ interface ArbitrumInboxLike { */ function bridge() external view returns (ArbitrumERC20Bridge); + /** + * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts + * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @dev Caller must set msg.value equal to at least `maxSubmissionCost + maxGas * gasPriceBid`. + * all msg.value will deposited to callValueRefundAddress on L3 + * @dev More details can be found here: https://developer.arbitrum.io/arbos/l1-to-l2-messaging + * @param to destination L3 contract address + * @param l3CallValue call value for retryable L3 message + * @param maxSubmissionCost Max gas deducted from user's L3 balance to cover base submission fee + * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on L3 balance + * @param callValueRefundAddress l3Callvalue gets credited here on L3 if retryable txn times out or gets cancelled + * @param gasLimit Max gas deducted from user's L3 balance to cover L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param maxFeePerGas price bid for L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param data ABI encoded data of L3 message + * @return unique message number of the retryable transaction + */ + function createRetryableTicket( + address to, + uint256 l3CallValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 gasLimit, + uint256 maxFeePerGas, + bytes calldata data + ) external payable returns (uint256); + /** * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying @@ -156,6 +183,9 @@ abstract contract ArbitrumForwarderInterface { // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol ArbitrumERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; + event TokensForwarded(address indexed l2Token, uint256 amount); + event MessageForwarded(address indexed target, bytes message); + error RescueFailed(); /* @@ -196,12 +226,15 @@ abstract contract ArbitrumForwarderInterface { CROSS_DOMAIN_ADMIN = _crossDomainAdmin; } + // Added so that this function may receive ETH in the event of stuck transactions. + receive() external payable {} + /** * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder */ - fallback() external onlyAdmin { + fallback() external payable onlyAdmin { _relayMessage(L3_SPOKE_POOL, msg.data); } @@ -226,7 +259,7 @@ abstract contract ArbitrumForwarderInterface { * @param l2Token L2 token to deposit. * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. */ - function relayTokens(address l2Token, uint256 amount) external virtual; + function relayTokens(address l2Token, uint256 amount) external payable virtual; /** * @notice Relay a message to a contract on L2. Implementation changes on whether the @@ -249,6 +282,4 @@ abstract contract ArbitrumForwarderInterface { function getL2CallValue(uint32 l3GasLimit) public view returns (uint256) { return L3_MAX_SUBMISSION_COST + L3_GAS_PRICE * l3GasLimit; } - - function _contractHasSufficientGasToken(uint32 l3GasLimit) internal view virtual returns (uint256); } From 0f686340de2086dbfa3a159f38871f719b228105 Mon Sep 17 00:00:00 2001 From: bennett Date: Wed, 18 Sep 2024 10:14:42 -0500 Subject: [PATCH 08/28] explicit imports Signed-off-by: bennett --- contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol | 6 +++++- contracts/chain-adapters/l2/WithdrawalAdapter.sol | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index 984c5e3a9..3e36a9a14 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -5,7 +5,9 @@ pragma solidity ^0.8.19; import { StandardBridgeLike } from "../../Arbitrum_SpokePool.sol"; -import "./WithdrawalAdapter.sol"; +import { WithdrawalAdapter, ITokenMessenger } from "./WithdrawalAdapter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** * @notice AVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. @@ -25,6 +27,8 @@ interface IArbitrum_SpokePool { * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. */ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { + using SafeERC20 for IERC20; + IArbitrum_SpokePool public immutable spokePool; /* diff --git a/contracts/chain-adapters/l2/WithdrawalAdapter.sol b/contracts/chain-adapters/l2/WithdrawalAdapter.sol index 814a2b5a3..81a19fc4a 100644 --- a/contracts/chain-adapters/l2/WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/WithdrawalAdapter.sol @@ -1,13 +1,17 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.19; -import "../../libraries/CircleCCTPAdapter.sol"; +import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libraries/CircleCCTPAdapter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** * @title Adapter for interacting with bridges from a generic L2 to Ethereum mainnet. * @notice This contract is used to share L2-L1 bridging logic with other Across contracts. */ abstract contract WithdrawalAdapter is CircleCCTPAdapter { + using SafeERC20 for IERC20; + struct WithdrawalInformation { // L1 address of the recipient. address recipient; From a0a371da2d461c274af7fe7196ba0a9bde58be89 Mon Sep 17 00:00:00 2001 From: bennett Date: Wed, 18 Sep 2024 13:33:52 -0500 Subject: [PATCH 09/28] add a withdrawal adapter for OVM chains Signed-off-by: bennett --- .../l2/Ovm_WithdrawalAdapter.sol | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol new file mode 100644 index 000000000..d2f115d84 --- /dev/null +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.0; + +import { WithdrawalAdapter, ITokenMessenger } from "./WithdrawalAdapter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol"; +import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; +import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol"; + +/** + * @notice OVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. + * @custom:security-contact bugs@across.to + */ + +interface IOvm_SpokePool { + // @dev Returns the address of the token bridge for the input l2 token. + function tokenBridges(address token) external view returns (address); + + // @dev Returns the address of the l1 token set in the spoke pool for the input l2 token. + function remoteL1Tokens(address token) external view returns (address); + + // @dev Returns the address for the representation of ETH on the l2. + function l2Eth() external view returns (address); + + // @dev Returns the address of the wrapped native token for the L2. + function wrappedNativeToken() external view returns (WETH9Interface); + + // @dev Returns the amount of gas the contract allocates for a token withdrawal. + function l1Gas() external view returns (uint32); +} + +/** + * @title Adapter for interacting with bridges from an OpStack L2 to Ethereum mainnet. + * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. + */ +contract Ovm_WithdrawalAdapter is WithdrawalAdapter { + using SafeERC20 for IERC20; + + // Address for the wrapped native token on this chain. For Ovm standard bridges, we need to unwrap + // this token before initiating the withdrawal. Normally, it is 0x42..006, but there are instances + // where this address is different. + WETH9Interface public immutable wrappedNativeToken; + // Address which represents the native token on L2. For OpStack chains, this is generally 0xDeadDeAdde...aDDeAD0000. + address public immutable l2Eth; + // Stores required gas to send tokens back to L1. + uint32 public immutable l1Gas; + // Address of the corresponding spoke pool on L2. This is to piggyback off of the spoke pool's supported + // token routes/defined token bridges. + IOvm_SpokePool public immutable spokePool; + + /* + * @notice constructs the withdrawal adapter. + * @param _l2Usdc address of native USDC on the L2. + * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. + * @param _l2Gateway address of the Optimism ERC20 l2 standard bridge contract. + */ + constructor( + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger, + address _l2Gateway, + IOvm_SpokePool _spokePool + ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2Gateway) { + spokePool = _spokePool; + wrappedNativeToken = spokePool.wrappedNativeToken(); + l2Eth = spokePool.l2Eth(); + l1Gas = spokePool.l1Gas(); + } + + /* + * @notice Calls CCTP or the Optimism token gateway to withdraw tokens back to the recipient. + * @param recipient L1 address of the recipient. + * @param amountToReturn amount of l2Token to send back. + * @param l2TokenAddress address of the l2Token to send back. + */ + function withdrawToken( + address recipient, + uint256 amountToReturn, + address l2TokenAddress + ) public override { + // If the token being bridged is WETH then we need to first unwrap it to ETH and then send ETH over the + // canonical bridge. On Optimism, this is address 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. + if (l2TokenAddress == address(wrappedNativeToken)) { + WETH9Interface(l2TokenAddress).withdraw(amountToReturn); // Unwrap into ETH. + l2TokenAddress = l2Eth; // Set the l2TokenAddress to ETH. + IL2ERC20Bridge(Lib_PredeployAddresses.L2_STANDARD_BRIDGE).withdrawTo{ value: amountToReturn }( + l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. + recipient, // _to. Withdraw, over the bridge, to the l1 pool contract. + amountToReturn, // _amount. + l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations + "" // _data. We don't need to send any data for the bridging action. + ); + } + // If the token is USDC && CCTP bridge is enabled, then bridge USDC via CCTP. + else if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { + _transferUsdc(recipient, amountToReturn); + } + // Note we'll default to withdrawTo instead of bridgeERC20To unless the remoteL1Tokens mapping is set for + // the l2TokenAddress. withdrawTo should be used to bridge back non-native L2 tokens + // (i.e. non-native L2 tokens have a canonical L1 token). If we should bridge "native L2" tokens then + // we'd need to call bridgeERC20To and give allowance to the tokenBridge to spend l2Token from this contract. + // Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2TokenAddress. + else { + IL2ERC20Bridge tokenBridge = IL2ERC20Bridge( + spokePool.tokenBridges(l2TokenAddress) == address(0) + ? Lib_PredeployAddresses.L2_STANDARD_BRIDGE + : spokePool.tokenBridges(l2TokenAddress) + ); + if (spokePool.remoteL1Tokens(l2TokenAddress) != address(0)) { + // If there is a mapping for this L2 token to an L1 token, then use the L1 token address and + // call bridgeERC20To. + IERC20(l2TokenAddress).safeIncreaseAllowance(address(tokenBridge), amountToReturn); + address remoteL1Token = spokePool.remoteL1Tokens(l2TokenAddress); + tokenBridge.bridgeERC20To( + l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. + remoteL1Token, // Remote token to be received on L1 side. If the + // remoteL1Token on the other chain does not recognize the local token as the correct + // pair token, the ERC20 bridge will fail and the tokens will be returned to sender on + // this chain. + recipient, // _to + amountToReturn, // _amount + l1Gas, // _l1Gas + "" // _data + ); + } else { + tokenBridge.withdrawTo( + l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. + recipient, // _to. Withdraw, over the bridge, to the l1 pool contract. + amountToReturn, // _amount. + l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations + "" // _data. We don't need to send any data for the bridging action. + ); + } + } + } +} From 0de93033f590bd7886fae198bf05dc4b36e9732e Mon Sep 17 00:00:00 2001 From: bennett Date: Thu, 19 Sep 2024 12:24:49 -0500 Subject: [PATCH 10/28] refactor and rename Signed-off-by: bennett --- .../chain-adapters/ArbitrumForwarderBase.sol | 150 +++++++++ contracts/chain-adapters/Arbitrum_Adapter.sol | 125 +------- .../Arbitrum_CustomGasToken_Adapter.sol | 106 +------ .../interfaces/ArbitrumForwarderInterface.sol | 285 ------------------ .../interfaces/ArbitrumBridgeInterfaces.sol | 185 ++++++++++++ 5 files changed, 337 insertions(+), 514 deletions(-) create mode 100644 contracts/chain-adapters/ArbitrumForwarderBase.sol delete mode 100644 contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol create mode 100644 contracts/interfaces/ArbitrumBridgeInterfaces.sol diff --git a/contracts/chain-adapters/ArbitrumForwarderBase.sol b/contracts/chain-adapters/ArbitrumForwarderBase.sol new file mode 100644 index 000000000..1ecc43889 --- /dev/null +++ b/contracts/chain-adapters/ArbitrumForwarderBase.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { ArbitrumERC20Bridge, ArbitrumInboxLike, ArbitrumERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; + +// solhint-disable-next-line contract-name-camelcase +abstract contract ArbitrumForwarderBase { + // Amount of gas token allocated to pay for the base submission fee. The base submission fee is a parameter unique to + // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their + // ticket’s calldata in the retry buffer. (current base submission fee is queryable via + // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address + // 0x000000000000000000000000000000000000006E. + // @dev This is immutable because we don't know what precision the custom gas token has. + uint256 public immutable L3_MAX_SUBMISSION_COST; + + // L3 Gas price bid for immediate L3 execution attempt (queryable via standard eth*gasPrice RPC) + uint256 public immutable L3_GAS_PRICE; // The standard is 5 gWei + + // Native token expected to be sent in L3 message. Should be 0 for all use cases of this constant, which + // includes sending messages from L2 to L3 and sending Custom gas token ERC20's, which won't be the native token + // on the L3 by definition. + uint256 public constant L3_CALL_VALUE = 0; + + // Gas limit for L3 execution of a cross chain token transfer sent via the inbox. + uint32 public constant RELAY_TOKENS_L3_GAS_LIMIT = 300_000; + // Gas limit for L3 execution of a message sent via the inbox. + uint32 public constant RELAY_MESSAGE_L3_GAS_LIMIT = 2_000_000; + + // This address on L3 receives extra gas token that is left over after relaying a message via the inbox. + address public immutable L3_REFUND_L3_ADDRESS; + + // This is the address which receives messages and tokens on L3, assumed to be the spoke pool. + address public immutable L3_SPOKE_POOL; + + // This is the address which has permission to relay root bundles/messages to the L3 spoke pool. + address public immutable CROSS_DOMAIN_ADMIN; + + // Inbox system contract to send messages to Arbitrum-like L3s. Token bridges use this to send tokens to L3. + // https://github.com/OffchainLabs/nitro-contracts/blob/f7894d3a6d4035ba60f51a7f1334f0f2d4f02dce/src/bridge/Inbox.sol + ArbitrumInboxLike public immutable L2_INBOX; + + // Router contract to send tokens to Arbitrum. Routes to correct gateway to bridge tokens. Internally this + // contract calls the Inbox. + // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol + // Gateway used for communicating with chains that use custom gas tokens: + // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol + ArbitrumERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; + + event TokensForwarded(address indexed l2Token, uint256 amount); + event MessageForwarded(address indexed target, bytes message); + + error RescueFailed(); + + /* + * @dev All functions with this modifier must revert if msg.sender != CROSS_DOMAIN_ADMIN, but each L2 may have + * unique aliasing logic, so it is up to the forwarder contract to verify that the sender is valid. + */ + modifier onlyAdmin() { + _requireAdminSender(); + _; + } + + /** + * @notice Constructs new Adapter. + * @param _l2ArbitrumInbox Inbox helper contract to send messages to Arbitrum-like L3s. + * @param _l2ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum-like L3s. + * @param _l3RefundL3Address L3 address to receive gas refunds on after a message is relayed. + * @param _l3MaxSubmissionCost Amount of gas token allocated to pay for the base submission fee. The base + * submission fee is a parameter unique to Arbitrum retryable transactions. This value is hardcoded + * and used for all messages sent by this adapter. + * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily + * stored in this contract on L2. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + * In practice, this is the hub pool. + */ + constructor( + ArbitrumInboxLike _l2ArbitrumInbox, + ArbitrumERC20GatewayLike _l2ERC20GatewayRouter, + address _l3RefundL3Address, + uint256 _l3MaxSubmissionCost, + uint256 _l3GasPrice, + address _l3SpokePool, + address _crossDomainAdmin + ) { + L2_INBOX = _l2ArbitrumInbox; + L2_ERC20_GATEWAY_ROUTER = _l2ERC20GatewayRouter; + L3_REFUND_L3_ADDRESS = _l3RefundL3Address; + L3_MAX_SUBMISSION_COST = _l3MaxSubmissionCost; + L3_GAS_PRICE = _l3GasPrice; + L3_SPOKE_POOL = _l3SpokePool; + CROSS_DOMAIN_ADMIN = _crossDomainAdmin; + } + + // Added so that this function may receive ETH in the event of stuck transactions. + receive() external payable {} + + /** + * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function + * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward + * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder + */ + fallback() external payable onlyAdmin { + _relayMessage(L3_SPOKE_POOL, msg.data); + } + + /** + * @notice This function can only be called via a rescue adapter. It is used to recover potentially stuck + * funds on this contract. + */ + function adminCall( + address target, + uint256 value, + bytes memory message + ) external onlyAdmin { + (bool success, ) = target.call{ value: value }(message); + if (!success) revert RescueFailed(); + } + + /** + * @notice Bridge tokens to an Arbitrum-like L3. + * @notice This contract must hold at least getL2CallValue() amount of ETH or custom gas token + * to send a message via the Inbox successfully, or the message will get stuck. + * @notice relayTokens should only send tokens to L3_SPOKE_POOL, so no access control is required. + * @param l2Token L2 token to deposit. + * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. + */ + function relayTokens(address l2Token, uint256 amount) external payable virtual; + + /** + * @notice Relay a message to a contract on L2. Implementation changes on whether the + * target bridge supports a custom gas token or not. + * @notice This contract must hold at least getL2CallValue() amount of the custom gas token + * to send a message via the Inbox successfully, or the message will get stuck. + * @notice This function should be implmented differently based on whether the L2-L3 bridge + * requires custom gas tokens to fund cross-chain transactions. + */ + function _relayMessage(address target, bytes memory message) internal virtual; + + // Function to be overridden to accomodate for each L2's unique method of address aliasing. + function _requireAdminSender() internal virtual; + + /** + * @notice Returns required amount of gas token to send a message via the Inbox. + * @param l3GasLimit L3 gas limit for the message. + * @return amount of gas token that this contract needs to hold in order for relayMessage to succeed. + */ + function getL2CallValue(uint32 l3GasLimit) public view returns (uint256) { + return L3_MAX_SUBMISSION_COST + L3_GAS_PRICE * l3GasLimit; + } +} diff --git a/contracts/chain-adapters/Arbitrum_Adapter.sol b/contracts/chain-adapters/Arbitrum_Adapter.sol index 3cfd0f323..8c58b911e 100644 --- a/contracts/chain-adapters/Arbitrum_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_Adapter.sol @@ -7,130 +7,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../external/interfaces/CCTPInterfaces.sol"; import "../libraries/CircleCCTPAdapter.sol"; - -/** - * @notice Interface for Arbitrum's L1 Inbox contract used to send messages to Arbitrum. - * @custom:security-contact bugs@across.to - */ -interface ArbitrumL1InboxLike { - /** - * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev Caller must set msg.value equal to at least `maxSubmissionCost + maxGas * gasPriceBid`. - * all msg.value will deposited to callValueRefundAddress on L2 - * @dev More details can be found here: https://developer.arbitrum.io/arbos/l1-to-l2-messaging - * @param to destination L2 contract address - * @param l2CallValue call value for retryable L2 message - * @param maxSubmissionCost Max gas deducted from user's L2 balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on L2 balance - * @param callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled - * @param gasLimit Max gas deducted from user's L2 balance to cover L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of L2 message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 l2CallValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - bytes calldata data - ) external payable returns (uint256); - - /** - * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Same as createRetryableTicket, but does not guarantee that submission will succeed by requiring the needed - * funds come from the deposit alone, rather than falling back on the user's L2 balance - * @dev Advanced usage only (does not rewrite aliases for excessFeeRefundAddress and callValueRefundAddress). - * createRetryableTicket method is the recommended standard. - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @param to destination L2 contract address - * @param l2CallValue call value for retryable L2 message - * @param maxSubmissionCost Max gas deducted from user's L2 balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on L2 balance - * @param callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled - * @param gasLimit Max gas deducted from user's L2 balance to cover L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of L2 message - * @return unique message number of the retryable transaction - */ - function unsafeCreateRetryableTicket( - address to, - uint256 l2CallValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - bytes calldata data - ) external payable returns (uint256); -} - -/** - * @notice Layer 1 Gateway contract for bridging standard ERC20s to Arbitrum. - */ -interface ArbitrumL1ERC20GatewayLike { - /** - * @notice Deprecated in favor of outboundTransferCustomRefund but still used in custom bridges - * like the DAI bridge. - * @dev Refunded to aliased L2 address of sender if sender has code on L1, otherwise to to sender's EOA on L2. - * @param _l1Token L1 address of ERC20 - * @param _to Account to be credited with the tokens in the L2 (can be the user's L2 account or a contract), - * not subject to L2 aliasing. This account, or its L2 alias if it have code in L1, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's L2 balance to cover L2 execution - * @param _gasPriceBid Gas price for L2 execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransfer( - address _l1Token, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); - - /** - * @notice Deposit ERC20 token from Ethereum into Arbitrum. - * @dev L2 address alias will not be applied to the following types of addresses on L1: - * - an externally-owned account - * - a contract in construction - * - an address where a contract will be created - * - an address where a contract lived, but was destroyed - * @param _l1Token L1 address of ERC20 - * @param _refundTo Account, or its L2 alias if it have code in L1, to be credited with excess gas refund in L2 - * @param _to Account to be credited with the tokens in the L2 (can be the user's L2 account or a contract), - * not subject to L2 aliasing. This account, or its L2 alias if it have code in L1, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's L2 balance to cover L2 execution - * @param _gasPriceBid Gas price for L2 execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransferCustomRefund( - address _l1Token, - address _refundTo, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); - - /** - * @notice get ERC20 gateway for token. - * @param _token ERC20 address. - * @return address of ERC20 gateway. - */ - function getGateway(address _token) external view returns (address); -} +import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumERC20GatewayLike as ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; /** * @notice Contract containing logic to send messages from L1 to Arbitrum. diff --git a/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol b/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol index b4c292b65..f55c1f66a 100644 --- a/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol @@ -7,6 +7,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ITokenMessenger as ICCTPTokenMessenger } from "../external/interfaces/CCTPInterfaces.sol"; import { CircleCCTPAdapter, CircleDomainIds } from "../libraries/CircleCCTPAdapter.sol"; +import { ArbitrumERC20Bridge as ArbitrumL1ERC20Bridge, ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumERC20GatewayLike as ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; /** * @notice Interface for funder contract that this contract pulls from to pay for relayMessage()/relayTokens() @@ -23,111 +24,6 @@ interface FunderInterface { function withdraw(IERC20 token, uint256 amount) external; } -/** - * @title Staging ground for incoming and outgoing messages - * @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is - * used as native currency on L2. - * @dev Fees are paid in this token. There are certain restrictions on the native token: - * - The token can't be rebasing or have a transfer fee - * - The token must only be transferrable via a call to the token address itself - * - The token must only be able to set allowance via a call to the token address itself - * - The token must not have a callback on transfer, and more generally a user must not be able to make a transfer to themselves revert - * - The token must have a max of 2^256 - 1 wei total supply unscaled - * - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals - */ -interface ArbitrumL1ERC20Bridge { - /** - * @notice Returns token that is escrowed in bridge on L1 side and minted on L2 as native currency. - * @dev This function doesn't exist on the generic Bridge interface. - * @return address of the native token. - */ - function nativeToken() external view returns (address); -} - -/** - * @title Inbox for user and contract originated messages - * @notice Messages created via this inbox are enqueued in the delayed accumulator - * to await inclusion in the SequencerInbox - */ -interface ArbitrumL1InboxLike { - /** - * @dev we only use this function to check the native token used by the bridge, so we hardcode the interface - * to return an ArbitrumL1ERC20Bridge instead of a more generic Bridge interface. - * @return address of the bridge. - */ - function bridge() external view returns (ArbitrumL1ERC20Bridge); - - /** - * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts - * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying - * for L1 to L2 message using a custom gas token. - * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on L2 - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - l2CallValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. - * @param to destination L2 contract address - * @param l2CallValue call value for retryable L2 message - * @param maxSubmissionCost Max gas deducted from user's L2 balance to cover base submission fee - * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on L2. - * @param callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on L2. - * @param gasLimit Max gas deducted from user's L2 balance to cover L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost - * @param data ABI encoded data of L2 message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 l2CallValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - uint256 tokenTotalFeeAmount, - bytes calldata data - ) external returns (uint256); -} - -/** - * @notice Layer 1 Gateway contract for bridging standard ERC20s to Arbitrum. - */ -interface ArbitrumL1ERC20GatewayLike { - /** - * @notice Deposit ERC20 token from Ethereum into Arbitrum. - * @dev L2 address alias will not be applied to the following types of addresses on L1: - * - an externally-owned account - * - a contract in construction - * - an address where a contract will be created - * - an address where a contract lived, but was destroyed - * @param _l1Token L1 address of ERC20 - * @param _refundTo Account, or its L2 alias if it have code in L1, to be credited with excess gas refund in L2 - * @param _to Account to be credited with the tokens in the L2 (can be the user's L2 account or a contract), - * not subject to L2 aliasing. This account, or its L2 alias if it have code in L1, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's L2 balance to cover L2 execution - * @param _gasPriceBid Gas price for L2 execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransferCustomRefund( - address _l1Token, - address _refundTo, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); - - /** - * @notice get ERC20 gateway for token. - * @param _token ERC20 address. - * @return address of ERC20 gateway. - */ - function getGateway(address _token) external view returns (address); -} - /** * @notice Contract containing logic to send messages from L1 to Arbitrum. * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be diff --git a/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol b/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol deleted file mode 100644 index cf6040c76..000000000 --- a/contracts/chain-adapters/interfaces/ArbitrumForwarderInterface.sol +++ /dev/null @@ -1,285 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -/** - * @title Staging ground for incoming and outgoing messages - * @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is - * used as native currency on L3. - * @dev Fees are paid in this token. There are certain restrictions on the native token: - * - The token can't be rebasing or have a transfer fee - * - The token must only be transferrable via a call to the token address itself - * - The token must only be able to set allowance via a call to the token address itself - * - The token must not have a callback on transfer, and more generally a user must not be able to make a transfer to themselves revert - * - The token must have a max of 2^256 - 1 wei total supply unscaled - * - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals - */ -interface ArbitrumERC20Bridge { - /** - * @notice Returns token that is escrowed in bridge on L2 side and minted on L3 as native currency. - * @dev This function doesn't exist on the generic Bridge interface. - * @return address of the native token. - */ - function nativeToken() external view returns (address); -} - -/** - * @title Inbox for user and contract originated messages - * @notice Messages created via this inbox are enqueued in the delayed accumulator - * to await inclusion in the SequencerInbox - */ -interface ArbitrumInboxLike { - /** - * @dev we only use this function to check the native token used by the bridge, so we hardcode the interface - * to return an ArbitrumERC20Bridge instead of a more generic Bridge interface. - * @return address of the bridge. - */ - function bridge() external view returns (ArbitrumERC20Bridge); - - /** - * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev Caller must set msg.value equal to at least `maxSubmissionCost + maxGas * gasPriceBid`. - * all msg.value will deposited to callValueRefundAddress on L3 - * @dev More details can be found here: https://developer.arbitrum.io/arbos/l1-to-l2-messaging - * @param to destination L3 contract address - * @param l3CallValue call value for retryable L3 message - * @param maxSubmissionCost Max gas deducted from user's L3 balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on L3 balance - * @param callValueRefundAddress l3Callvalue gets credited here on L3 if retryable txn times out or gets cancelled - * @param gasLimit Max gas deducted from user's L3 balance to cover L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of L3 message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 l3CallValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - bytes calldata data - ) external payable returns (uint256); - - /** - * @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts - * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying - * for L2 to L3 message using a custom gas token. - * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on L3 - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - l3CallValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. - * @param to destination L3 contract address - * @param l3CallValue call value for retryable L3 message - * @param maxSubmissionCost Max gas deducted from user's L3 balance to cover base submission fee - * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on L3. - * @param callValueRefundAddress l3Callvalue gets credited here on L3 if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on L3. - * @param gasLimit Max gas deducted from user's L3 balance to cover L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for L3 execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost - * @param data ABI encoded data of L3 message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 l3CallValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - uint256 tokenTotalFeeAmount, - bytes calldata data - ) external returns (uint256); -} - -/** - * @notice Generic gateway contract for bridging standard ERC20s to Arbitrum-like networks. - */ -interface ArbitrumERC20GatewayLike { - /** - * @notice Deposit ERC20 token from Ethereum into Arbitrum-like networks. - * @dev L3 address alias will not be applied to the following types of addresses on L2: - * - an externally-owned account - * - a contract in construction - * - an address where a contract will be created - * - an address where a contract lived, but was destroyed - * @param _l2Token L2 address of ERC20 - * @param _refundTo Account, or its L3 alias if it have code in L2, to be credited with excess gas refund in L3 - * @param _to Account to be credited with the tokens in the L3 (can be the user's L3 account or a contract), - * not subject to L3 aliasing. This account, or its L3 alias if it have code in L2, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's L3 balance to cover L3 execution - * @param _gasPriceBid Gas price for L3 execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransferCustomRefund( - address _l2Token, - address _refundTo, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); - - /** - * @notice get ERC20 gateway for token. - * @param _token ERC20 address. - * @return address of ERC20 gateway. - */ - function getGateway(address _token) external view returns (address); -} - -/** - * @notice Contract containing logic to send messages from L2 to Arbitrum-like L3s. - * @dev This contract is meant to share code for Arbitrum L2 forwarder contracts deployed to various - * different L2 architectures (e.g. Base, Arbitrum, ZkSync, etc.). It assumes that the L3 conforms - * to an Arbitrum-like interface. - */ - -// solhint-disable-next-line contract-name-camelcase -abstract contract ArbitrumForwarderInterface { - // Amount of gas token allocated to pay for the base submission fee. The base submission fee is a parameter unique to - // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their - // ticket’s calldata in the retry buffer. (current base submission fee is queryable via - // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address - // 0x000000000000000000000000000000000000006E. - // @dev This is immutable because we don't know what precision the custom gas token has. - uint256 public immutable L3_MAX_SUBMISSION_COST; - - // L3 Gas price bid for immediate L3 execution attempt (queryable via standard eth*gasPrice RPC) - uint256 public constant L3_GAS_PRICE = 5e9; // 5 gWei - - // Native token expected to be sent in L3 message. Should be 0 for all use cases of this constant, which - // includes sending messages from L2 to L3 and sending Custom gas token ERC20's, which won't be the native token - // on the L3 by definition. - uint256 public constant L3_CALL_VALUE = 0; - - // Gas limit for L3 execution of a cross chain token transfer sent via the inbox. - uint32 public constant RELAY_TOKENS_L3_GAS_LIMIT = 300_000; - // Gas limit for L3 execution of a message sent via the inbox. - uint32 public constant RELAY_MESSAGE_L3_GAS_LIMIT = 2_000_000; - - // This address on L3 receives extra gas token that is left over after relaying a message via the inbox. - address public immutable L3_REFUND_L3_ADDRESS; - - // This is the address which receives messages and tokens on L3, assumed to be the spoke pool. - address public immutable L3_SPOKE_POOL; - - // This is the address which has permission to relay root bundles/messages to the L3 spoke pool. - address public immutable CROSS_DOMAIN_ADMIN; - - // Inbox system contract to send messages to Arbitrum-like L3s. Token bridges use this to send tokens to L3. - // https://github.com/OffchainLabs/nitro-contracts/blob/f7894d3a6d4035ba60f51a7f1334f0f2d4f02dce/src/bridge/Inbox.sol - ArbitrumInboxLike public immutable L2_INBOX; - - // Router contract to send tokens to Arbitrum. Routes to correct gateway to bridge tokens. Internally this - // contract calls the Inbox. - // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol - // Gateway used for communicating with chains that use custom gas tokens: - // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol - ArbitrumERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; - - event TokensForwarded(address indexed l2Token, uint256 amount); - event MessageForwarded(address indexed target, bytes message); - - error RescueFailed(); - - /* - * @dev All functions with this modifier must revert if msg.sender != CROSS_DOMAIN_ADMIN, but each L2 may have - * unique aliasing logic, so it is up to the forwarder contract to verify that the sender is valid. - */ - modifier onlyAdmin() { - _requireAdminSender(); - _; - } - - /** - * @notice Constructs new Adapter. - * @param _l2ArbitrumInbox Inbox helper contract to send messages to Arbitrum-like L3s. - * @param _l2ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum-like L3s. - * @param _l3RefundL3Address L3 address to receive gas refunds on after a message is relayed. - * @param _l3MaxSubmissionCost Amount of gas token allocated to pay for the base submission fee. The base - * submission fee is a parameter unique to Arbitrum retryable transactions. This value is hardcoded - * and used for all messages sent by this adapter. - * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily - * stored in this contract on L2. - * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. - * In practice, this is the hub pool. - */ - constructor( - ArbitrumInboxLike _l2ArbitrumInbox, - ArbitrumERC20GatewayLike _l2ERC20GatewayRouter, - address _l3RefundL3Address, - uint256 _l3MaxSubmissionCost, - address _l3SpokePool, - address _crossDomainAdmin - ) { - L2_INBOX = _l2ArbitrumInbox; - L2_ERC20_GATEWAY_ROUTER = _l2ERC20GatewayRouter; - L3_REFUND_L3_ADDRESS = _l3RefundL3Address; - L3_MAX_SUBMISSION_COST = _l3MaxSubmissionCost; - L3_SPOKE_POOL = _l3SpokePool; - CROSS_DOMAIN_ADMIN = _crossDomainAdmin; - } - - // Added so that this function may receive ETH in the event of stuck transactions. - receive() external payable {} - - /** - * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function - * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward - * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder - */ - fallback() external payable onlyAdmin { - _relayMessage(L3_SPOKE_POOL, msg.data); - } - - /** - * @notice This function can only be called via a rescue adapter, and is used to recover potentially stuck - * funds on this contract. - */ - function rescue( - address target, - uint256 value, - bytes memory message - ) external onlyAdmin { - (bool success, ) = target.call{ value: value }(message); - if (!success) revert RescueFailed(); - } - - /** - * @notice Bridge tokens to an Arbitrum-like L3. - * @notice This contract must hold at least getL2CallValue() amount of ETH or custom gas token - * to send a message via the Inbox successfully, or the message will get stuck. - * @notice relayTokens should only send tokens to L3_SPOKE_POOL, so no access control is required. - * @param l2Token L2 token to deposit. - * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. - */ - function relayTokens(address l2Token, uint256 amount) external payable virtual; - - /** - * @notice Relay a message to a contract on L2. Implementation changes on whether the - * target bridge supports a custom gas token or not. - * @notice This contract must hold at least getL2CallValue() amount of the custom gas token - * to send a message via the Inbox successfully, or the message will get stuck. - * @notice This function should be implmented differently based on whether the L2-L3 bridge - * requires custom gas tokens to fund cross-chain transactions. - */ - function _relayMessage(address target, bytes memory message) internal virtual; - - // Function to be overridden to accomodate for each L2's unique method of address aliasing. - function _requireAdminSender() internal virtual; - - /** - * @notice Returns required amount of gas token to send a message via the Inbox. - * @param l3GasLimit L3 gas limit for the message. - * @return amount of gas token that this contract needs to hold in order for relayMessage to succeed. - */ - function getL2CallValue(uint32 l3GasLimit) public view returns (uint256) { - return L3_MAX_SUBMISSION_COST + L3_GAS_PRICE * l3GasLimit; - } -} diff --git a/contracts/interfaces/ArbitrumBridgeInterfaces.sol b/contracts/interfaces/ArbitrumBridgeInterfaces.sol new file mode 100644 index 000000000..ea7f81893 --- /dev/null +++ b/contracts/interfaces/ArbitrumBridgeInterfaces.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Staging ground for incoming and outgoing messages + * @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is + * used as native currency on upper layer. + * @dev Fees are paid in this token. There are certain restrictions on the native token: + * - The token can't be rebasing or have a transfer fee + * - The token must only be transferrable via a call to the token address itself + * - The token must only be able to set allowance via a call to the token address itself + * - The token must not have a callback on transfer, and more generally a user must not be able to make a transfer to themselves revert + * - The token must have a max of 2^256 - 1 wei total supply unscaled + * - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals + */ +interface ArbitrumERC20Bridge { + /** + * @notice Returns token that is escrowed in bridge on the lower layer and minted on the upper layer as native currency. + * @dev This function doesn't exist on the generic Bridge interface. + * @return address of the native token. + */ + function nativeToken() external view returns (address); +} + +/** + * @title Inbox for user and contract originated messages + * @notice Messages created via this inbox are enqueued in the delayed accumulator + * to await inclusion in the SequencerInbox + */ +interface ArbitrumInboxLike { + /** + * @dev we only use this function to check the native token used by the bridge, so we hardcode the interface + * to return an ArbitrumERC20Bridge instead of a more generic Bridge interface. + * @return address of the bridge. + */ + function bridge() external view returns (ArbitrumERC20Bridge); + + /** + * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts + * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @dev Caller must set msg.value equal to at least `maxSubmissionCost + maxGas * gasPriceBid`. + * all msg.value will deposited to callValueRefundAddress on the upper layer + * @dev More details can be found here: https://developer.arbitrum.io/arbos/l1-to-l2-messaging + * @param to destination contract address + * @param callValue call value for retryable message + * @param maxSubmissionCost Max gas deducted from user's (upper layer) balance to cover base submission fee + * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on (upper layer) balance + * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled + * @param gasLimit Max gas deducted from user's upper layer balance to cover upper layer execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param maxFeePerGas price bid for upper layer execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param data ABI encoded data of message + * @return unique message number of the retryable transaction + */ + function createRetryableTicket( + address to, + uint256 callValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 gasLimit, + uint256 maxFeePerGas, + bytes calldata data + ) external payable returns (uint256); + + /** + * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts + * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying + * for message using a custom gas token. + * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on upper layer + * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - callValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. + * @param to destination contract address + * @param callValue call value for retryable message + * @param maxSubmissionCost Max gas deducted from user's upper layer balance to cover base submission fee + * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on upper layer. + * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on upper layer. + * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost + * @param data ABI encoded data of message + * @return unique message number of the retryable transaction + */ + function createRetryableTicket( + address to, + uint256 callValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 gasLimit, + uint256 maxFeePerGas, + uint256 tokenTotalFeeAmount, + bytes calldata data + ) external returns (uint256); + + /** + * @notice Put a message in the source chain inbox that can be reexecuted for some fixed amount of time if it reverts + * @dev Same as createRetryableTicket, but does not guarantee that submission will succeed by requiring the needed + * funds come from the deposit alone, rather than falling back on the user's balance + * @dev Advanced usage only (does not rewrite aliases for excessFeeRefundAddress and callValueRefundAddress). + * createRetryableTicket method is the recommended standard. + * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @param to destination contract address + * @param callValue call value for retryable message + * @param maxSubmissionCost Max gas deducted from user's source chain balance to cover base submission fee + * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on source chain balance + * @param callValueRefundAddress callvalue gets credited here on source chain if retryable txn times out or gets cancelled + * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) + * @param data ABI encoded data of the message + * @return unique message number of the retryable transaction + */ + function unsafeCreateRetryableTicket( + address to, + uint256 callValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 gasLimit, + uint256 maxFeePerGas, + bytes calldata data + ) external payable returns (uint256); +} + +/** + * @notice Generic gateway contract for bridging standard ERC20s to Arbitrum-like networks. + */ +interface ArbitrumERC20GatewayLike { + /** + * @notice Deposit ERC20 token from Ethereum into Arbitrum-like networks. + * @dev Upper layer address alias will not be applied to the following types of addresses on lower layer: + * - an externally-owned account + * - a contract in construction + * - an address where a contract will be created + * - an address where a contract lived, but was destroyed + * @param _sourceToken address of ERC20 on source chain. + * @param _refundTo Account, or its alias if it has code on the source chain, to be credited with excess gas refund at destination + * @param _to Account to be credited with the tokens in the L3 (can be the user's L3 account or a contract), + * not subject to aliasing. This account, or its alias if it has code on the source chain, will also be able to + * cancel the retryable ticket and receive callvalue refund + * @param _amount Token Amount + * @param _maxGas Max gas deducted from user's balance to cover execution + * @param _gasPriceBid Gas price for execution + * @param _data encoded data from router and user + * @return res abi encoded inbox sequence number + */ + function outboundTransferCustomRefund( + address _sourceToken, + address _refundTo, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data + ) external payable returns (bytes memory); + + /** + * @notice Deprecated in favor of outboundTransferCustomRefund but still used in custom bridges + * like the DAI bridge. + * @dev Refunded to aliased address of sender if sender has code on source chain, otherwise to to sender's EOA on destination chain. + * @param _sourceToken address of ERC20 + * @param _to Account to be credited with the tokens at the destination (can be the user's account or a contract), + * not subject to aliasing. This account, or its alias if it has code in the source chain, will also be able to + * cancel the retryable ticket and receive callvalue refund + * @param _amount Token Amount + * @param _maxGas Max gas deducted from user's balance to cover execution + * @param _gasPriceBid Gas price for execution + * @param _data encoded data from router and user + * @return res abi encoded inbox sequence number + */ + function outboundTransfer( + address _sourceToken, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data + ) external payable returns (bytes memory); + + /** + * @notice get ERC20 gateway for token. + * @param _token ERC20 address. + * @return address of ERC20 gateway. + */ + function getGateway(address _token) external view returns (address); +} From 942f741d1fa7ba6f9c8f6af1cac90d1ca4ef0675 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 08:48:44 -0500 Subject: [PATCH 11/28] WIP remove spoke pool dependency Signed-off-by: bennett --- contracts/L2_TokenRetriever.sol | 30 +++------------- .../l2/Arbitrum_WithdrawalAdapter.sol | 27 ++++---------- .../l2/Ovm_WithdrawalAdapter.sol | 29 ++++++++------- .../chain-adapters/l2/WithdrawalAdapter.sol | 14 ++++++-- .../local/Arbitrum_WithdrawalAdapter.t.sol | 35 ++++--------------- 5 files changed, 44 insertions(+), 91 deletions(-) diff --git a/contracts/L2_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol index 4f4eb6003..7d96d473a 100644 --- a/contracts/L2_TokenRetriever.sol +++ b/contracts/L2_TokenRetriever.sol @@ -7,12 +7,6 @@ import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ import { WithdrawalAdapter } from "./chain-adapters/l2/WithdrawalAdapter.sol"; interface IBridgeAdapter { - function withdrawToken( - address recipient, - uint256 amountToReturn, - address l2Token - ) external; - function withdrawTokens(WithdrawalAdapter.WithdrawalInformation[] memory) external; } @@ -30,11 +24,10 @@ contract L2_TokenRetriever is Lockable { // Should be set to the L1 address which will receive withdrawn tokens. address public immutable tokenRecipient; - error RetrieveFailed(address l2Token); - error RetrieveManyFailed(address[] l2Tokens); + error RetrieveFailed(address[] l2Tokens); /** - * @notice Constructs the Intermediate_TokenRetriever + * @notice Constructs the L2_TokenRetriever * @param _bridgeAdapter contract which contains network's bridging logic. * @param _tokenRecipient L1 address of the recipient of withdrawn tokens. */ @@ -43,28 +36,13 @@ contract L2_TokenRetriever is Lockable { tokenRecipient = _tokenRecipient; } - /** - * @notice delegatecalls the contract's stored bridge adapter to bridge tokens back to the defined token retriever - * @notice This follows the bridging logic of the corresponding bridge adapter. - * @param l2Token (current network's) contract address of the token to be withdrawn. - */ - function retrieve(address l2Token) external nonReentrant { - (bool success, ) = bridgeAdapter.delegatecall( - abi.encodeCall( - IBridgeAdapter.withdrawToken, - (tokenRecipient, IERC20Upgradeable(l2Token).balanceOf(address(this)), l2Token) - ) - ); - if (!success) revert RetrieveFailed(l2Token); - } - /** * @notice delegatecalls the bridge adapter to withdraw multiple different L2 tokens. * @dev this is preferrable to multicalling `retrieve` since instead of `n` delegatecalls for `n` * withdrawal txns, we can have 1 delegatecall for `n` withdrawal transactions. * @param l2Tokens (current network's) contracts addresses of the l2 tokens to be withdrawn. */ - function retrieveMany(address[] memory l2Tokens) external nonReentrant { + function retrieve(address[] memory l2Tokens) external nonReentrant { uint256 nWithdrawals = l2Tokens.length; WithdrawalAdapter.WithdrawalInformation[] memory withdrawals = new WithdrawalAdapter.WithdrawalInformation[]( nWithdrawals @@ -78,6 +56,6 @@ contract L2_TokenRetriever is Lockable { ); } (bool success, ) = bridgeAdapter.delegatecall(abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals))); - if (!success) revert RetrieveManyFailed(l2Tokens); + if (!success) revert RetrieveFailed(l2Tokens); } } diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index 3e36a9a14..837192d4b 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -14,14 +14,6 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s * @custom:security-contact bugs@across.to */ -/* - * @notice Interface for the Across Arbitrum_SpokePool contract. Used to access state which - * can only be modified by admin functions. - */ -interface IArbitrum_SpokePool { - function whitelistedTokens(address) external view returns (address); -} - /** * @title Adapter for interacting with bridges from the Arbitrum One L2 to Ethereum mainnet. * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. @@ -29,8 +21,6 @@ interface IArbitrum_SpokePool { contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { using SafeERC20 for IERC20; - IArbitrum_SpokePool public immutable spokePool; - /* * @notice constructs the withdrawal adapter. * @param _l2Usdc address of native USDC on the L2. @@ -41,11 +31,8 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, - IArbitrum_SpokePool _spokePool, address _l2GatewayRouter - ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2GatewayRouter) { - spokePool = _spokePool; - } + ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2GatewayRouter) {} /* * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the `tokenRetriever`. The @@ -56,20 +43,18 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { */ function withdrawToken( address recipient, - uint256 amountToReturn, - address l2TokenAddress + address l1TokenAddress, + address l2TokenAddress, + uint256 amountToReturn ) public override { // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { _transferUsdc(recipient, amountToReturn); } else { - // Check that the Ethereum counterpart of the L2 token is stored on this contract. - // Tokens will only be bridged if they are whitelisted by the spoke pool. - address ethereumTokenToBridge = spokePool.whitelistedTokens(l2TokenAddress); - require(ethereumTokenToBridge != address(0), "Uninitialized mainnet token"); + require(l1TokenAddress != address(0), "Uninitialized mainnet token"); //slither-disable-next-line unused-return StandardBridgeLike(l2Gateway).outboundTransfer( - ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over. + l1TokenAddress, // _l1Token. Address of the L1 token to bridge over. recipient, // _to. Withdraw, over the bridge, to the l1 hub pool contract. amountToReturn, // _amount. "" // _data. We don't need to send any data for the bridging action. diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol index d2f115d84..cd899a1ea 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol @@ -7,6 +7,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; +import { IL2StandardERC20 } from "@eth-optimism/contracts/standards/IL2StandardERC20.sol"; import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol"; /** @@ -60,12 +61,13 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapter { IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, address _l2Gateway, - IOvm_SpokePool _spokePool + address _l2Eth, + address _wrappedNativeToken, + uint256 _l1Gas ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2Gateway) { - spokePool = _spokePool; - wrappedNativeToken = spokePool.wrappedNativeToken(); - l2Eth = spokePool.l2Eth(); - l1Gas = spokePool.l1Gas(); + wrappedNativeToken = _wrappedNativeToken; + l2Eth = _l2Eth; + l1Gas = _l1Gas; } /* @@ -76,8 +78,9 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapter { */ function withdrawToken( address recipient, - uint256 amountToReturn, - address l2TokenAddress + address l1TokenAddress, + address l2TokenAddress, + uint256 amountToReturn ) public override { // If the token being bridged is WETH then we need to first unwrap it to ETH and then send ETH over the // canonical bridge. On Optimism, this is address 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. @@ -102,19 +105,21 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapter { // we'd need to call bridgeERC20To and give allowance to the tokenBridge to spend l2Token from this contract. // Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2TokenAddress. else { + // TODO: This assumes that the token implements IOptimismMintableERC20. Many do not implement this, so you'll need + // to find a different solution. IL2ERC20Bridge tokenBridge = IL2ERC20Bridge( - spokePool.tokenBridges(l2TokenAddress) == address(0) + l2TokenAddress.bridge() == address(0) ? Lib_PredeployAddresses.L2_STANDARD_BRIDGE - : spokePool.tokenBridges(l2TokenAddress) + : l2TokenAddress.bridge() ); - if (spokePool.remoteL1Tokens(l2TokenAddress) != address(0)) { + if (l1TokenAddress != address(0)) { // If there is a mapping for this L2 token to an L1 token, then use the L1 token address and // call bridgeERC20To. IERC20(l2TokenAddress).safeIncreaseAllowance(address(tokenBridge), amountToReturn); - address remoteL1Token = spokePool.remoteL1Tokens(l2TokenAddress); + require(IL2StandardERC20(l2TokenAddress).l1Token() == l2TokenAddress, "INVALID_TOKEN_MAPPING"); tokenBridge.bridgeERC20To( l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. - remoteL1Token, // Remote token to be received on L1 side. If the + l1TokenAddress, // Remote token to be received on L1 side. If the // remoteL1Token on the other chain does not recognize the local token as the correct // pair token, the ERC20 bridge will fail and the tokens will be returned to sender on // this chain. diff --git a/contracts/chain-adapters/l2/WithdrawalAdapter.sol b/contracts/chain-adapters/l2/WithdrawalAdapter.sol index 81a19fc4a..73088baec 100644 --- a/contracts/chain-adapters/l2/WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/WithdrawalAdapter.sol @@ -15,6 +15,8 @@ abstract contract WithdrawalAdapter is CircleCCTPAdapter { struct WithdrawalInformation { // L1 address of the recipient. address recipient; + // Address of the l1 token to receive. + address l1TokenAddress; // Address of l2 token to withdraw. address l2TokenAddress; // Amount of l2 Token to return. @@ -47,7 +49,12 @@ abstract contract WithdrawalAdapter is CircleCCTPAdapter { WithdrawalInformation memory withdrawal; for (uint256 i = 0; i < informationLength; ++i) { withdrawal = withdrawalInformation[i]; - withdrawToken(withdrawal.recipient, withdrawal.amountToReturn, withdrawal.l2TokenAddress); + withdrawToken( + withdrawal.recipient, + withdrawal.l1TokenAddress, + withdrawal.l2TokenAddress, + withdrawal.amountToReturn + ); } } @@ -60,7 +67,8 @@ abstract contract WithdrawalAdapter is CircleCCTPAdapter { */ function withdrawToken( address recipient, - uint256 amountToReturn, - address l2TokenAddress + address l1TokenAddress, + address l2TokenAddress, + uint256 amountToReturn ) public virtual; } diff --git a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index cada0b46b..567d68d30 100644 --- a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -4,8 +4,7 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { Arbitrum_SpokePool, ITokenMessenger } from "../../../../contracts/Arbitrum_SpokePool.sol"; -import { Arbitrum_WithdrawalAdapter, IArbitrum_SpokePool } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; +import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; import { L2_TokenRetriever } from "../../../../contracts/L2_TokenRetriever.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; @@ -47,7 +46,6 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // HubPool should receive funds. address hubPool; - address owner; address aliasedOwner; address wrappedNativeToken; @@ -66,28 +64,10 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // Instantiate all other addresses used in the system. tokenMessenger = ITokenMessenger(vm.addr(1)); - owner = vm.addr(2); - wrappedNativeToken = vm.addr(3); - hubPool = vm.addr(4); - aliasedOwner = _applyL1ToL2Alias(owner); - - // Create the spoke pool. - vm.startPrank(owner); - Arbitrum_SpokePool implementation = new Arbitrum_SpokePool(wrappedNativeToken, 0, 0, usdc, tokenMessenger); - address proxy = address( - new ERC1967Proxy( - address(implementation), - abi.encodeCall(Arbitrum_SpokePool.initialize, (0, address(gatewayRouter), owner, owner)) - ) - ); - vm.stopPrank(); - arbitrumSpokePool = Arbitrum_SpokePool(payable(proxy)); - arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter( - usdc, - tokenMessenger, - IArbitrum_SpokePool(proxy), - address(gatewayRouter) - ); + wrappedNativeToken = vm.addr(2); + hubPool = vm.addr(3); + + arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter(usdc, tokenMessenger, address(gatewayRouter)); // Create the token retriever contract. tokenRetriever = new L2_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); @@ -99,10 +79,7 @@ contract Arbitrum_WithdrawalAdapterTest is Test { assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - // Whitelist tokens in the spoke pool and simulate a L3 -> L2 withdrawal into the token retriever. - vm.startPrank(aliasedOwner); - arbitrumSpokePool.whitelistToken(address(whitelistedToken), address(whitelistedToken)); - vm.stopPrank(); + // Simulate a L3 -> L2 withdrawal into the token retriever. whitelistedToken.mint(address(tokenRetriever), amountToReturn); // Attempt to withdraw the token. From fd94012e02e143448ff0e5005f3b66a34e04d1bc Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 10:40:39 -0500 Subject: [PATCH 12/28] clean up interfaces Signed-off-by: bennett --- contracts/Arbitrum_SpokePool.sol | 12 +- .../chain-adapters/ArbitrumForwarderBase.sol | 6 +- contracts/chain-adapters/Arbitrum_Adapter.sol | 2 +- .../Arbitrum_CustomGasToken_Adapter.sol | 2 +- .../interfaces/ArbitrumBridgeInterfaces.sol | 124 +++++++++++------- 5 files changed, 83 insertions(+), 63 deletions(-) diff --git a/contracts/Arbitrum_SpokePool.sol b/contracts/Arbitrum_SpokePool.sol index ecdd5bab4..7620b855a 100644 --- a/contracts/Arbitrum_SpokePool.sol +++ b/contracts/Arbitrum_SpokePool.sol @@ -6,15 +6,7 @@ pragma solidity ^0.8.19; import "./SpokePool.sol"; import "./libraries/CircleCCTPAdapter.sol"; - -interface StandardBridgeLike { - function outboundTransfer( - address _l1Token, - address _to, - uint256 _amount, - bytes calldata _data - ) external payable returns (bytes memory); -} +import { ArbitrumL2ERC20GatewayLike } from "./interfaces/ArbitrumBridgeInterfaces.sol"; /** * @notice AVM specific SpokePool. Uses AVM cross-domain-enabled logic to implement admin only access to functions. @@ -100,7 +92,7 @@ contract Arbitrum_SpokePool is SpokePool, CircleCCTPAdapter { address ethereumTokenToBridge = whitelistedTokens[l2TokenAddress]; require(ethereumTokenToBridge != address(0), "Uninitialized mainnet token"); //slither-disable-next-line unused-return - StandardBridgeLike(l2GatewayRouter).outboundTransfer( + ArbitrumL2ERC20GatewayLike(l2GatewayRouter).outboundTransfer( ethereumTokenToBridge, // _l1Token. Address of the L1 token to bridge over. hubPool, // _to. Withdraw, over the bridge, to the l1 hub pool contract. amountToReturn, // _amount. diff --git a/contracts/chain-adapters/ArbitrumForwarderBase.sol b/contracts/chain-adapters/ArbitrumForwarderBase.sol index 1ecc43889..3832519d9 100644 --- a/contracts/chain-adapters/ArbitrumForwarderBase.sol +++ b/contracts/chain-adapters/ArbitrumForwarderBase.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import { ArbitrumERC20Bridge, ArbitrumInboxLike, ArbitrumERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; +import { ArbitrumERC20Bridge, ArbitrumInboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; // solhint-disable-next-line contract-name-camelcase abstract contract ArbitrumForwarderBase { @@ -44,7 +44,7 @@ abstract contract ArbitrumForwarderBase { // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol // Gateway used for communicating with chains that use custom gas tokens: // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol - ArbitrumERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; + ArbitrumL1ERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; event TokensForwarded(address indexed l2Token, uint256 amount); event MessageForwarded(address indexed target, bytes message); @@ -75,7 +75,7 @@ abstract contract ArbitrumForwarderBase { */ constructor( ArbitrumInboxLike _l2ArbitrumInbox, - ArbitrumERC20GatewayLike _l2ERC20GatewayRouter, + ArbitrumL1ERC20GatewayLike _l2ERC20GatewayRouter, address _l3RefundL3Address, uint256 _l3MaxSubmissionCost, uint256 _l3GasPrice, diff --git a/contracts/chain-adapters/Arbitrum_Adapter.sol b/contracts/chain-adapters/Arbitrum_Adapter.sol index 8c58b911e..f8ec507eb 100644 --- a/contracts/chain-adapters/Arbitrum_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_Adapter.sol @@ -7,7 +7,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../external/interfaces/CCTPInterfaces.sol"; import "../libraries/CircleCCTPAdapter.sol"; -import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumERC20GatewayLike as ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; +import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; /** * @notice Contract containing logic to send messages from L1 to Arbitrum. diff --git a/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol b/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol index 2cdddd346..203ee2270 100644 --- a/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_CustomGasToken_Adapter.sol @@ -7,7 +7,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ITokenMessenger as ICCTPTokenMessenger } from "../external/interfaces/CCTPInterfaces.sol"; import { CircleCCTPAdapter, CircleDomainIds } from "../libraries/CircleCCTPAdapter.sol"; -import { ArbitrumERC20Bridge as ArbitrumL1ERC20Bridge, ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumERC20GatewayLike as ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; +import { ArbitrumERC20Bridge as ArbitrumL1ERC20Bridge, ArbitrumCustomGasTokenInbox as ArbitrumL1InboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; /** * @notice Interface for funder contract that this contract pulls from to pay for relayMessage()/relayTokens() diff --git a/contracts/interfaces/ArbitrumBridgeInterfaces.sol b/contracts/interfaces/ArbitrumBridgeInterfaces.sol index 31ce11972..a2b317248 100644 --- a/contracts/interfaces/ArbitrumBridgeInterfaces.sol +++ b/contracts/interfaces/ArbitrumBridgeInterfaces.sol @@ -70,24 +70,23 @@ interface ArbitrumInboxLike { ) external payable returns (uint256); /** - * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts - * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying - * for message using a custom gas token. - * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on upper layer + * @notice Put a message in the source chain inbox that can be reexecuted for some fixed amount of time if it reverts + * @dev Same as createRetryableTicket, but does not guarantee that submission will succeed by requiring the needed + * funds come from the deposit alone, rather than falling back on the user's balance + * @dev Advanced usage only (does not rewrite aliases for excessFeeRefundAddress and callValueRefundAddress). + * createRetryableTicket method is the recommended standard. * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - callValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. * @param to destination contract address * @param callValue call value for retryable message - * @param maxSubmissionCost Max gas deducted from user's upper layer balance to cover base submission fee - * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on upper layer. - * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on upper layer. + * @param maxSubmissionCost Max gas deducted from user's source chain balance to cover base submission fee + * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on source chain balance + * @param callValueRefundAddress callvalue gets credited here on source chain if retryable txn times out or gets cancelled * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost - * @param data ABI encoded data of message + * @param data ABI encoded data of the message * @return unique message number of the retryable transaction */ - function createRetryableTicket( + function unsafeCreateRetryableTicket( address to, uint256 callValue, uint256 maxSubmissionCost, @@ -95,28 +94,33 @@ interface ArbitrumInboxLike { address callValueRefundAddress, uint256 gasLimit, uint256 maxFeePerGas, - uint256 tokenTotalFeeAmount, bytes calldata data - ) external returns (uint256); + ) external payable returns (uint256); +} +/** + * @notice Interface which extends ArbitrumInboxLike with functions used to interact with bridges that use a custom gas token. + */ +interface ArbitrumCustomGasTokenInbox is ArbitrumInboxLike { /** - * @notice Put a message in the source chain inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Same as createRetryableTicket, but does not guarantee that submission will succeed by requiring the needed - * funds come from the deposit alone, rather than falling back on the user's balance - * @dev Advanced usage only (does not rewrite aliases for excessFeeRefundAddress and callValueRefundAddress). - * createRetryableTicket method is the recommended standard. + * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts + * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying + * for message using a custom gas token. + * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on upper layer * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error + * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - callValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. * @param to destination contract address * @param callValue call value for retryable message - * @param maxSubmissionCost Max gas deducted from user's source chain balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on source chain balance - * @param callValueRefundAddress callvalue gets credited here on source chain if retryable txn times out or gets cancelled + * @param maxSubmissionCost Max gas deducted from user's upper layer balance to cover base submission fee + * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on upper layer. + * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on upper layer. * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of the message + * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost + * @param data ABI encoded data of message * @return unique message number of the retryable transaction */ - function unsafeCreateRetryableTicket( + function createRetryableTicket( address to, uint256 callValue, uint256 maxSubmissionCost, @@ -124,14 +128,46 @@ interface ArbitrumInboxLike { address callValueRefundAddress, uint256 gasLimit, uint256 maxFeePerGas, + uint256 tokenTotalFeeAmount, bytes calldata data - ) external payable returns (uint256); + ) external returns (uint256); } /** - * @notice Generic gateway contract for bridging standard ERC20s to Arbitrum-like networks. + * @notice Generic gateway contract for bridging standard ERC20s to/from Arbitrum-like networks. + * @notice These function signatures are shared between the L1 and L2 gateway router contracts. */ -interface ArbitrumERC20GatewayLike { +interface ArbitrumL1ERC20GatewayLike { + /** + * @notice Deprecated in favor of outboundTransferCustomRefund but still used in custom bridges + * like the DAI bridge. + * @dev Refunded to aliased address of sender if sender has code on source chain, otherwise to to sender's EOA on destination chain. + * @param _sourceToken address of ERC20 + * @param _to Account to be credited with the tokens at the destination (can be the user's account or a contract), + * not subject to aliasing. This account, or its alias if it has code in the source chain, will also be able to + * cancel the retryable ticket and receive callvalue refund + * @param _amount Token Amount + * @param _maxGas Max gas deducted from user's balance to cover execution + * @param _gasPriceBid Gas price for execution + * @param _data encoded data from router and user + * @return res abi encoded inbox sequence number + */ + function outboundTransfer( + address _sourceToken, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data + ) external payable returns (bytes memory); + + /** + * @notice get ERC20 gateway for token. + * @param _token ERC20 address. + * @return address of ERC20 gateway. + */ + function getGateway(address _token) external view returns (address); + /** * @notice Deposit ERC20 token from Ethereum into Arbitrum-like networks. * @dev Upper layer address alias will not be applied to the following types of addresses on lower layer: @@ -159,34 +195,26 @@ interface ArbitrumERC20GatewayLike { uint256 _gasPriceBid, bytes calldata _data ) external payable returns (bytes memory); +} +interface ArbitrumL2ERC20GatewayLike { /** - * @notice Deprecated in favor of outboundTransferCustomRefund but still used in custom bridges - * like the DAI bridge. - * @dev Refunded to aliased address of sender if sender has code on source chain, otherwise to to sender's EOA on destination chain. - * @param _sourceToken address of ERC20 - * @param _to Account to be credited with the tokens at the destination (can be the user's account or a contract), - * not subject to aliasing. This account, or its alias if it has code in the source chain, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's balance to cover execution - * @param _gasPriceBid Gas price for execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number + * @notice Fetches the l2 token address from the gateway router for the input l1 token address + * @param _l1Erc20 address of the l1 token. + */ + function calculateL2TokenAddress(address _l1Erc20) external view returns (address); + + /** + * @notice Withdraws a specified amount of an l2 token to an l1 token. + * @param _l1Token address of the token to withdraw on L1. + * @param _to address on L1 which will receive the tokens upon withdrawal. + * @param _amount amount of the token to withdraw. + * @param _data encoded data to send to the gateway router. */ function outboundTransfer( - address _sourceToken, + address _l1Token, address _to, uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, bytes calldata _data ) external payable returns (bytes memory); - - /** - * @notice get ERC20 gateway for token. - * @param _token ERC20 address. - * @return address of ERC20 gateway. - */ - function getGateway(address _token) external view returns (address); } From 4c410980502de45456345379bb443212d12fa0cd Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 13:46:52 -0500 Subject: [PATCH 13/28] arbitrum withdrawal adapter refactoring Signed-off-by: bennett --- contracts/L2_TokenRetriever.sol | 25 ++++++++++------- .../l2/Arbitrum_WithdrawalAdapter.sol | 8 ++++-- .../l2/Ovm_WithdrawalAdapter.sol | 28 ++++++++----------- .../local/Arbitrum_WithdrawalAdapter.t.sol | 10 ++----- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/contracts/L2_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol index 7d96d473a..974163148 100644 --- a/contracts/L2_TokenRetriever.sol +++ b/contracts/L2_TokenRetriever.sol @@ -18,13 +18,18 @@ interface IBridgeAdapter { contract L2_TokenRetriever is Lockable { using SafeERC20Upgradeable for IERC20Upgradeable; + struct TokenPair { + address l1Token; + address l2Token; + } + // Should be set to the bridge adapter which contains the proper logic to withdraw tokens on // the deployed L2 address public immutable bridgeAdapter; // Should be set to the L1 address which will receive withdrawn tokens. address public immutable tokenRecipient; - error RetrieveFailed(address[] l2Tokens); + error RetrieveFailed(); /** * @notice Constructs the L2_TokenRetriever @@ -38,24 +43,24 @@ contract L2_TokenRetriever is Lockable { /** * @notice delegatecalls the bridge adapter to withdraw multiple different L2 tokens. - * @dev this is preferrable to multicalling `retrieve` since instead of `n` delegatecalls for `n` - * withdrawal txns, we can have 1 delegatecall for `n` withdrawal transactions. - * @param l2Tokens (current network's) contracts addresses of the l2 tokens to be withdrawn. + * @param tokenPairs l1 and l2 addresses of the token to withdraw. */ - function retrieve(address[] memory l2Tokens) external nonReentrant { - uint256 nWithdrawals = l2Tokens.length; + function retrieve(TokenPair[] memory tokenPairs) external nonReentrant { + uint256 nWithdrawals = tokenPairs.length; WithdrawalAdapter.WithdrawalInformation[] memory withdrawals = new WithdrawalAdapter.WithdrawalInformation[]( nWithdrawals ); + TokenPair memory tokenPair; for (uint256 i = 0; i < nWithdrawals; ++i) { - address l2Token = l2Tokens[i]; + tokenPair = tokenPairs[i]; withdrawals[i] = WithdrawalAdapter.WithdrawalInformation( tokenRecipient, - l2Token, - IERC20Upgradeable(l2Token).balanceOf(address(this)) + tokenPair.l1Token, + tokenPair.l2Token, + IERC20Upgradeable(tokenPair.l2Token).balanceOf(address(this)) ); } (bool success, ) = bridgeAdapter.delegatecall(abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals))); - if (!success) revert RetrieveFailed(l2Tokens); + if (!success) revert RetrieveFailed(); } } diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index 837192d4b..e8999eca6 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -4,7 +4,7 @@ // See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum pragma solidity ^0.8.19; -import { StandardBridgeLike } from "../../Arbitrum_SpokePool.sol"; +import { ArbitrumL2ERC20GatewayLike } from "../../interfaces/ArbitrumBridgeInterfaces.sol"; import { WithdrawalAdapter, ITokenMessenger } from "./WithdrawalAdapter.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -52,10 +52,12 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { _transferUsdc(recipient, amountToReturn); } else { require(l1TokenAddress != address(0), "Uninitialized mainnet token"); + ArbitrumL2ERC20GatewayLike tokenBridge = ArbitrumL2ERC20GatewayLike(l2Gateway); + require(tokenBridge.calculateL2TokenAddress(l1TokenAddress) == l2TokenAddress, "Invalid token mapping"); //slither-disable-next-line unused-return - StandardBridgeLike(l2Gateway).outboundTransfer( + tokenBridge.outboundTransfer( l1TokenAddress, // _l1Token. Address of the L1 token to bridge over. - recipient, // _to. Withdraw, over the bridge, to the l1 hub pool contract. + recipient, // _to. Withdraw, over the bridge, to the recipient. amountToReturn, // _amount. "" // _data. We don't need to send any data for the bridging action. ); diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol index cd899a1ea..bdb2c8eaa 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol @@ -7,7 +7,6 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; -import { IL2StandardERC20 } from "@eth-optimism/contracts/standards/IL2StandardERC20.sol"; import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol"; /** @@ -61,24 +60,23 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapter { IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, address _l2Gateway, - address _l2Eth, - address _wrappedNativeToken, - uint256 _l1Gas + IOvm_SpokePool _spokePool ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2Gateway) { - wrappedNativeToken = _wrappedNativeToken; - l2Eth = _l2Eth; - l1Gas = _l1Gas; + spokePool = _spokePool; + wrappedNativeToken = spokePool.wrappedNativeToken(); + l2Eth = spokePool.l2Eth(); + l1Gas = spokePool.l1Gas(); } /* * @notice Calls CCTP or the Optimism token gateway to withdraw tokens back to the recipient. * @param recipient L1 address of the recipient. - * @param amountToReturn amount of l2Token to send back. * @param l2TokenAddress address of the l2Token to send back. + * @param amountToReturn amount of l2Token to send back. */ function withdrawToken( address recipient, - address l1TokenAddress, + address, address l2TokenAddress, uint256 amountToReturn ) public override { @@ -105,21 +103,19 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapter { // we'd need to call bridgeERC20To and give allowance to the tokenBridge to spend l2Token from this contract. // Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2TokenAddress. else { - // TODO: This assumes that the token implements IOptimismMintableERC20. Many do not implement this, so you'll need - // to find a different solution. IL2ERC20Bridge tokenBridge = IL2ERC20Bridge( - l2TokenAddress.bridge() == address(0) + spokePool.tokenBridges(l2TokenAddress) == address(0) ? Lib_PredeployAddresses.L2_STANDARD_BRIDGE - : l2TokenAddress.bridge() + : spokePool.tokenBridges(l2TokenAddress) ); - if (l1TokenAddress != address(0)) { + if (spokePool.remoteL1Tokens(l2TokenAddress) != address(0)) { // If there is a mapping for this L2 token to an L1 token, then use the L1 token address and // call bridgeERC20To. IERC20(l2TokenAddress).safeIncreaseAllowance(address(tokenBridge), amountToReturn); - require(IL2StandardERC20(l2TokenAddress).l1Token() == l2TokenAddress, "INVALID_TOKEN_MAPPING"); + address remoteL1Token = spokePool.remoteL1Tokens(l2TokenAddress); tokenBridge.bridgeERC20To( l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. - l1TokenAddress, // Remote token to be received on L1 side. If the + remoteL1Token, // Remote token to be received on L1 side. If the // remoteL1Token on the other chain does not recognize the local token as the correct // pair token, the ERC20 bridge will fail and the tokens will be returned to sender on // this chain. diff --git a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index 567d68d30..91099c1cc 100644 --- a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -7,10 +7,9 @@ import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; import { L2_TokenRetriever } from "../../../../contracts/L2_TokenRetriever.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import "forge-std/console.sol"; - contract Token_ERC20 is ERC20 { constructor(string memory name, string memory symbol) ERC20(name, symbol) {} @@ -39,7 +38,6 @@ contract ArbitrumGatewayRouter { contract Arbitrum_WithdrawalAdapterTest is Test { Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; L2_TokenRetriever tokenRetriever; - Arbitrum_SpokePool arbitrumSpokePool; Token_ERC20 whitelistedToken; Token_ERC20 usdc; ArbitrumGatewayRouter gatewayRouter; @@ -52,8 +50,7 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // Token messenger is set so CCTP is activated. ITokenMessenger tokenMessenger; - error RetrieveFailed(address l2Token); - error RetrieveManyFailed(address[] l2Tokens); + error RetrieveFailed(address[] l2Tokens); function setUp() public { // Initialize mintable/burnable tokens. @@ -76,7 +73,6 @@ contract Arbitrum_WithdrawalAdapterTest is Test { function testWithdrawWhitelistedTokenNonCCTP(uint256 amountToReturn) public { // There should be no balance in any contract/EOA. assertEq(whitelistedToken.balanceOf(hubPool), 0); - assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); // Simulate a L3 -> L2 withdrawal into the token retriever. @@ -87,14 +83,12 @@ contract Arbitrum_WithdrawalAdapterTest is Test { // Ensure that the balances are updated (i.e. the token bridge contract was called). assertEq(whitelistedToken.balanceOf(hubPool), amountToReturn); - assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); } function testWithdrawOtherTokenNonCCTP(uint256 amountToReturn) public { // There should be no balance in any contract/EOA. assertEq(whitelistedToken.balanceOf(hubPool), 0); - assertEq(whitelistedToken.balanceOf(owner), 0); assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); // Simulate a L3 -> L2 withdrawal of an non-whitelisted token to the tokenRetriever contract. From f2e3c41f3a0f78ba94d2ee2dc749a782e54512e0 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 15:33:00 -0500 Subject: [PATCH 14/28] simplify forwarder base and make it a proxy Signed-off-by: bennett --- .../chain-adapters/ArbitrumForwarderBase.sol | 150 ------------------ contracts/chain-adapters/ForwarderBase.sol | 110 +++++++++++++ 2 files changed, 110 insertions(+), 150 deletions(-) delete mode 100644 contracts/chain-adapters/ArbitrumForwarderBase.sol create mode 100644 contracts/chain-adapters/ForwarderBase.sol diff --git a/contracts/chain-adapters/ArbitrumForwarderBase.sol b/contracts/chain-adapters/ArbitrumForwarderBase.sol deleted file mode 100644 index 3832519d9..000000000 --- a/contracts/chain-adapters/ArbitrumForwarderBase.sol +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -import { ArbitrumERC20Bridge, ArbitrumInboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridgeInterfaces.sol"; - -// solhint-disable-next-line contract-name-camelcase -abstract contract ArbitrumForwarderBase { - // Amount of gas token allocated to pay for the base submission fee. The base submission fee is a parameter unique to - // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their - // ticket’s calldata in the retry buffer. (current base submission fee is queryable via - // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address - // 0x000000000000000000000000000000000000006E. - // @dev This is immutable because we don't know what precision the custom gas token has. - uint256 public immutable L3_MAX_SUBMISSION_COST; - - // L3 Gas price bid for immediate L3 execution attempt (queryable via standard eth*gasPrice RPC) - uint256 public immutable L3_GAS_PRICE; // The standard is 5 gWei - - // Native token expected to be sent in L3 message. Should be 0 for all use cases of this constant, which - // includes sending messages from L2 to L3 and sending Custom gas token ERC20's, which won't be the native token - // on the L3 by definition. - uint256 public constant L3_CALL_VALUE = 0; - - // Gas limit for L3 execution of a cross chain token transfer sent via the inbox. - uint32 public constant RELAY_TOKENS_L3_GAS_LIMIT = 300_000; - // Gas limit for L3 execution of a message sent via the inbox. - uint32 public constant RELAY_MESSAGE_L3_GAS_LIMIT = 2_000_000; - - // This address on L3 receives extra gas token that is left over after relaying a message via the inbox. - address public immutable L3_REFUND_L3_ADDRESS; - - // This is the address which receives messages and tokens on L3, assumed to be the spoke pool. - address public immutable L3_SPOKE_POOL; - - // This is the address which has permission to relay root bundles/messages to the L3 spoke pool. - address public immutable CROSS_DOMAIN_ADMIN; - - // Inbox system contract to send messages to Arbitrum-like L3s. Token bridges use this to send tokens to L3. - // https://github.com/OffchainLabs/nitro-contracts/blob/f7894d3a6d4035ba60f51a7f1334f0f2d4f02dce/src/bridge/Inbox.sol - ArbitrumInboxLike public immutable L2_INBOX; - - // Router contract to send tokens to Arbitrum. Routes to correct gateway to bridge tokens. Internally this - // contract calls the Inbox. - // Generic gateway: https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ArbitrumGateway.sol - // Gateway used for communicating with chains that use custom gas tokens: - // https://github.com/OffchainLabs/token-bridge-contracts/blob/main/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol - ArbitrumL1ERC20GatewayLike public immutable L2_ERC20_GATEWAY_ROUTER; - - event TokensForwarded(address indexed l2Token, uint256 amount); - event MessageForwarded(address indexed target, bytes message); - - error RescueFailed(); - - /* - * @dev All functions with this modifier must revert if msg.sender != CROSS_DOMAIN_ADMIN, but each L2 may have - * unique aliasing logic, so it is up to the forwarder contract to verify that the sender is valid. - */ - modifier onlyAdmin() { - _requireAdminSender(); - _; - } - - /** - * @notice Constructs new Adapter. - * @param _l2ArbitrumInbox Inbox helper contract to send messages to Arbitrum-like L3s. - * @param _l2ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum-like L3s. - * @param _l3RefundL3Address L3 address to receive gas refunds on after a message is relayed. - * @param _l3MaxSubmissionCost Amount of gas token allocated to pay for the base submission fee. The base - * submission fee is a parameter unique to Arbitrum retryable transactions. This value is hardcoded - * and used for all messages sent by this adapter. - * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily - * stored in this contract on L2. - * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. - * In practice, this is the hub pool. - */ - constructor( - ArbitrumInboxLike _l2ArbitrumInbox, - ArbitrumL1ERC20GatewayLike _l2ERC20GatewayRouter, - address _l3RefundL3Address, - uint256 _l3MaxSubmissionCost, - uint256 _l3GasPrice, - address _l3SpokePool, - address _crossDomainAdmin - ) { - L2_INBOX = _l2ArbitrumInbox; - L2_ERC20_GATEWAY_ROUTER = _l2ERC20GatewayRouter; - L3_REFUND_L3_ADDRESS = _l3RefundL3Address; - L3_MAX_SUBMISSION_COST = _l3MaxSubmissionCost; - L3_GAS_PRICE = _l3GasPrice; - L3_SPOKE_POOL = _l3SpokePool; - CROSS_DOMAIN_ADMIN = _crossDomainAdmin; - } - - // Added so that this function may receive ETH in the event of stuck transactions. - receive() external payable {} - - /** - * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function - * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward - * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder - */ - fallback() external payable onlyAdmin { - _relayMessage(L3_SPOKE_POOL, msg.data); - } - - /** - * @notice This function can only be called via a rescue adapter. It is used to recover potentially stuck - * funds on this contract. - */ - function adminCall( - address target, - uint256 value, - bytes memory message - ) external onlyAdmin { - (bool success, ) = target.call{ value: value }(message); - if (!success) revert RescueFailed(); - } - - /** - * @notice Bridge tokens to an Arbitrum-like L3. - * @notice This contract must hold at least getL2CallValue() amount of ETH or custom gas token - * to send a message via the Inbox successfully, or the message will get stuck. - * @notice relayTokens should only send tokens to L3_SPOKE_POOL, so no access control is required. - * @param l2Token L2 token to deposit. - * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. - */ - function relayTokens(address l2Token, uint256 amount) external payable virtual; - - /** - * @notice Relay a message to a contract on L2. Implementation changes on whether the - * target bridge supports a custom gas token or not. - * @notice This contract must hold at least getL2CallValue() amount of the custom gas token - * to send a message via the Inbox successfully, or the message will get stuck. - * @notice This function should be implmented differently based on whether the L2-L3 bridge - * requires custom gas tokens to fund cross-chain transactions. - */ - function _relayMessage(address target, bytes memory message) internal virtual; - - // Function to be overridden to accomodate for each L2's unique method of address aliasing. - function _requireAdminSender() internal virtual; - - /** - * @notice Returns required amount of gas token to send a message via the Inbox. - * @param l3GasLimit L3 gas limit for the message. - * @return amount of gas token that this contract needs to hold in order for relayMessage to succeed. - */ - function getL2CallValue(uint32 l3GasLimit) public view returns (uint256) { - return L3_MAX_SUBMISSION_COST + L3_GAS_PRICE * l3GasLimit; - } -} diff --git a/contracts/chain-adapters/ForwarderBase.sol b/contracts/chain-adapters/ForwarderBase.sol new file mode 100644 index 000000000..ed5f27f02 --- /dev/null +++ b/contracts/chain-adapters/ForwarderBase.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { EIP712CrossChainUpgradeable } from "../upgradeable/EIP712CrossChainUpgradeable.sol"; + +abstract contract ForwarderBase is UUPSUpgradeable, EIP712CrossChainUpgradeable { + // L3 address of the recipient of fallback messages and tokens. + address public l3SpokePool; + + // L1 address of the contract which can relay messages to the l3SpokePool contract and update this proxy contract. + address public crossDomainAdmin; + + event TokensForwarded(address indexed l2Token, uint256 amount); + event MessageForwarded(address indexed target, bytes message); + event SetXDomainAdmin(address indexed crossDomainAdmin); + event SetL3SpokePool(address indexed l3SpokePool); + + error InvalidCrossDomainAdmin(); + error InvalidL3SpokePool(); + + /* + * @dev All functions with this modifier must revert if msg.sender != CROSS_DOMAIN_ADMIN, but each L2 may have + * unique aliasing logic, so it is up to the forwarder contract to verify that the sender is valid. + */ + modifier onlyAdmin() { + _requireAdminSender(); + _; + } + + /** + * @notice Initializes the forwarder contract. + * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily + * stored in this contract on L2. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + * In practice, this is the hub pool. + */ + function __Forwarder_init(address _l3SpokePool, address _crossDomainAdmin) public onlyInitializing { + __UUPSUpgradeable_init(); + __EIP712_init("ACROSS-V2", "1.0.0"); + _setL3SpokePool(_l3SpokePool); + _setCrossDomainAdmin(_crossDomainAdmin); + } + + // Added so that this function may receive ETH in the event of stuck transactions. + receive() external payable {} + + /** + * @notice When called by the cross domain admin (i.e. the hub pool), the msg.data should be some function + * recognizable by the L3 spoke pool, such as "relayRootBundle" or "upgradeTo". Therefore, we simply forward + * this message to the L3 spoke pool using the implemented messaging logic of the L2 forwarder + */ + fallback() external payable onlyAdmin { + _relayL3Message(l3SpokePool, msg.data); + } + + /** + * @notice Sets a new cross domain admin for this contract. + * @param _newCrossDomainAdmin L1 address of the new cross domain admin. + */ + function setCrossDomainAdmin(address _newCrossDomainAdmin) external onlyAdmin { + _setCrossDomainAdmin(_newCrossDomainAdmin); + } + + /** + * @notice Sets a new spoke pool address. + * @param _newL3SpokePool L3 address of the new spoke pool contract. + */ + function setL3SpokePool(address _newL3SpokePool) external onlyAdmin { + _setL3SpokePool(_newL3SpokePool); + } + + /** + * @notice Bridge tokens to an L3. + * @notice relayTokens should only send tokens to L3_SPOKE_POOL, so no access control is required. + * @param l2Token L2 token to deposit. + * @param l3Token L3 token to receive on the destination chain. + * @param amount Amount of L2 tokens to deposit and L3 tokens to receive. + */ + function relayTokens( + address l2Token, + address l3Token, + uint256 amount + ) external payable virtual; + + /** + * @notice Relay a message to a contract on L3. Implementation changes on whether the + * @notice This function should be implmented differently based on whether the L2-L3 bridge + * requires custom gas tokens to fund cross-chain transactions. + */ + function _relayL3Message(address target, bytes memory message) internal virtual; + + // Function to be overridden to accomodate for each L2's unique method of address aliasing. + function _requireAdminSender() internal virtual; + + // Use the same access control logic implemented in the forwarders to authorize an upgrade. + function _authorizeUpgrade(address) internal virtual override onlyAdmin {} + + function _setCrossDomainAdmin(address _newCrossDomainAdmin) internal { + if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin(); + crossDomainAdmin = _newCrossDomainAdmin; + emit SetXDomainAdmin(_newCrossDomainAdmin); + } + + function _setL3SpokePool(address _newL3SpokePool) internal { + if (_newL3SpokePool == address(0)) revert InvalidL3SpokePool(); + l3SpokePool = _newL3SpokePool; + emit SetL3SpokePool(_newL3SpokePool); + } +} From f251354340210063881e794a59bed95274142e32 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 15:38:52 -0500 Subject: [PATCH 15/28] disable implementation initialization Signed-off-by: bennett --- contracts/chain-adapters/ForwarderBase.sol | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contracts/chain-adapters/ForwarderBase.sol b/contracts/chain-adapters/ForwarderBase.sol index ed5f27f02..cf7630048 100644 --- a/contracts/chain-adapters/ForwarderBase.sol +++ b/contracts/chain-adapters/ForwarderBase.sol @@ -28,6 +28,15 @@ abstract contract ForwarderBase is UUPSUpgradeable, EIP712CrossChainUpgradeable _; } + /** + @notice Constructs the Forwarder contract. + @dev _disableInitializers() restricts anybody from initializing the implementation contract, which if not done, + * may disrupt the proxy if another EOA were to initialize it. + */ + constructor() { + _disableInitializers(); + } + /** * @notice Initializes the forwarder contract. * @param _l3SpokePool L3 address of the contract which will receive messages and tokens which are temporarily From 982eff72b290c485b46d9c29b5c610325e1c26d1 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 23 Sep 2024 16:04:08 -0500 Subject: [PATCH 16/28] update tests for new arbitrum bridge checks Signed-off-by: bennett --- .../local/Arbitrum_WithdrawalAdapter.t.sol | 85 +++++++++---------- 1 file changed, 38 insertions(+), 47 deletions(-) diff --git a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol index 91099c1cc..d40b0728b 100644 --- a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol @@ -23,86 +23,77 @@ contract Token_ERC20 is ERC20 { } contract ArbitrumGatewayRouter { + mapping(address => address) l2TokenAddress; + function outboundTransfer( address tokenToBridge, address recipient, uint256 amountToReturn, bytes memory ) external returns (bytes memory) { - Token_ERC20(tokenToBridge).burn(msg.sender, amountToReturn); + address l2Token = l2TokenAddress[tokenToBridge]; + Token_ERC20(l2Token).burn(msg.sender, amountToReturn); Token_ERC20(tokenToBridge).mint(recipient, amountToReturn); return ""; } + + function calculateL2TokenAddress(address l1Token) external view returns (address) { + return l2TokenAddress[l1Token]; + } + + function setTokenPair(address l1Token, address l2Token) external { + l2TokenAddress[l1Token] = l2Token; + } } contract Arbitrum_WithdrawalAdapterTest is Test { Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; L2_TokenRetriever tokenRetriever; - Token_ERC20 whitelistedToken; - Token_ERC20 usdc; ArbitrumGatewayRouter gatewayRouter; + Token_ERC20 l1Token; + Token_ERC20 l2Token; + Token_ERC20 l2Usdc; // HubPool should receive funds. address hubPool; - address aliasedOwner; - address wrappedNativeToken; // Token messenger is set so CCTP is activated. ITokenMessenger tokenMessenger; - error RetrieveFailed(address[] l2Tokens); + error RetrieveFailed(); function setUp() public { - // Initialize mintable/burnable tokens. - whitelistedToken = new Token_ERC20("TOKEN", "TOKEN"); - usdc = new Token_ERC20("USDC", "USDC"); - // Initialize mock bridge. + l1Token = new Token_ERC20("TOKEN", "TOKEN"); + l2Token = new Token_ERC20("TOKEN", "TOKEN"); + l2Usdc = new Token_ERC20("USDC", "USDC"); gatewayRouter = new ArbitrumGatewayRouter(); // Instantiate all other addresses used in the system. tokenMessenger = ITokenMessenger(vm.addr(1)); - wrappedNativeToken = vm.addr(2); - hubPool = vm.addr(3); - - arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter(usdc, tokenMessenger, address(gatewayRouter)); + hubPool = vm.addr(2); - // Create the token retriever contract. + gatewayRouter.setTokenPair(address(l1Token), address(l2Token)); + arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter(l2Usdc, tokenMessenger, address(gatewayRouter)); tokenRetriever = new L2_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); } - function testWithdrawWhitelistedTokenNonCCTP(uint256 amountToReturn) public { - // There should be no balance in any contract/EOA. - assertEq(whitelistedToken.balanceOf(hubPool), 0); - assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - - // Simulate a L3 -> L2 withdrawal into the token retriever. - whitelistedToken.mint(address(tokenRetriever), amountToReturn); - - // Attempt to withdraw the token. - tokenRetriever.retrieve(address(whitelistedToken)); - - // Ensure that the balances are updated (i.e. the token bridge contract was called). - assertEq(whitelistedToken.balanceOf(hubPool), amountToReturn); - assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - } - - function testWithdrawOtherTokenNonCCTP(uint256 amountToReturn) public { - // There should be no balance in any contract/EOA. - assertEq(whitelistedToken.balanceOf(hubPool), 0); - assertEq(whitelistedToken.balanceOf(address(tokenRetriever)), 0); - - // Simulate a L3 -> L2 withdrawal of an non-whitelisted token to the tokenRetriever contract. - whitelistedToken.mint(address(tokenRetriever), amountToReturn); - - // Attempt to withdraw the token. - vm.expectRevert(abi.encodeWithSelector(RetrieveFailed.selector, address(whitelistedToken))); - tokenRetriever.retrieve(address(whitelistedToken)); + function testWithdrawToken(uint256 amountToReturn) public { + l2Token.mint(address(tokenRetriever), amountToReturn); + assertEq(amountToReturn, l2Token.totalSupply()); + L2_TokenRetriever.TokenPair[] memory tokenPairs = new L2_TokenRetriever.TokenPair[](1); + tokenPairs[0] = L2_TokenRetriever.TokenPair({ l1Token: address(l1Token), l2Token: address(l2Token) }); + tokenRetriever.retrieve(tokenPairs); + assertEq(0, l2Token.totalSupply()); + assertEq(amountToReturn, l1Token.totalSupply()); + assertEq(l1Token.balanceOf(hubPool), amountToReturn); } - function _applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { - // Allows overflows as explained above. - unchecked { - l2Address = address(uint160(l1Address) + uint160(0x1111000000000000000000000000000000001111)); - } + function testWithdrawTokenFailure(uint256 amountToReturn, address invalidToken) public { + l2Token.mint(address(tokenRetriever), amountToReturn); + assertEq(amountToReturn, l2Token.totalSupply()); + L2_TokenRetriever.TokenPair[] memory tokenPairs = new L2_TokenRetriever.TokenPair[](1); + tokenPairs[0] = L2_TokenRetriever.TokenPair({ l1Token: invalidToken, l2Token: address(l2Token) }); + vm.expectRevert(L2_TokenRetriever.RetrieveFailed.selector); + tokenRetriever.retrieve(tokenPairs); } } From 546f9ffb577b490107e4a514cf076f09642855cb Mon Sep 17 00:00:00 2001 From: bennett Date: Tue, 24 Sep 2024 14:24:50 -0500 Subject: [PATCH 17/28] change variable names Signed-off-by: bennett --- contracts/L2_TokenRetriever.sol | 22 ++++++++++--------- .../chain-adapters/l2/WithdrawalAdapter.sol | 14 ++++++------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/contracts/L2_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol index 974163148..34bce89b6 100644 --- a/contracts/L2_TokenRetriever.sol +++ b/contracts/L2_TokenRetriever.sol @@ -7,7 +7,7 @@ import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ import { WithdrawalAdapter } from "./chain-adapters/l2/WithdrawalAdapter.sol"; interface IBridgeAdapter { - function withdrawTokens(WithdrawalAdapter.WithdrawalInformation[] memory) external; + function withdrawTokens(WithdrawalAdapter.WithdrawalInstruction[] memory) external; } /** @@ -23,9 +23,9 @@ contract L2_TokenRetriever is Lockable { address l2Token; } - // Should be set to the bridge adapter which contains the proper logic to withdraw tokens on - // the deployed L2 - address public immutable bridgeAdapter; + // Should be set to the withdrawal which contains the proper logic to withdraw tokens on + // the deployed L2. + address public immutable l2WithdrawalAdapter; // Should be set to the L1 address which will receive withdrawn tokens. address public immutable tokenRecipient; @@ -33,11 +33,11 @@ contract L2_TokenRetriever is Lockable { /** * @notice Constructs the L2_TokenRetriever - * @param _bridgeAdapter contract which contains network's bridging logic. + * @param _l2WithdrawalAdapter contract which contains network's bridging logic. * @param _tokenRecipient L1 address of the recipient of withdrawn tokens. */ - constructor(address _bridgeAdapter, address _tokenRecipient) { - bridgeAdapter = _bridgeAdapter; + constructor(address _l2WithdrawalAdapter, address _tokenRecipient) { + l2WithdrawalAdapter = _l2WithdrawalAdapter; tokenRecipient = _tokenRecipient; } @@ -47,20 +47,22 @@ contract L2_TokenRetriever is Lockable { */ function retrieve(TokenPair[] memory tokenPairs) external nonReentrant { uint256 nWithdrawals = tokenPairs.length; - WithdrawalAdapter.WithdrawalInformation[] memory withdrawals = new WithdrawalAdapter.WithdrawalInformation[]( + WithdrawalAdapter.WithdrawalInstruction[] memory withdrawals = new WithdrawalAdapter.WithdrawalInstruction[]( nWithdrawals ); TokenPair memory tokenPair; for (uint256 i = 0; i < nWithdrawals; ++i) { tokenPair = tokenPairs[i]; - withdrawals[i] = WithdrawalAdapter.WithdrawalInformation( + withdrawals[i] = WithdrawalAdapter.WithdrawalInstruction( tokenRecipient, tokenPair.l1Token, tokenPair.l2Token, IERC20Upgradeable(tokenPair.l2Token).balanceOf(address(this)) ); } - (bool success, ) = bridgeAdapter.delegatecall(abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals))); + (bool success, ) = l2WithdrawalAdapter.delegatecall( + abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals)) + ); if (!success) revert RetrieveFailed(); } } diff --git a/contracts/chain-adapters/l2/WithdrawalAdapter.sol b/contracts/chain-adapters/l2/WithdrawalAdapter.sol index 73088baec..a0154d593 100644 --- a/contracts/chain-adapters/l2/WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/WithdrawalAdapter.sol @@ -12,7 +12,7 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s abstract contract WithdrawalAdapter is CircleCCTPAdapter { using SafeERC20 for IERC20; - struct WithdrawalInformation { + struct WithdrawalInstruction { // L1 address of the recipient. address recipient; // Address of the l1 token to receive. @@ -41,14 +41,14 @@ abstract contract WithdrawalAdapter is CircleCCTPAdapter { /* * @notice withdraws tokens to Ethereum given the input parameters. - * @param withdrawalInformation array containing information to withdraw a token. Includes the L1 recipient + * @param withdrawals array containing information to withdraw a token. Includes the L1 recipient * address, the amount to withdraw, and the token address of the L2 token to withdraw. */ - function withdrawTokens(WithdrawalInformation[] memory withdrawalInformation) external { - uint256 informationLength = withdrawalInformation.length; - WithdrawalInformation memory withdrawal; - for (uint256 i = 0; i < informationLength; ++i) { - withdrawal = withdrawalInformation[i]; + function withdrawTokens(WithdrawalInstruction[] memory withdrawals) external { + uint256 nWithdrawals = withdrawals.length; + WithdrawalInstruction memory withdrawal; + for (uint256 i = 0; i < nWithdrawals; ++i) { + withdrawal = withdrawals[i]; withdrawToken( withdrawal.recipient, withdrawal.l1TokenAddress, From 4db2572d1a4b4bf7ebb81d87659f57e3afda4f0b Mon Sep 17 00:00:00 2001 From: bennett Date: Wed, 2 Oct 2024 13:23:31 -0500 Subject: [PATCH 18/28] refactor of withdrawal adapter format Signed-off-by: bennett --- contracts/L2_TokenRetriever.sol | 68 ------ .../l2/Arbitrum_WithdrawalAdapter.sol | 75 +++--- .../l2/Ovm_WithdrawalAdapter.sol | 104 +++++---- .../chain-adapters/l2/WithdrawalAdapter.sol | 74 ------ .../l2/WithdrawalAdapterBase.sol | 60 +++++ .../interfaces/ArbitrumBridgeInterfaces.sol | 220 ------------------ contracts/test/ArbitrumMocks.sol | 29 ++- contracts/test/MockBedrockStandardBridge.sol | 18 +- .../local/Arbitrum_WithdrawalAdapter.t.sol | 99 -------- .../evm/foundry/local/WithdrawalAdapter.t.sol | 209 +++++++++++++++++ 10 files changed, 407 insertions(+), 549 deletions(-) delete mode 100644 contracts/L2_TokenRetriever.sol delete mode 100644 contracts/chain-adapters/l2/WithdrawalAdapter.sol create mode 100644 contracts/chain-adapters/l2/WithdrawalAdapterBase.sol delete mode 100644 contracts/interfaces/ArbitrumBridgeInterfaces.sol delete mode 100644 test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol create mode 100644 test/evm/foundry/local/WithdrawalAdapter.t.sol diff --git a/contracts/L2_TokenRetriever.sol b/contracts/L2_TokenRetriever.sol deleted file mode 100644 index 34bce89b6..000000000 --- a/contracts/L2_TokenRetriever.sol +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.19; - -import { Lockable } from "./Lockable.sol"; -import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; -import { WithdrawalAdapter } from "./chain-adapters/l2/WithdrawalAdapter.sol"; - -interface IBridgeAdapter { - function withdrawTokens(WithdrawalAdapter.WithdrawalInstruction[] memory) external; -} - -/** - * @notice Contract deployed on an arbitrary L2 to act as an intermediate contract for withdrawals from L3 to L1. - * @dev Since each network has its own bridging requirements, this contract delegates that logic to a bridge adapter contract - * which performs the necessary withdraw action. - */ -contract L2_TokenRetriever is Lockable { - using SafeERC20Upgradeable for IERC20Upgradeable; - - struct TokenPair { - address l1Token; - address l2Token; - } - - // Should be set to the withdrawal which contains the proper logic to withdraw tokens on - // the deployed L2. - address public immutable l2WithdrawalAdapter; - // Should be set to the L1 address which will receive withdrawn tokens. - address public immutable tokenRecipient; - - error RetrieveFailed(); - - /** - * @notice Constructs the L2_TokenRetriever - * @param _l2WithdrawalAdapter contract which contains network's bridging logic. - * @param _tokenRecipient L1 address of the recipient of withdrawn tokens. - */ - constructor(address _l2WithdrawalAdapter, address _tokenRecipient) { - l2WithdrawalAdapter = _l2WithdrawalAdapter; - tokenRecipient = _tokenRecipient; - } - - /** - * @notice delegatecalls the bridge adapter to withdraw multiple different L2 tokens. - * @param tokenPairs l1 and l2 addresses of the token to withdraw. - */ - function retrieve(TokenPair[] memory tokenPairs) external nonReentrant { - uint256 nWithdrawals = tokenPairs.length; - WithdrawalAdapter.WithdrawalInstruction[] memory withdrawals = new WithdrawalAdapter.WithdrawalInstruction[]( - nWithdrawals - ); - TokenPair memory tokenPair; - for (uint256 i = 0; i < nWithdrawals; ++i) { - tokenPair = tokenPairs[i]; - withdrawals[i] = WithdrawalAdapter.WithdrawalInstruction( - tokenRecipient, - tokenPair.l1Token, - tokenPair.l2Token, - IERC20Upgradeable(tokenPair.l2Token).balanceOf(address(this)) - ); - } - (bool success, ) = l2WithdrawalAdapter.delegatecall( - abi.encodeCall(IBridgeAdapter.withdrawTokens, (withdrawals)) - ); - if (!success) revert RetrieveFailed(); - } -} diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol index e8999eca6..020c1ca16 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol @@ -4,60 +4,73 @@ // See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum pragma solidity ^0.8.19; -import { ArbitrumL2ERC20GatewayLike } from "../../interfaces/ArbitrumBridgeInterfaces.sol"; -import { WithdrawalAdapter, ITokenMessenger } from "./WithdrawalAdapter.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ArbitrumL2ERC20GatewayLike } from "../../interfaces/ArbitrumBridge.sol"; +import { WithdrawalAdapterBase } from "./WithdrawalAdapterBase.sol"; +import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol"; /** - * @notice AVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. + * @title Arbitrum_WithdrawalAdapter + * @notice This contract interfaces with L2-L1 token bridges and withdraws tokens to a single address on L1. + * @dev This contract should be deployed on Arbitrum L2s which only use CCTP or the canonical Arbitrum gateway router to withdraw tokens. * @custom:security-contact bugs@across.to */ - -/** - * @title Adapter for interacting with bridges from the Arbitrum One L2 to Ethereum mainnet. - * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. - */ -contract Arbitrum_WithdrawalAdapter is WithdrawalAdapter { +contract Arbitrum_WithdrawalAdapter is WithdrawalAdapterBase { using SafeERC20 for IERC20; + // Error which triggers when the supplied L1 token does not match the Arbitrum gateway router's expected L2 token. + error InvalidTokenMapping(); + /* - * @notice constructs the withdrawal adapter. - * @param _l2Usdc address of native USDC on the L2. - * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. - * @param _spokePool address of the spoke pool on L2. - * @param _l2GatewayRouter address of the Arbitrum l2 gateway router contract. + * @notice Constructs the Arbitrum_WithdrawalAdapter. + * @param _l2Usdc Address of native USDC on the L2. + * @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2. + * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this is 0. + * @param _l2GatewayRouter Address of the Arbitrum l2 gateway router contract. + * @param _tokenRecipient L1 Address which will unconditionally receive tokens withdrawn from this contract. */ constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, - address _l2GatewayRouter - ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2GatewayRouter) {} + uint32 _destinationCircleDomainId, + address _l2GatewayRouter, + address _tokenRecipient + ) + WithdrawalAdapterBase( + _l2Usdc, + _cctpTokenMessenger, + _destinationCircleDomainId, + _l2GatewayRouter, + _tokenRecipient + ) + {} /* - * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the `tokenRetriever`. The - * bridge will not be called if the token is not in the Arbitrum_SpokePool's `whitelistedTokens` mapping. - * @param recipient L1 address of the recipient. - * @param amountToReturn amount of l2Token to send back. - * @param l2TokenAddress address of the l2Token to send back. + * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the TOKEN_RECIPIENT L1 address. + * @param l1Token Address of the L1 token to receive. + * @param l2Token Address of the L2 token to send back. + * @param amountToReturn Amount of l2Token to send back. */ function withdrawToken( - address recipient, - address l1TokenAddress, - address l2TokenAddress, + address l1Token, + address l2Token, uint256 amountToReturn ) public override { // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. - if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { - _transferUsdc(recipient, amountToReturn); + if (_isCCTPEnabled() && l2Token == address(usdcToken)) { + _transferUsdc(TOKEN_RECIPIENT, amountToReturn); } else { - require(l1TokenAddress != address(0), "Uninitialized mainnet token"); - ArbitrumL2ERC20GatewayLike tokenBridge = ArbitrumL2ERC20GatewayLike(l2Gateway); - require(tokenBridge.calculateL2TokenAddress(l1TokenAddress) == l2TokenAddress, "Invalid token mapping"); + // Otherwise, we use the Arbitrum ERC20 Gateway router. + ArbitrumL2ERC20GatewayLike tokenBridge = ArbitrumL2ERC20GatewayLike(L2_TOKEN_GATEWAY); + // If the gateway router's expected L2 token address does not match then revert. This check does not actually + // impact whether the bridge will succeed, since the ERC20 gateway router only requires the L1 token address, but + // it is added here to potentially catch scenarios where there was a mistake in the calldata. + if (tokenBridge.calculateL2TokenAddress(l1Token) != l2Token) revert InvalidTokenMapping(); //slither-disable-next-line unused-return tokenBridge.outboundTransfer( - l1TokenAddress, // _l1Token. Address of the L1 token to bridge over. - recipient, // _to. Withdraw, over the bridge, to the recipient. + l1Token, // _l1Token. Address of the L1 token to bridge over. + TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the recipient. amountToReturn, // _amount. "" // _data. We don't need to send any data for the bridging action. ); diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol index bdb2c8eaa..be0ae49e6 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol @@ -2,132 +2,148 @@ pragma solidity ^0.8.0; -import { WithdrawalAdapter, ITokenMessenger } from "./WithdrawalAdapter.sol"; +import { WithdrawalAdapterBase } from "./WithdrawalAdapterBase.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol"; +import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol"; /** - * @notice OVM specific bridge adapter. Implements logic to bridge tokens back to mainnet. - * @custom:security-contact bugs@across.to + * @notice Minimal interface for the Ovm_SpokePool contract. This interface is called to pull state from the network's + * spoke pool contract to be used by this withdrawal adapter. */ - interface IOvm_SpokePool { - // @dev Returns the address of the token bridge for the input l2 token. + // Returns the address of the token bridge for the input l2 token. function tokenBridges(address token) external view returns (address); - // @dev Returns the address of the l1 token set in the spoke pool for the input l2 token. + // Returns the address of the l1 token set in the spoke pool for the input l2 token. function remoteL1Tokens(address token) external view returns (address); - // @dev Returns the address for the representation of ETH on the l2. + // Returns the address for the representation of ETH on the l2. function l2Eth() external view returns (address); - // @dev Returns the address of the wrapped native token for the L2. + // Returns the address of the wrapped native token for the L2. function wrappedNativeToken() external view returns (WETH9Interface); - // @dev Returns the amount of gas the contract allocates for a token withdrawal. + // Returns the amount of gas the contract allocates for a token withdrawal. function l1Gas() external view returns (uint32); } /** - * @title Adapter for interacting with bridges from an OpStack L2 to Ethereum mainnet. - * @notice This contract is used to share L2-L1 bridging logic with other L2 Across contracts. + * @title Ovm_WithdrawalAdapter + * @notice This contract interfaces with L2-L1 token bridges and withdraws tokens to a single address on L1. + * @dev This contract should be deployed on OpStack L2s which both have a Ovm_SpokePool contract deployed to the L2 + * network AND only use token bridges defined in the Ovm_SpokePool. A notable exception to this requirement is Optimism, + * which has a special SNX bridge (and thus this adapter will NOT work for Optimism). + * @custom:security-contact bugs@across.to */ -contract Ovm_WithdrawalAdapter is WithdrawalAdapter { +contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { using SafeERC20 for IERC20; // Address for the wrapped native token on this chain. For Ovm standard bridges, we need to unwrap // this token before initiating the withdrawal. Normally, it is 0x42..006, but there are instances // where this address is different. WETH9Interface public immutable wrappedNativeToken; - // Address which represents the native token on L2. For OpStack chains, this is generally 0xDeadDeAdde...aDDeAD0000. - address public immutable l2Eth; - // Stores required gas to send tokens back to L1. - uint32 public immutable l1Gas; // Address of the corresponding spoke pool on L2. This is to piggyback off of the spoke pool's supported // token routes/defined token bridges. IOvm_SpokePool public immutable spokePool; + // Address of native ETH on the l2. For OpStack chains, this address is used to indicate a native ETH withdrawal. + // In general, this address is 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. + address public immutable l2Eth; /* - * @notice constructs the withdrawal adapter. - * @param _l2Usdc address of native USDC on the L2. - * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. - * @param _l2Gateway address of the Optimism ERC20 l2 standard bridge contract. + * @notice Constructs the Ovm_WithdrawalAdapter. + * @param _l2Usdc Address of native USDC on the L2. + * @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2. + * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this + * is 0. + * @param _l2Gateway Address of the Optimism ERC20 L2 standard bridge contract. + * @param _tokenRecipient The L1 address which will unconditionally receive tokens from withdrawals by this contract. + * @param _spokePool The contract address of the Ovm_SpokePool which is deployed on this L2 network. */ constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, + uint32 _destinationCircleDomainId, address _l2Gateway, + address _tokenRecipient, IOvm_SpokePool _spokePool - ) WithdrawalAdapter(_l2Usdc, _cctpTokenMessenger, _l2Gateway) { + ) WithdrawalAdapterBase(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2Gateway, _tokenRecipient) { spokePool = _spokePool; + + // These addresses should only change network-by-network, or after a bridge upgrade, so we define them once in the constructor. wrappedNativeToken = spokePool.wrappedNativeToken(); l2Eth = spokePool.l2Eth(); - l1Gas = spokePool.l1Gas(); } /* * @notice Calls CCTP or the Optimism token gateway to withdraw tokens back to the recipient. - * @param recipient L1 address of the recipient. - * @param l2TokenAddress address of the l2Token to send back. + * @param l2Token address of the l2Token to send back. * @param amountToReturn amount of l2Token to send back. + * @dev The l1Token parameter is unused since we obtain the l1Token to receive by querying the state of the Ovm_SpokePool deployed + * to this network. + * @dev This function is a copy of the `_bridgeTokensToHubPool` function found on the Ovm_SpokePool contract here: + * https://github.com/across-protocol/contracts/blob/65191dbcded95c8fe050e0f95eb7848e3784e61f/contracts/Ovm_SpokePool.sol#L148. + * New lines of code correspond to instances where this contract queries state from the spoke pool, such as determining + * the appropriate token bridge for the withdrawal or finding the remoteL1Token to withdraw. */ function withdrawToken( - address recipient, address, - address l2TokenAddress, + address l2Token, uint256 amountToReturn ) public override { + // Fetch the current l1Gas defined in the Ovm_SpokePool. + uint32 l1Gas = spokePool.l1Gas(); // If the token being bridged is WETH then we need to first unwrap it to ETH and then send ETH over the // canonical bridge. On Optimism, this is address 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. - if (l2TokenAddress == address(wrappedNativeToken)) { - WETH9Interface(l2TokenAddress).withdraw(amountToReturn); // Unwrap into ETH. - l2TokenAddress = l2Eth; // Set the l2TokenAddress to ETH. + if (l2Token == address(wrappedNativeToken)) { + WETH9Interface(l2Token).withdraw(amountToReturn); // Unwrap into ETH. + l2Token = l2Eth; // Set the l2Token to ETH. IL2ERC20Bridge(Lib_PredeployAddresses.L2_STANDARD_BRIDGE).withdrawTo{ value: amountToReturn }( - l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. - recipient, // _to. Withdraw, over the bridge, to the l1 pool contract. + l2Token, // _l2Token. Address of the L2 token to bridge over. + TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the l1 pool contract. amountToReturn, // _amount. l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations "" // _data. We don't need to send any data for the bridging action. ); } // If the token is USDC && CCTP bridge is enabled, then bridge USDC via CCTP. - else if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { - _transferUsdc(recipient, amountToReturn); + else if (_isCCTPEnabled() && l2Token == address(usdcToken)) { + _transferUsdc(TOKEN_RECIPIENT, amountToReturn); } // Note we'll default to withdrawTo instead of bridgeERC20To unless the remoteL1Tokens mapping is set for - // the l2TokenAddress. withdrawTo should be used to bridge back non-native L2 tokens + // the l2Token. withdrawTo should be used to bridge back non-native L2 tokens // (i.e. non-native L2 tokens have a canonical L1 token). If we should bridge "native L2" tokens then // we'd need to call bridgeERC20To and give allowance to the tokenBridge to spend l2Token from this contract. - // Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2TokenAddress. + // Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2Token. else { IL2ERC20Bridge tokenBridge = IL2ERC20Bridge( - spokePool.tokenBridges(l2TokenAddress) == address(0) + spokePool.tokenBridges(l2Token) == address(0) ? Lib_PredeployAddresses.L2_STANDARD_BRIDGE - : spokePool.tokenBridges(l2TokenAddress) + : spokePool.tokenBridges(l2Token) ); - if (spokePool.remoteL1Tokens(l2TokenAddress) != address(0)) { + address remoteL1Token = spokePool.remoteL1Tokens(l2Token); + if (remoteL1Token != address(0)) { // If there is a mapping for this L2 token to an L1 token, then use the L1 token address and // call bridgeERC20To. - IERC20(l2TokenAddress).safeIncreaseAllowance(address(tokenBridge), amountToReturn); - address remoteL1Token = spokePool.remoteL1Tokens(l2TokenAddress); + IERC20(l2Token).safeIncreaseAllowance(address(tokenBridge), amountToReturn); tokenBridge.bridgeERC20To( - l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. + l2Token, // _l2Token. Address of the L2 token to bridge over. remoteL1Token, // Remote token to be received on L1 side. If the // remoteL1Token on the other chain does not recognize the local token as the correct // pair token, the ERC20 bridge will fail and the tokens will be returned to sender on // this chain. - recipient, // _to + TOKEN_RECIPIENT, // _to amountToReturn, // _amount l1Gas, // _l1Gas "" // _data ); } else { tokenBridge.withdrawTo( - l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. - recipient, // _to. Withdraw, over the bridge, to the l1 pool contract. + l2Token, // _l2Token. Address of the L2 token to bridge over. + TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the l1 pool contract. amountToReturn, // _amount. l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations "" // _data. We don't need to send any data for the bridging action. diff --git a/contracts/chain-adapters/l2/WithdrawalAdapter.sol b/contracts/chain-adapters/l2/WithdrawalAdapter.sol deleted file mode 100644 index a0154d593..000000000 --- a/contracts/chain-adapters/l2/WithdrawalAdapter.sol +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.19; - -import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libraries/CircleCCTPAdapter.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -/** - * @title Adapter for interacting with bridges from a generic L2 to Ethereum mainnet. - * @notice This contract is used to share L2-L1 bridging logic with other Across contracts. - */ -abstract contract WithdrawalAdapter is CircleCCTPAdapter { - using SafeERC20 for IERC20; - - struct WithdrawalInstruction { - // L1 address of the recipient. - address recipient; - // Address of the l1 token to receive. - address l1TokenAddress; - // Address of l2 token to withdraw. - address l2TokenAddress; - // Amount of l2 Token to return. - uint256 amountToReturn; - } - - address public immutable l2Gateway; - - /* - * @notice constructs the withdrawal adapter. - * @param _l2Usdc address of native USDC on the L2. - * @param _cctpTokenMessenger address of the CCTP token messenger contract on L2. - * @param _l2Gateway address of the network's l2 token gateway/bridge contract. - */ - constructor( - IERC20 _l2Usdc, - ITokenMessenger _cctpTokenMessenger, - address _l2Gateway - ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { - l2Gateway = _l2Gateway; - } - - /* - * @notice withdraws tokens to Ethereum given the input parameters. - * @param withdrawals array containing information to withdraw a token. Includes the L1 recipient - * address, the amount to withdraw, and the token address of the L2 token to withdraw. - */ - function withdrawTokens(WithdrawalInstruction[] memory withdrawals) external { - uint256 nWithdrawals = withdrawals.length; - WithdrawalInstruction memory withdrawal; - for (uint256 i = 0; i < nWithdrawals; ++i) { - withdrawal = withdrawals[i]; - withdrawToken( - withdrawal.recipient, - withdrawal.l1TokenAddress, - withdrawal.l2TokenAddress, - withdrawal.amountToReturn - ); - } - } - - /* - * @notice implementation for withdrawing a specific token back to Ethereum. This is to be implemented - * for each different L2, since each L2 has various mappings for L1<->L2 tokens. - * @param recipient L1 address of the recipient. - * @param amountToReturn amount of l2Token to send back. - * @param l2TokenAddress address of the l2Token to send back. - */ - function withdrawToken( - address recipient, - address l1TokenAddress, - address l2TokenAddress, - uint256 amountToReturn - ) public virtual; -} diff --git a/contracts/chain-adapters/l2/WithdrawalAdapterBase.sol b/contracts/chain-adapters/l2/WithdrawalAdapterBase.sol new file mode 100644 index 000000000..59518eb00 --- /dev/null +++ b/contracts/chain-adapters/l2/WithdrawalAdapterBase.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.19; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCaller.sol"; +import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libraries/CircleCCTPAdapter.sol"; + +/** + * @title WithdrawalAdapterBase + * @notice This contract contains general configurations for bridging tokens from an L2 to a single recipient on L1. + * @dev This contract should be deployed on L2. It provides an interface to withdraw tokens to some address on L1. The only + * function which must be implemented in contracts which inherit this contract is `withdrawToken`. It is up to that function + * to determine which bridges to use for an input L2 token. Importantly, that function must also verify that the l2 to l1 + * token mapping is correct so that the bridge call itself can succeed. + */ +abstract contract WithdrawalAdapterBase is CircleCCTPAdapter, MultiCaller { + using SafeERC20 for IERC20; + + // The L1 address which will unconditionally receive all withdrawals from this contract. + address public immutable TOKEN_RECIPIENT; + // The address of the primary or default token gateway/canonical bridge contract on L2. + address public immutable L2_TOKEN_GATEWAY; + + /* + * @notice Constructs a new withdrawal adapter. + * @param _l2Usdc Address of native USDC on the L2. + * @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2. + * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. + * @param _l2TokenGateway Address of the network's l2 token gateway/bridge contract. + * @param _tokenRecipient L1 address which will unconditionally receive all withdrawals originating from this contract. + */ + constructor( + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger, + uint32 _destinationCircleDomainId, + address _l2TokenGateway, + address _tokenRecipient + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId) { + L2_TOKEN_GATEWAY = _l2TokenGateway; + TOKEN_RECIPIENT = _tokenRecipient; + } + + /* + * @notice Withdraws a specified token to L1. This may be implemented uniquely for each L2, since each L2 has various + * dependencies to withdraw a token, such as the token bridge to use, mappings for L1 and L2 tokens, and gas configurations. + * Notably, withdrawals should always send token back to `TOKEN_RECIPIENT`. + * @param l1Token Address of the l1Token to receive. + * @param l2Token Address of the l2Token to send back. + * @param amountToReturn Amount of l2Token to send back. + * @dev Some networks do not require the L1/L2 token argument to withdraw tokens, while others enable contracts to derive the + * L1/L2 given knowledge of only one of the addresses. Both arguments are provided to enable a flexible interface; however, due + * to this, `withdrawToken` MUST account for situations where the L1/L2 token mapping is incorrect. + */ + function withdrawToken( + address l1Token, + address l2Token, + uint256 amountToReturn + ) public virtual; +} diff --git a/contracts/interfaces/ArbitrumBridgeInterfaces.sol b/contracts/interfaces/ArbitrumBridgeInterfaces.sol deleted file mode 100644 index a2b317248..000000000 --- a/contracts/interfaces/ArbitrumBridgeInterfaces.sol +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -/** - * @title Staging ground for incoming and outgoing messages - * @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is - * used as native currency on upper layer. - * @dev Fees are paid in this token. There are certain restrictions on the native token: - * - The token can't be rebasing or have a transfer fee - * - The token must only be transferrable via a call to the token address itself - * - The token must only be able to set allowance via a call to the token address itself - * - The token must not have a callback on transfer, and more generally a user must not be able to make a transfer to themselves revert - * - The token must have a max of 2^256 - 1 wei total supply unscaled - * - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals - */ -interface ArbitrumERC20Bridge { - /** - * @notice Returns token that is escrowed in bridge on the lower layer and minted on the upper layer as native currency. - * @dev This function doesn't exist on the generic Bridge interface. - * @return address of the native token. - */ - function nativeToken() external view returns (address); - - /** - * @dev number of decimals used by the native token - * This is set on bridge initialization using nativeToken.decimals() - * If the token does not have decimals() method, we assume it have 0 decimals - */ - function nativeTokenDecimals() external view returns (uint8); -} - -/** - * @title Inbox for user and contract originated messages - * @notice Messages created via this inbox are enqueued in the delayed accumulator - * to await inclusion in the SequencerInbox - */ -interface ArbitrumInboxLike { - /** - * @dev we only use this function to check the native token used by the bridge, so we hardcode the interface - * to return an ArbitrumERC20Bridge instead of a more generic Bridge interface. - * @return address of the bridge. - */ - function bridge() external view returns (ArbitrumERC20Bridge); - - /** - * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev Caller must set msg.value equal to at least `maxSubmissionCost + maxGas * gasPriceBid`. - * all msg.value will deposited to callValueRefundAddress on the upper layer - * @dev More details can be found here: https://developer.arbitrum.io/arbos/l1-to-l2-messaging - * @param to destination contract address - * @param callValue call value for retryable message - * @param maxSubmissionCost Max gas deducted from user's (upper layer) balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on (upper layer) balance - * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled - * @param gasLimit Max gas deducted from user's upper layer balance to cover upper layer execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for upper layer execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 callValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - bytes calldata data - ) external payable returns (uint256); - - /** - * @notice Put a message in the source chain inbox that can be reexecuted for some fixed amount of time if it reverts - * @dev Same as createRetryableTicket, but does not guarantee that submission will succeed by requiring the needed - * funds come from the deposit alone, rather than falling back on the user's balance - * @dev Advanced usage only (does not rewrite aliases for excessFeeRefundAddress and callValueRefundAddress). - * createRetryableTicket method is the recommended standard. - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @param to destination contract address - * @param callValue call value for retryable message - * @param maxSubmissionCost Max gas deducted from user's source chain balance to cover base submission fee - * @param excessFeeRefundAddress gasLimit x maxFeePerGas - execution cost gets credited here on source chain balance - * @param callValueRefundAddress callvalue gets credited here on source chain if retryable txn times out or gets cancelled - * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param data ABI encoded data of the message - * @return unique message number of the retryable transaction - */ - function unsafeCreateRetryableTicket( - address to, - uint256 callValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - bytes calldata data - ) external payable returns (uint256); -} - -/** - * @notice Interface which extends ArbitrumInboxLike with functions used to interact with bridges that use a custom gas token. - */ -interface ArbitrumCustomGasTokenInbox is ArbitrumInboxLike { - /** - * @notice Put a message in the inbox that can be reexecuted for some fixed amount of time if it reverts - * @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying - * for message using a custom gas token. - * @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on upper layer - * @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error - * @dev In case of native token having non-18 decimals: tokenTotalFeeAmount is denominated in native token's decimals. All other value params - callValue, maxSubmissionCost and maxFeePerGas are denominated in child chain's native 18 decimals. - * @param to destination contract address - * @param callValue call value for retryable message - * @param maxSubmissionCost Max gas deducted from user's upper layer balance to cover base submission fee - * @param excessFeeRefundAddress the address which receives the difference between execution fee paid and the actual execution cost. In case this address is a contract, funds will be received in its alias on upper layer. - * @param callValueRefundAddress callvalue gets credited here on upper layer if retryable txn times out or gets cancelled. In case this address is a contract, funds will be received in its alias on upper layer. - * @param gasLimit Max gas deducted from user's balance to cover execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param maxFeePerGas price bid for execution. Should not be set to 1 (magic value used to trigger the RetryableData error) - * @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost - * @param data ABI encoded data of message - * @return unique message number of the retryable transaction - */ - function createRetryableTicket( - address to, - uint256 callValue, - uint256 maxSubmissionCost, - address excessFeeRefundAddress, - address callValueRefundAddress, - uint256 gasLimit, - uint256 maxFeePerGas, - uint256 tokenTotalFeeAmount, - bytes calldata data - ) external returns (uint256); -} - -/** - * @notice Generic gateway contract for bridging standard ERC20s to/from Arbitrum-like networks. - * @notice These function signatures are shared between the L1 and L2 gateway router contracts. - */ -interface ArbitrumL1ERC20GatewayLike { - /** - * @notice Deprecated in favor of outboundTransferCustomRefund but still used in custom bridges - * like the DAI bridge. - * @dev Refunded to aliased address of sender if sender has code on source chain, otherwise to to sender's EOA on destination chain. - * @param _sourceToken address of ERC20 - * @param _to Account to be credited with the tokens at the destination (can be the user's account or a contract), - * not subject to aliasing. This account, or its alias if it has code in the source chain, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's balance to cover execution - * @param _gasPriceBid Gas price for execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransfer( - address _sourceToken, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); - - /** - * @notice get ERC20 gateway for token. - * @param _token ERC20 address. - * @return address of ERC20 gateway. - */ - function getGateway(address _token) external view returns (address); - - /** - * @notice Deposit ERC20 token from Ethereum into Arbitrum-like networks. - * @dev Upper layer address alias will not be applied to the following types of addresses on lower layer: - * - an externally-owned account - * - a contract in construction - * - an address where a contract will be created - * - an address where a contract lived, but was destroyed - * @param _sourceToken address of ERC20 on source chain. - * @param _refundTo Account, or its alias if it has code on the source chain, to be credited with excess gas refund at destination - * @param _to Account to be credited with the tokens in the L3 (can be the user's L3 account or a contract), - * not subject to aliasing. This account, or its alias if it has code on the source chain, will also be able to - * cancel the retryable ticket and receive callvalue refund - * @param _amount Token Amount - * @param _maxGas Max gas deducted from user's balance to cover execution - * @param _gasPriceBid Gas price for execution - * @param _data encoded data from router and user - * @return res abi encoded inbox sequence number - */ - function outboundTransferCustomRefund( - address _sourceToken, - address _refundTo, - address _to, - uint256 _amount, - uint256 _maxGas, - uint256 _gasPriceBid, - bytes calldata _data - ) external payable returns (bytes memory); -} - -interface ArbitrumL2ERC20GatewayLike { - /** - * @notice Fetches the l2 token address from the gateway router for the input l1 token address - * @param _l1Erc20 address of the l1 token. - */ - function calculateL2TokenAddress(address _l1Erc20) external view returns (address); - - /** - * @notice Withdraws a specified amount of an l2 token to an l1 token. - * @param _l1Token address of the token to withdraw on L1. - * @param _to address on L1 which will receive the tokens upon withdrawal. - * @param _amount amount of the token to withdraw. - * @param _data encoded data to send to the gateway router. - */ - function outboundTransfer( - address _l1Token, - address _to, - uint256 _amount, - bytes calldata _data - ) external payable returns (bytes memory); -} diff --git a/contracts/test/ArbitrumMocks.sol b/contracts/test/ArbitrumMocks.sol index da46dd9be..9971358b5 100644 --- a/contracts/test/ArbitrumMocks.sol +++ b/contracts/test/ArbitrumMocks.sol @@ -39,17 +39,32 @@ contract Inbox { address, uint256, uint256, - bytes calldata _data - ) external returns (uint256) { + bytes memory + ) external pure returns (uint256) { return 0; } } contract L2GatewayRouter { + mapping(address => address) l2Tokens; + + event OutboundTransfer(address indexed l1Token, address indexed to, uint256 amount); + function outboundTransfer( - address, - address, - uint256, - bytes calldata _data - ) public payable returns (bytes memory) {} + address l1Token, + address to, + uint256 amount, + bytes memory + ) public payable returns (bytes memory) { + emit OutboundTransfer(l1Token, to, amount); + return ""; + } + + function calculateL2TokenAddress(address l1Token) external view returns (address) { + return l2Tokens[l1Token]; + } + + function setL2TokenAddress(address l1Token, address l2Token) external { + l2Tokens[l1Token] = l2Token; + } } diff --git a/contracts/test/MockBedrockStandardBridge.sol b/contracts/test/MockBedrockStandardBridge.sol index 61e9576ee..c470ef893 100644 --- a/contracts/test/MockBedrockStandardBridge.sol +++ b/contracts/test/MockBedrockStandardBridge.sol @@ -5,14 +5,16 @@ import "../Ovm_SpokePool.sol"; // Provides payable withdrawTo interface introduced on Bedrock contract MockBedrockL2StandardBridge is IL2ERC20Bridge { + event ERC20WithdrawalInitiated(address indexed l2Token, address indexed to, uint256 amount); + function withdrawTo( address _l2Token, address _to, uint256 _amount, - uint32 _minGasLimit, - bytes calldata _extraData + uint32, + bytes calldata ) external payable { - // do nothing + emit ERC20WithdrawalInitiated(_l2Token, _to, _amount); } function bridgeERC20To( @@ -20,12 +22,12 @@ contract MockBedrockL2StandardBridge is IL2ERC20Bridge { address _remoteToken, address _to, uint256 _amount, - uint256 _minGasLimit, - bytes calldata _extraData + uint256, + bytes calldata ) external { // Check that caller has approved this contract to pull funds, mirroring mainnet's behavior IERC20(_localToken).transferFrom(msg.sender, address(this), _amount); - // do nothing + IERC20(_remoteToken).transfer(_to, _amount); } } @@ -64,4 +66,8 @@ contract MockBedrockCrossDomainMessenger { ) external { emit MessageSent(target); } + + function xDomainMessageSender() external view returns (address) { + return address(this); + } } diff --git a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol b/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol deleted file mode 100644 index d40b0728b..000000000 --- a/test/evm/foundry/local/Arbitrum_WithdrawalAdapter.t.sol +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -import { Test } from "forge-std/Test.sol"; -import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; -import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; -import { L2_TokenRetriever } from "../../../../contracts/L2_TokenRetriever.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; -import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; - -contract Token_ERC20 is ERC20 { - constructor(string memory name, string memory symbol) ERC20(name, symbol) {} - - function mint(address to, uint256 value) public virtual { - _mint(to, value); - } - - function burn(address from, uint256 value) public virtual { - _burn(from, value); - } -} - -contract ArbitrumGatewayRouter { - mapping(address => address) l2TokenAddress; - - function outboundTransfer( - address tokenToBridge, - address recipient, - uint256 amountToReturn, - bytes memory - ) external returns (bytes memory) { - address l2Token = l2TokenAddress[tokenToBridge]; - Token_ERC20(l2Token).burn(msg.sender, amountToReturn); - Token_ERC20(tokenToBridge).mint(recipient, amountToReturn); - return ""; - } - - function calculateL2TokenAddress(address l1Token) external view returns (address) { - return l2TokenAddress[l1Token]; - } - - function setTokenPair(address l1Token, address l2Token) external { - l2TokenAddress[l1Token] = l2Token; - } -} - -contract Arbitrum_WithdrawalAdapterTest is Test { - Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; - L2_TokenRetriever tokenRetriever; - ArbitrumGatewayRouter gatewayRouter; - Token_ERC20 l1Token; - Token_ERC20 l2Token; - Token_ERC20 l2Usdc; - - // HubPool should receive funds. - address hubPool; - - // Token messenger is set so CCTP is activated. - ITokenMessenger tokenMessenger; - - error RetrieveFailed(); - - function setUp() public { - l1Token = new Token_ERC20("TOKEN", "TOKEN"); - l2Token = new Token_ERC20("TOKEN", "TOKEN"); - l2Usdc = new Token_ERC20("USDC", "USDC"); - gatewayRouter = new ArbitrumGatewayRouter(); - - // Instantiate all other addresses used in the system. - tokenMessenger = ITokenMessenger(vm.addr(1)); - hubPool = vm.addr(2); - - gatewayRouter.setTokenPair(address(l1Token), address(l2Token)); - arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter(l2Usdc, tokenMessenger, address(gatewayRouter)); - tokenRetriever = new L2_TokenRetriever(address(arbitrumWithdrawalAdapter), hubPool); - } - - function testWithdrawToken(uint256 amountToReturn) public { - l2Token.mint(address(tokenRetriever), amountToReturn); - assertEq(amountToReturn, l2Token.totalSupply()); - L2_TokenRetriever.TokenPair[] memory tokenPairs = new L2_TokenRetriever.TokenPair[](1); - tokenPairs[0] = L2_TokenRetriever.TokenPair({ l1Token: address(l1Token), l2Token: address(l2Token) }); - tokenRetriever.retrieve(tokenPairs); - assertEq(0, l2Token.totalSupply()); - assertEq(amountToReturn, l1Token.totalSupply()); - assertEq(l1Token.balanceOf(hubPool), amountToReturn); - } - - function testWithdrawTokenFailure(uint256 amountToReturn, address invalidToken) public { - l2Token.mint(address(tokenRetriever), amountToReturn); - assertEq(amountToReturn, l2Token.totalSupply()); - L2_TokenRetriever.TokenPair[] memory tokenPairs = new L2_TokenRetriever.TokenPair[](1); - tokenPairs[0] = L2_TokenRetriever.TokenPair({ l1Token: invalidToken, l2Token: address(l2Token) }); - vm.expectRevert(L2_TokenRetriever.RetrieveFailed.selector); - tokenRetriever.retrieve(tokenPairs); - } -} diff --git a/test/evm/foundry/local/WithdrawalAdapter.t.sol b/test/evm/foundry/local/WithdrawalAdapter.t.sol new file mode 100644 index 000000000..918114844 --- /dev/null +++ b/test/evm/foundry/local/WithdrawalAdapter.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; +import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; +import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; +import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; +import { Ovm_WithdrawalAdapter, IOvm_SpokePool } from "../../../../contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol"; +import { CircleDomainIds } from "../../../../contracts/libraries/CircleCCTPAdapter.sol"; +import { L2GatewayRouter } from "../../../../contracts/test/ArbitrumMocks.sol"; +import { MockBedrockL2StandardBridge, MockBedrockCrossDomainMessenger } from "../../../../contracts/test/MockBedrockStandardBridge.sol"; +import { Base_SpokePool } from "../../../../contracts/Base_SpokePool.sol"; +import { WETH9 } from "../../../../contracts/external/WETH9.sol"; +import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9Interface.sol"; + +contract Mock_Ovm_WithdrawalAdapter is Ovm_WithdrawalAdapter { + constructor( + IERC20 _l2Usdc, + ITokenMessenger _cctpTokenMessenger, + uint32 _destinationCircleDomainId, + address _l2Gateway, + address _tokenRecipient, + IOvm_SpokePool _spokePool + ) + Ovm_WithdrawalAdapter( + _l2Usdc, + _cctpTokenMessenger, + _destinationCircleDomainId, + _l2Gateway, + _tokenRecipient, + _spokePool + ) + {} + + receive() external payable {} +} + +contract Token_ERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 value) public virtual { + _mint(to, value); + } + + function burn(address from, uint256 value) public virtual { + _burn(from, value); + } +} + +contract WithdrawalAdapterTest is Test { + uint32 constant fillDeadlineBuffer = type(uint32).max; + Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; + Mock_Ovm_WithdrawalAdapter ovmWithdrawalAdapter; + Base_SpokePool ovmSpokePool; + + L2GatewayRouter arbBridge; + MockBedrockL2StandardBridge ovmBridge; + MockBedrockL2StandardBridge customOvmBridge; + MockBedrockCrossDomainMessenger messenger; + + Token_ERC20 l1Token; + Token_ERC20 l2Token; + Token_ERC20 l1CustomToken; + Token_ERC20 l2CustomToken; + Token_ERC20 l2Usdc; + WETH9 l2Weth; + + // HubPool should receive funds. + address hubPool; + // Owner of the Ovm_SpokePool. + address owner; + + // Token messenger is set so CCTP is activated, but it will contain no contract code. + ITokenMessenger tokenMessenger; + + function setUp() public { + // Instantiate addresses. + l1Token = new Token_ERC20("TOKEN", "TOKEN"); + l2Token = new Token_ERC20("TOKEN", "TOKEN"); + l1CustomToken = new Token_ERC20("CTOKEN", "CTOKEN"); + l2CustomToken = new Token_ERC20("CTOKEN", "CTOKEN"); + l2Usdc = new Token_ERC20("USDC", "USDC"); + l2Weth = new WETH9(); + + arbBridge = new L2GatewayRouter(); + customOvmBridge = new MockBedrockL2StandardBridge(); + + // The Ovm spoke pools use predeploys in their code, so we must deploy mock code to these addresses. + deployCodeTo( + "contracts/test/MockBedrockStandardBridge.sol:MockBedrockL2StandardBridge", + Lib_PredeployAddresses.L2_STANDARD_BRIDGE + ); + deployCodeTo( + "contracts/test/MockBedrockStandardBridge.sol:MockBedrockCrossDomainMessenger", + Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER + ); + messenger = MockBedrockCrossDomainMessenger(payable(Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER)); + ovmBridge = MockBedrockL2StandardBridge(payable(Lib_PredeployAddresses.L2_STANDARD_BRIDGE)); + + tokenMessenger = ITokenMessenger(makeAddr("tokenMessenger")); + hubPool = makeAddr("hubPool"); + owner = makeAddr("owner"); + + // Construct the Ovm_SpokePool + vm.startPrank(owner); + arbBridge.setL2TokenAddress(address(l1Token), address(l2Token)); + Base_SpokePool implementation = new Base_SpokePool( + address(l2Weth), + fillDeadlineBuffer, + fillDeadlineBuffer, + l2Usdc, + tokenMessenger + ); + address proxy = address( + // The cross domain admin is set as the messenger so that we may set remote token mappings. + new ERC1967Proxy( + address(implementation), + abi.encodeCall(Base_SpokePool.initialize, (0, address(messenger), owner)) + ) + ); + ovmSpokePool = Base_SpokePool(payable(proxy)); + vm.stopPrank(); + + // Set a custom token and bridge mapping in the spoke pool. + vm.startPrank(address(messenger)); + ovmSpokePool.setRemoteL1Token(address(l2CustomToken), address(l1CustomToken)); + ovmSpokePool.setTokenBridge(address(l2CustomToken), address(customOvmBridge)); + vm.stopPrank(); + + arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter( + l2Usdc, + tokenMessenger, + CircleDomainIds.Ethereum, + address(arbBridge), + hubPool + ); + ovmWithdrawalAdapter = new Mock_Ovm_WithdrawalAdapter( + l2Usdc, + tokenMessenger, + CircleDomainIds.Ethereum, + address(ovmBridge), + hubPool, + IOvm_SpokePool(address(ovmSpokePool)) + ); + } + + // This test should call the gateway router contract. + function testWithdrawTokenArbitrum(uint256 amountToReturn) public { + l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + + vm.expectEmit(address(arbBridge)); + emit L2GatewayRouter.OutboundTransfer(address(l1Token), hubPool, amountToReturn); + arbitrumWithdrawalAdapter.withdrawToken(address(l1Token), address(l2Token), amountToReturn); + } + + // This test should error since the token mappings are incorrect. + function testWithdrawInvalidTokenArbitrum(uint256 amountToReturn, address invalidToken) public { + l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + + vm.expectRevert(Arbitrum_WithdrawalAdapter.InvalidTokenMapping.selector); + arbitrumWithdrawalAdapter.withdrawToken(invalidToken, address(l2Token), amountToReturn); + } + + // This test should call the OpStack standard bridge with l2Eth as the input token. + function testWithdrawEthOvm(uint256 amountToReturn, address random) public { + // Give the withdrawal adapter some WETH. + vm.startPrank(address(ovmWithdrawalAdapter)); + vm.deal(address(ovmWithdrawalAdapter), amountToReturn); + l2Weth.deposit{ value: amountToReturn }(); + vm.stopPrank(); + + vm.expectEmit(address(ovmBridge)); + emit MockBedrockL2StandardBridge.ERC20WithdrawalInitiated( + Lib_PredeployAddresses.OVM_ETH, + hubPool, + amountToReturn + ); + ovmWithdrawalAdapter.withdrawToken(random, address(l2Weth), amountToReturn); + } + + // This test should call the OpStack standard bridge with l2Token as the input token. `withdrawTo` should be called. + function testWithdrawTokenOvm(uint256 amountToReturn) public { + l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + + vm.expectEmit(address(ovmBridge)); + emit MockBedrockL2StandardBridge.ERC20WithdrawalInitiated(address(l2Token), hubPool, amountToReturn); + ovmWithdrawalAdapter.withdrawToken(address(l1Token), address(l2Token), amountToReturn); + } + + // This test should use a custom token bridge with a custom l1/l2 token mapping. `bridgeERC20To` should be called. + function testWithdrawCustomMappingsOvm(uint256 amountToReturn) public { + l2CustomToken.mint(address(ovmWithdrawalAdapter), amountToReturn); + l1CustomToken.mint(address(customOvmBridge), amountToReturn); + assertEq(0, l1CustomToken.balanceOf(hubPool)); + assertEq(0, l2CustomToken.balanceOf(hubPool)); + assertEq(amountToReturn, l2CustomToken.balanceOf(address(ovmWithdrawalAdapter))); + + ovmWithdrawalAdapter.withdrawToken(address(l1CustomToken), address(l2CustomToken), amountToReturn); + + assertEq(amountToReturn, l1CustomToken.balanceOf(hubPool)); + assertEq(0, l2CustomToken.balanceOf(hubPool)); + assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalAdapter))); + } +} From db64f5f01057865b63d517c6e723cbf2eb32374c Mon Sep 17 00:00:00 2001 From: bennett Date: Wed, 2 Oct 2024 13:32:24 -0500 Subject: [PATCH 19/28] remove unused test imports Signed-off-by: bennett --- test/evm/foundry/local/WithdrawalAdapter.t.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/evm/foundry/local/WithdrawalAdapter.t.sol b/test/evm/foundry/local/WithdrawalAdapter.t.sol index 918114844..92e580fe0 100644 --- a/test/evm/foundry/local/WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/WithdrawalAdapter.t.sol @@ -2,11 +2,8 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; -import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; From da1bc9abb9f9f75e83652b7e9b5be7ba41e1dca3 Mon Sep 17 00:00:00 2001 From: bennett Date: Thu, 3 Oct 2024 14:07:59 -0500 Subject: [PATCH 20/28] rename and add test Signed-off-by: bennett --- ...pter.sol => Arbitrum_WithdrawalHelper.sol} | 26 ++++-- ...alAdapter.sol => Ovm_WithdrawalHelper.sol} | 28 +++++- ...apterBase.sol => WithdrawalHelperBase.sol} | 41 ++++++++- contracts/test/MockBedrockStandardBridge.sol | 18 +++- ...alAdapter.t.sol => WithdrawalHelper.t.sol} | 92 +++++++++++++------ 5 files changed, 159 insertions(+), 46 deletions(-) rename contracts/chain-adapters/l2/{Arbitrum_WithdrawalAdapter.sol => Arbitrum_WithdrawalHelper.sol} (82%) rename contracts/chain-adapters/l2/{Ovm_WithdrawalAdapter.sol => Ovm_WithdrawalHelper.sol} (89%) rename contracts/chain-adapters/l2/{WithdrawalAdapterBase.sol => WithdrawalHelperBase.sol} (65%) rename test/evm/foundry/local/{WithdrawalAdapter.t.sol => WithdrawalHelper.t.sol} (67%) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol similarity index 82% rename from contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol rename to contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index 020c1ca16..d4b88b1a7 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -7,23 +7,29 @@ pragma solidity ^0.8.19; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ArbitrumL2ERC20GatewayLike } from "../../interfaces/ArbitrumBridge.sol"; -import { WithdrawalAdapterBase } from "./WithdrawalAdapterBase.sol"; +import { WithdrawalHelperBase } from "./WithdrawalHelperBase.sol"; import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol"; +import { CrossDomainAddressUtils } from "../../libraries/CrossDomainAddressUtils.sol"; /** - * @title Arbitrum_WithdrawalAdapter + * @title Arbitrum_WithdrawalHelper * @notice This contract interfaces with L2-L1 token bridges and withdraws tokens to a single address on L1. * @dev This contract should be deployed on Arbitrum L2s which only use CCTP or the canonical Arbitrum gateway router to withdraw tokens. * @custom:security-contact bugs@across.to */ -contract Arbitrum_WithdrawalAdapter is WithdrawalAdapterBase { +contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { using SafeERC20 for IERC20; // Error which triggers when the supplied L1 token does not match the Arbitrum gateway router's expected L2 token. error InvalidTokenMapping(); + modifier onlyFromHubPool() { + require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(HUB_POOL), "ONLY_HUB_POOL"); + _; + } + /* - * @notice Constructs the Arbitrum_WithdrawalAdapter. + * @notice Constructs the Arbitrum_WithdrawalHelper. * @param _l2Usdc Address of native USDC on the L2. * @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2. * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this is 0. @@ -35,14 +41,16 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapterBase { ITokenMessenger _cctpTokenMessenger, uint32 _destinationCircleDomainId, address _l2GatewayRouter, - address _tokenRecipient + address _tokenRecipient, + address _hubPool ) - WithdrawalAdapterBase( + WithdrawalHelperBase( _l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2GatewayRouter, - _tokenRecipient + _tokenRecipient, + _hubPool ) {} @@ -58,7 +66,7 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapterBase { uint256 amountToReturn ) public override { // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. - if (_isCCTPEnabled() && l2Token == address(usdcToken)) { + if (l2Token == address(usdcToken) && _isCCTPEnabled()) { _transferUsdc(TOKEN_RECIPIENT, amountToReturn); } else { // Otherwise, we use the Arbitrum ERC20 Gateway router. @@ -76,4 +84,6 @@ contract Arbitrum_WithdrawalAdapter is WithdrawalAdapterBase { ); } } + + function _requireHubPoolSender() internal override onlyFromHubPool {} } diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol similarity index 89% rename from contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol rename to contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol index be0ae49e6..3cd221887 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol @@ -2,12 +2,13 @@ pragma solidity ^0.8.0; -import { WithdrawalAdapterBase } from "./WithdrawalAdapterBase.sol"; +import { WithdrawalHelperBase } from "./WithdrawalHelperBase.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol"; import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; +import { LibOptimismUpgradeable } from "@openzeppelin/contracts-upgradeable/crosschain/optimism/LibOptimismUpgradeable.sol"; import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol"; /** @@ -39,7 +40,7 @@ interface IOvm_SpokePool { * which has a special SNX bridge (and thus this adapter will NOT work for Optimism). * @custom:security-contact bugs@across.to */ -contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { +contract Ovm_WithdrawalHelper is WithdrawalHelperBase { using SafeERC20 for IERC20; // Address for the wrapped native token on this chain. For Ovm standard bridges, we need to unwrap @@ -52,6 +53,11 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { // Address of native ETH on the l2. For OpStack chains, this address is used to indicate a native ETH withdrawal. // In general, this address is 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. address public immutable l2Eth; + // Address of the messenger contract on L2. This is by default defined in Lib_PredeployAddresses. + address public constant MESSENGER = Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER; + + // Error which triggers when the L1 msg.sender is not the hub pool. + error NotHubPool(); /* * @notice Constructs the Ovm_WithdrawalAdapter. @@ -69,8 +75,18 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { uint32 _destinationCircleDomainId, address _l2Gateway, address _tokenRecipient, + address _hubPool, IOvm_SpokePool _spokePool - ) WithdrawalAdapterBase(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2Gateway, _tokenRecipient) { + ) + WithdrawalHelperBase( + _l2Usdc, + _cctpTokenMessenger, + _destinationCircleDomainId, + _l2Gateway, + _tokenRecipient, + _hubPool + ) + { spokePool = _spokePool; // These addresses should only change network-by-network, or after a bridge upgrade, so we define them once in the constructor. @@ -110,7 +126,7 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { ); } // If the token is USDC && CCTP bridge is enabled, then bridge USDC via CCTP. - else if (_isCCTPEnabled() && l2Token == address(usdcToken)) { + else if (l2Token == address(usdcToken) && _isCCTPEnabled()) { _transferUsdc(TOKEN_RECIPIENT, amountToReturn); } // Note we'll default to withdrawTo instead of bridgeERC20To unless the remoteL1Tokens mapping is set for @@ -151,4 +167,8 @@ contract Ovm_WithdrawalAdapter is WithdrawalAdapterBase { } } } + + function _requireHubPoolSender() internal view override { + if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != HUB_POOL) revert NotHubPool(); + } } diff --git a/contracts/chain-adapters/l2/WithdrawalAdapterBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol similarity index 65% rename from contracts/chain-adapters/l2/WithdrawalAdapterBase.sol rename to contracts/chain-adapters/l2/WithdrawalHelperBase.sol index 59518eb00..49af84780 100644 --- a/contracts/chain-adapters/l2/WithdrawalAdapterBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -7,23 +7,32 @@ import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCall import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libraries/CircleCCTPAdapter.sol"; /** - * @title WithdrawalAdapterBase + * @title WithdrawalHelperBase * @notice This contract contains general configurations for bridging tokens from an L2 to a single recipient on L1. * @dev This contract should be deployed on L2. It provides an interface to withdraw tokens to some address on L1. The only * function which must be implemented in contracts which inherit this contract is `withdrawToken`. It is up to that function * to determine which bridges to use for an input L2 token. Importantly, that function must also verify that the l2 to l1 * token mapping is correct so that the bridge call itself can succeed. */ -abstract contract WithdrawalAdapterBase is CircleCCTPAdapter, MultiCaller { +abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { using SafeERC20 for IERC20; // The L1 address which will unconditionally receive all withdrawals from this contract. address public immutable TOKEN_RECIPIENT; // The address of the primary or default token gateway/canonical bridge contract on L2. address public immutable L2_TOKEN_GATEWAY; + // The address of the hub pool contract on L1. As a last resort, the hub pool can rescue stuck tokens on this withdrawal helper contract, + // similar to how it may send admin functions to spoke pools. + address public immutable HUB_POOL; + + // Functions which contain this modifier should only be callable via a cross-chain call where the L1 msg.sender is the hub pool. + modifier onlyHubPool() { + _requireHubPoolSender(); + _; + } /* - * @notice Constructs a new withdrawal adapter. + * @notice Constructs a new withdrawal helper. * @param _l2Usdc Address of native USDC on the L2. * @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2. * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. @@ -35,10 +44,28 @@ abstract contract WithdrawalAdapterBase is CircleCCTPAdapter, MultiCaller { ITokenMessenger _cctpTokenMessenger, uint32 _destinationCircleDomainId, address _l2TokenGateway, - address _tokenRecipient + address _tokenRecipient, + address _hubPool ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId) { L2_TOKEN_GATEWAY = _l2TokenGateway; TOKEN_RECIPIENT = _tokenRecipient; + HUB_POOL = _hubPool; + } + + /* + * @notice Transfers a specified amount of an L2 token to an address on L2. + * @param l2Token Address of the L2 token to send. + * @param to Address which should receive the L2 token. + * @param amount Amount of the L2 token to send to `to`. + * @dev This function should only be callable via a rescue adapter. This function is solely for times when token withdrawals from L2-L1 + * cannot succeed for whatever reason, so the tokens must be sent to an alternate address. + */ + function sendTokenTo( + address l2Token, + address to, + uint256 amount + ) external onlyHubPool { + IERC20(l2Token).safeTransfer(to, amount); } /* @@ -57,4 +84,10 @@ abstract contract WithdrawalAdapterBase is CircleCCTPAdapter, MultiCaller { address l2Token, uint256 amountToReturn ) public virtual; + + /* + * @notice Checks that the L1 msg.sender is the `HUB_POOL` address. + * @dev This implementation must change on a per-chain basis, since each L2 network has their own method of deriving the L1 msg.sender. + */ + function _requireHubPoolSender() internal virtual; } diff --git a/contracts/test/MockBedrockStandardBridge.sol b/contracts/test/MockBedrockStandardBridge.sol index c470ef893..b55cd1a12 100644 --- a/contracts/test/MockBedrockStandardBridge.sol +++ b/contracts/test/MockBedrockStandardBridge.sol @@ -59,6 +59,8 @@ contract MockBedrockL1StandardBridge { contract MockBedrockCrossDomainMessenger { event MessageSent(address indexed target); + address private msgSender; + function sendMessage( address target, bytes calldata, @@ -67,7 +69,21 @@ contract MockBedrockCrossDomainMessenger { emit MessageSent(target); } + // Impersonates making a call on L2 from L1. + function impersonateCall(address target, bytes memory data) external payable returns (bytes memory) { + msgSender = msg.sender; + (bool success, bytes memory returnData) = target.call{ value: msg.value }(data); + + // Revert if call reverted. + if (!success) { + assembly { + revert(add(32, returnData), mload(returnData)) + } + } + return returnData; + } + function xDomainMessageSender() external view returns (address) { - return address(this); + return msgSender; } } diff --git a/test/evm/foundry/local/WithdrawalAdapter.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol similarity index 67% rename from test/evm/foundry/local/WithdrawalAdapter.t.sol rename to test/evm/foundry/local/WithdrawalHelper.t.sol index 92e580fe0..bf3d9eca8 100644 --- a/test/evm/foundry/local/WithdrawalAdapter.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -6,30 +6,34 @@ import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; -import { Arbitrum_WithdrawalAdapter } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalAdapter.sol"; -import { Ovm_WithdrawalAdapter, IOvm_SpokePool } from "../../../../contracts/chain-adapters/l2/Ovm_WithdrawalAdapter.sol"; +import { Arbitrum_WithdrawalHelper } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol"; +import { Ovm_WithdrawalHelper, IOvm_SpokePool } from "../../../../contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol"; import { CircleDomainIds } from "../../../../contracts/libraries/CircleCCTPAdapter.sol"; import { L2GatewayRouter } from "../../../../contracts/test/ArbitrumMocks.sol"; import { MockBedrockL2StandardBridge, MockBedrockCrossDomainMessenger } from "../../../../contracts/test/MockBedrockStandardBridge.sol"; import { Base_SpokePool } from "../../../../contracts/Base_SpokePool.sol"; +import { Ovm_SpokePool } from "../../../../contracts/Ovm_SpokePool.sol"; +import { WithdrawalHelperBase } from "../../../../contracts/chain-adapters/l2/WithdrawalHelperBase.sol"; import { WETH9 } from "../../../../contracts/external/WETH9.sol"; import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9Interface.sol"; -contract Mock_Ovm_WithdrawalAdapter is Ovm_WithdrawalAdapter { +contract Mock_Ovm_WithdrawalHelper is Ovm_WithdrawalHelper { constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, uint32 _destinationCircleDomainId, address _l2Gateway, address _tokenRecipient, + address _hubPool, IOvm_SpokePool _spokePool ) - Ovm_WithdrawalAdapter( + Ovm_WithdrawalHelper( _l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2Gateway, _tokenRecipient, + _hubPool, _spokePool ) {} @@ -51,8 +55,8 @@ contract Token_ERC20 is ERC20 { contract WithdrawalAdapterTest is Test { uint32 constant fillDeadlineBuffer = type(uint32).max; - Arbitrum_WithdrawalAdapter arbitrumWithdrawalAdapter; - Mock_Ovm_WithdrawalAdapter ovmWithdrawalAdapter; + Arbitrum_WithdrawalHelper arbitrumWithdrawalHelper; + Mock_Ovm_WithdrawalHelper ovmWithdrawalHelper; Base_SpokePool ovmSpokePool; L2GatewayRouter arbBridge; @@ -115,59 +119,64 @@ contract WithdrawalAdapterTest is Test { ); address proxy = address( // The cross domain admin is set as the messenger so that we may set remote token mappings. - new ERC1967Proxy( - address(implementation), - abi.encodeCall(Base_SpokePool.initialize, (0, address(messenger), owner)) - ) + new ERC1967Proxy(address(implementation), abi.encodeCall(Base_SpokePool.initialize, (0, hubPool, owner))) ); ovmSpokePool = Base_SpokePool(payable(proxy)); vm.stopPrank(); // Set a custom token and bridge mapping in the spoke pool. - vm.startPrank(address(messenger)); - ovmSpokePool.setRemoteL1Token(address(l2CustomToken), address(l1CustomToken)); - ovmSpokePool.setTokenBridge(address(l2CustomToken), address(customOvmBridge)); + vm.startPrank(hubPool); + messenger.impersonateCall( + address(ovmSpokePool), + abi.encodeCall(Ovm_SpokePool.setRemoteL1Token, (address(l2CustomToken), address(l1CustomToken))) + ); + messenger.impersonateCall( + address(ovmSpokePool), + abi.encodeCall(Ovm_SpokePool.setTokenBridge, (address(l2CustomToken), address(customOvmBridge))) + ); vm.stopPrank(); - arbitrumWithdrawalAdapter = new Arbitrum_WithdrawalAdapter( + arbitrumWithdrawalHelper = new Arbitrum_WithdrawalHelper( l2Usdc, tokenMessenger, CircleDomainIds.Ethereum, address(arbBridge), + hubPool, hubPool ); - ovmWithdrawalAdapter = new Mock_Ovm_WithdrawalAdapter( + ovmWithdrawalHelper = new Mock_Ovm_WithdrawalHelper( l2Usdc, tokenMessenger, CircleDomainIds.Ethereum, address(ovmBridge), hubPool, + hubPool, IOvm_SpokePool(address(ovmSpokePool)) ); } // This test should call the gateway router contract. function testWithdrawTokenArbitrum(uint256 amountToReturn) public { - l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + l2Token.mint(address(arbitrumWithdrawalHelper), amountToReturn); vm.expectEmit(address(arbBridge)); emit L2GatewayRouter.OutboundTransfer(address(l1Token), hubPool, amountToReturn); - arbitrumWithdrawalAdapter.withdrawToken(address(l1Token), address(l2Token), amountToReturn); + arbitrumWithdrawalHelper.withdrawToken(address(l1Token), address(l2Token), amountToReturn); } // This test should error since the token mappings are incorrect. function testWithdrawInvalidTokenArbitrum(uint256 amountToReturn, address invalidToken) public { - l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + l2Token.mint(address(arbitrumWithdrawalHelper), amountToReturn); - vm.expectRevert(Arbitrum_WithdrawalAdapter.InvalidTokenMapping.selector); - arbitrumWithdrawalAdapter.withdrawToken(invalidToken, address(l2Token), amountToReturn); + vm.expectRevert(Arbitrum_WithdrawalHelper.InvalidTokenMapping.selector); + arbitrumWithdrawalHelper.withdrawToken(invalidToken, address(l2Token), amountToReturn); } // This test should call the OpStack standard bridge with l2Eth as the input token. function testWithdrawEthOvm(uint256 amountToReturn, address random) public { // Give the withdrawal adapter some WETH. - vm.startPrank(address(ovmWithdrawalAdapter)); - vm.deal(address(ovmWithdrawalAdapter), amountToReturn); + vm.startPrank(address(ovmWithdrawalHelper)); + vm.deal(address(ovmWithdrawalHelper), amountToReturn); l2Weth.deposit{ value: amountToReturn }(); vm.stopPrank(); @@ -177,30 +186,55 @@ contract WithdrawalAdapterTest is Test { hubPool, amountToReturn ); - ovmWithdrawalAdapter.withdrawToken(random, address(l2Weth), amountToReturn); + ovmWithdrawalHelper.withdrawToken(random, address(l2Weth), amountToReturn); } // This test should call the OpStack standard bridge with l2Token as the input token. `withdrawTo` should be called. function testWithdrawTokenOvm(uint256 amountToReturn) public { - l2Token.mint(address(arbitrumWithdrawalAdapter), amountToReturn); + l2Token.mint(address(arbitrumWithdrawalHelper), amountToReturn); vm.expectEmit(address(ovmBridge)); emit MockBedrockL2StandardBridge.ERC20WithdrawalInitiated(address(l2Token), hubPool, amountToReturn); - ovmWithdrawalAdapter.withdrawToken(address(l1Token), address(l2Token), amountToReturn); + ovmWithdrawalHelper.withdrawToken(address(l1Token), address(l2Token), amountToReturn); } // This test should use a custom token bridge with a custom l1/l2 token mapping. `bridgeERC20To` should be called. function testWithdrawCustomMappingsOvm(uint256 amountToReturn) public { - l2CustomToken.mint(address(ovmWithdrawalAdapter), amountToReturn); + l2CustomToken.mint(address(ovmWithdrawalHelper), amountToReturn); l1CustomToken.mint(address(customOvmBridge), amountToReturn); assertEq(0, l1CustomToken.balanceOf(hubPool)); assertEq(0, l2CustomToken.balanceOf(hubPool)); - assertEq(amountToReturn, l2CustomToken.balanceOf(address(ovmWithdrawalAdapter))); + assertEq(amountToReturn, l2CustomToken.balanceOf(address(ovmWithdrawalHelper))); - ovmWithdrawalAdapter.withdrawToken(address(l1CustomToken), address(l2CustomToken), amountToReturn); + ovmWithdrawalHelper.withdrawToken(address(l1CustomToken), address(l2CustomToken), amountToReturn); assertEq(amountToReturn, l1CustomToken.balanceOf(hubPool)); assertEq(0, l2CustomToken.balanceOf(hubPool)); - assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalAdapter))); + assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalHelper))); + } + + function testRescueTokensWithAdminMessage(uint256 amountToReturn, address rando) public { + vm.assume(rando != hubPool); + l2CustomToken.mint(address(ovmWithdrawalHelper), amountToReturn); + + // Should revert if we are an unauthorized user. + vm.startPrank(rando); + vm.expectRevert(Ovm_WithdrawalHelper.NotHubPool.selector); + messenger.impersonateCall( + address(ovmWithdrawalHelper), + abi.encodeCall(WithdrawalHelperBase.sendTokenTo, (address(l2CustomToken), hubPool, amountToReturn)) + ); + vm.stopPrank(); + + // Should work if we are an authorized user. + vm.startPrank(hubPool); + messenger.impersonateCall( + address(ovmWithdrawalHelper), + abi.encodeCall(WithdrawalHelperBase.sendTokenTo, (address(l2CustomToken), hubPool, amountToReturn)) + ); + vm.stopPrank(); + + assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalHelper))); + assertEq(amountToReturn, l2CustomToken.balanceOf(hubPool)); } } From 2056784034360ccdda33d025d7d86b16be170728 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 08:14:22 -0500 Subject: [PATCH 21/28] add natspec comment and change name to crossDomainAdmin Signed-off-by: bennett --- .../l2/Arbitrum_WithdrawalHelper.sol | 12 +++++----- .../l2/Ovm_WithdrawalHelper.sol | 14 +++++++----- .../l2/WithdrawalHelperBase.sol | 22 ++++++++++--------- test/evm/foundry/local/WithdrawalHelper.t.sol | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index d4b88b1a7..4fb5c1d3d 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -23,8 +23,8 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { // Error which triggers when the supplied L1 token does not match the Arbitrum gateway router's expected L2 token. error InvalidTokenMapping(); - modifier onlyFromHubPool() { - require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(HUB_POOL), "ONLY_HUB_POOL"); + modifier onlyCrossDomainAdmin() { + require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(CROSS_DOMAIN_ADMIN), "ONLY_CROSS_DOMAIN_ADMIN"); _; } @@ -35,6 +35,8 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this is 0. * @param _l2GatewayRouter Address of the Arbitrum l2 gateway router contract. * @param _tokenRecipient L1 Address which will unconditionally receive tokens withdrawn from this contract. + * @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an + * L2 address. */ constructor( IERC20 _l2Usdc, @@ -42,7 +44,7 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { uint32 _destinationCircleDomainId, address _l2GatewayRouter, address _tokenRecipient, - address _hubPool + address _crossDomainAdmin ) WithdrawalHelperBase( _l2Usdc, @@ -50,7 +52,7 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { _destinationCircleDomainId, _l2GatewayRouter, _tokenRecipient, - _hubPool + _crossDomainAdmin ) {} @@ -85,5 +87,5 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { } } - function _requireHubPoolSender() internal override onlyFromHubPool {} + function _requireAdminSender() internal override onlyCrossDomainAdmin {} } diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol index 3cd221887..45679d183 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol @@ -56,8 +56,8 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { // Address of the messenger contract on L2. This is by default defined in Lib_PredeployAddresses. address public constant MESSENGER = Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER; - // Error which triggers when the L1 msg.sender is not the hub pool. - error NotHubPool(); + // Error which triggers when the L1 msg.sender is not the cross domain admin address. + error NotCrossDomainAdmin(); /* * @notice Constructs the Ovm_WithdrawalAdapter. @@ -67,6 +67,8 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { * is 0. * @param _l2Gateway Address of the Optimism ERC20 L2 standard bridge contract. * @param _tokenRecipient The L1 address which will unconditionally receive tokens from withdrawals by this contract. + * @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an + * L2 address. * @param _spokePool The contract address of the Ovm_SpokePool which is deployed on this L2 network. */ constructor( @@ -75,7 +77,7 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { uint32 _destinationCircleDomainId, address _l2Gateway, address _tokenRecipient, - address _hubPool, + address _crossDomainAdmin, IOvm_SpokePool _spokePool ) WithdrawalHelperBase( @@ -84,7 +86,7 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { _destinationCircleDomainId, _l2Gateway, _tokenRecipient, - _hubPool + _crossDomainAdmin ) { spokePool = _spokePool; @@ -168,7 +170,7 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { } } - function _requireHubPoolSender() internal view override { - if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != HUB_POOL) revert NotHubPool(); + function _requireAdminSender() internal view override { + if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != CROSS_DOMAIN_ADMIN) revert NotCrossDomainAdmin(); } } diff --git a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol index 49af84780..a00e9211b 100644 --- a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -21,13 +21,13 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { address public immutable TOKEN_RECIPIENT; // The address of the primary or default token gateway/canonical bridge contract on L2. address public immutable L2_TOKEN_GATEWAY; - // The address of the hub pool contract on L1. As a last resort, the hub pool can rescue stuck tokens on this withdrawal helper contract, - // similar to how it may send admin functions to spoke pools. - address public immutable HUB_POOL; + // The address of the admin contract on L1,which will likely be the hub pool. As a last resort, this admin can rescue stuck tokens + // on this withdrawal helper contract, similar to how it may send admin functions to spoke pools. + address public immutable CROSS_DOMAIN_ADMIN; // Functions which contain this modifier should only be callable via a cross-chain call where the L1 msg.sender is the hub pool. - modifier onlyHubPool() { - _requireHubPoolSender(); + modifier onlyAdmin() { + _requireAdminSender(); _; } @@ -38,6 +38,8 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. * @param _l2TokenGateway Address of the network's l2 token gateway/bridge contract. * @param _tokenRecipient L1 address which will unconditionally receive all withdrawals originating from this contract. + * @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an + * L2 address. */ constructor( IERC20 _l2Usdc, @@ -45,11 +47,11 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { uint32 _destinationCircleDomainId, address _l2TokenGateway, address _tokenRecipient, - address _hubPool + address _crossDomainAdmin ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId) { L2_TOKEN_GATEWAY = _l2TokenGateway; TOKEN_RECIPIENT = _tokenRecipient; - HUB_POOL = _hubPool; + CROSS_DOMAIN_ADMIN = _crossDomainAdmin; } /* @@ -64,7 +66,7 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { address l2Token, address to, uint256 amount - ) external onlyHubPool { + ) external onlyAdmin { IERC20(l2Token).safeTransfer(to, amount); } @@ -86,8 +88,8 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { ) public virtual; /* - * @notice Checks that the L1 msg.sender is the `HUB_POOL` address. + * @notice Checks that the L1 msg.sender is the `CROSS_DOMAIN_ADMIN` address. * @dev This implementation must change on a per-chain basis, since each L2 network has their own method of deriving the L1 msg.sender. */ - function _requireHubPoolSender() internal virtual; + function _requireAdminSender() internal virtual; } diff --git a/test/evm/foundry/local/WithdrawalHelper.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol index bf3d9eca8..d692f5b0e 100644 --- a/test/evm/foundry/local/WithdrawalHelper.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -219,7 +219,7 @@ contract WithdrawalAdapterTest is Test { // Should revert if we are an unauthorized user. vm.startPrank(rando); - vm.expectRevert(Ovm_WithdrawalHelper.NotHubPool.selector); + vm.expectRevert(Ovm_WithdrawalHelper.NotCrossDomainAdmin.selector); messenger.impersonateCall( address(ovmWithdrawalHelper), abi.encodeCall(WithdrawalHelperBase.sendTokenTo, (address(l2CustomToken), hubPool, amountToReturn)) From 450cfe5466872772d57df2d45425ad18d8bbf418 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 08:50:59 -0500 Subject: [PATCH 22/28] add vm.assume to test to address fuzz where l1Token = invalidToken Signed-off-by: bennett --- test/evm/foundry/local/WithdrawalHelper.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/evm/foundry/local/WithdrawalHelper.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol index d692f5b0e..40c8bbe67 100644 --- a/test/evm/foundry/local/WithdrawalHelper.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -166,6 +166,7 @@ contract WithdrawalAdapterTest is Test { // This test should error since the token mappings are incorrect. function testWithdrawInvalidTokenArbitrum(uint256 amountToReturn, address invalidToken) public { + vm.assume(invalidToken != address(l1Token)); l2Token.mint(address(arbitrumWithdrawalHelper), amountToReturn); vm.expectRevert(Arbitrum_WithdrawalHelper.InvalidTokenMapping.selector); From bcaa0f4f5b9577c055a823785da5bc82957a34d1 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 14:42:40 -0500 Subject: [PATCH 23/28] make the helper into a proxy Signed-off-by: bennett --- .../l2/Arbitrum_WithdrawalHelper.sol | 10 +--- .../l2/Ovm_WithdrawalHelper.sol | 14 +---- .../l2/WithdrawalHelperBase.sol | 56 +++++++++++++------ test/evm/foundry/local/WithdrawalHelper.t.sol | 30 ++++++---- 4 files changed, 64 insertions(+), 46 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index 4fb5c1d3d..486766619 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -24,7 +24,7 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { error InvalidTokenMapping(); modifier onlyCrossDomainAdmin() { - require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(CROSS_DOMAIN_ADMIN), "ONLY_CROSS_DOMAIN_ADMIN"); + require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin), "ONLY_CROSS_DOMAIN_ADMIN"); _; } @@ -35,24 +35,20 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { * @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this is 0. * @param _l2GatewayRouter Address of the Arbitrum l2 gateway router contract. * @param _tokenRecipient L1 Address which will unconditionally receive tokens withdrawn from this contract. - * @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an - * L2 address. */ constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, uint32 _destinationCircleDomainId, address _l2GatewayRouter, - address _tokenRecipient, - address _crossDomainAdmin + address _tokenRecipient ) WithdrawalHelperBase( _l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2GatewayRouter, - _tokenRecipient, - _crossDomainAdmin + _tokenRecipient ) {} diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol index 45679d183..64ff48187 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol @@ -77,18 +77,8 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { uint32 _destinationCircleDomainId, address _l2Gateway, address _tokenRecipient, - address _crossDomainAdmin, IOvm_SpokePool _spokePool - ) - WithdrawalHelperBase( - _l2Usdc, - _cctpTokenMessenger, - _destinationCircleDomainId, - _l2Gateway, - _tokenRecipient, - _crossDomainAdmin - ) - { + ) WithdrawalHelperBase(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2Gateway, _tokenRecipient) { spokePool = _spokePool; // These addresses should only change network-by-network, or after a bridge upgrade, so we define them once in the constructor. @@ -171,6 +161,6 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { } function _requireAdminSender() internal view override { - if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != CROSS_DOMAIN_ADMIN) revert NotCrossDomainAdmin(); + if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != crossDomainAdmin) revert NotCrossDomainAdmin(); } } diff --git a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol index a00e9211b..69faa46dd 100644 --- a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -5,6 +5,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCaller.sol"; import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libraries/CircleCCTPAdapter.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; /** * @title WithdrawalHelperBase @@ -14,7 +15,7 @@ import { CircleCCTPAdapter, ITokenMessenger, CircleDomainIds } from "../../libra * to determine which bridges to use for an input L2 token. Importantly, that function must also verify that the l2 to l1 * token mapping is correct so that the bridge call itself can succeed. */ -abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { +abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller, UUPSUpgradeable { using SafeERC20 for IERC20; // The L1 address which will unconditionally receive all withdrawals from this contract. @@ -23,7 +24,12 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { address public immutable L2_TOKEN_GATEWAY; // The address of the admin contract on L1,which will likely be the hub pool. As a last resort, this admin can rescue stuck tokens // on this withdrawal helper contract, similar to how it may send admin functions to spoke pools. - address public immutable CROSS_DOMAIN_ADMIN; + address public crossDomainAdmin; + + event SetXDomainAdmin(address _crossDomainAdmin); + + // Error which triggers when the cross domain admin was attempted to be set to the zero address. + error InvalidCrossDomainAdmin(); // Functions which contain this modifier should only be callable via a cross-chain call where the L1 msg.sender is the hub pool. modifier onlyAdmin() { @@ -40,34 +46,39 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { * @param _tokenRecipient L1 address which will unconditionally receive all withdrawals originating from this contract. * @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an * L2 address. + * @dev _disableInitializers() restricts anybody from initializing the implementation contract, which if not done, + * may disrupt the proxy if another EOA were to initialize it. */ constructor( IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger, uint32 _destinationCircleDomainId, address _l2TokenGateway, - address _tokenRecipient, - address _crossDomainAdmin + address _tokenRecipient ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId) { L2_TOKEN_GATEWAY = _l2TokenGateway; TOKEN_RECIPIENT = _tokenRecipient; - CROSS_DOMAIN_ADMIN = _crossDomainAdmin; + _disableInitializers(); + } + + /** + * @notice Initializes the withdrawal helper contract. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + */ + function initialize(address _crossDomainAdmin) public initializer { + __UUPSUpgradeable_init(); + _setCrossDomainAdmin(_crossDomainAdmin); } /* - * @notice Transfers a specified amount of an L2 token to an address on L2. - * @param l2Token Address of the L2 token to send. - * @param to Address which should receive the L2 token. - * @param amount Amount of the L2 token to send to `to`. - * @dev This function should only be callable via a rescue adapter. This function is solely for times when token withdrawals from L2-L1 - * cannot succeed for whatever reason, so the tokens must be sent to an alternate address. + * @notice Sets a new cross domain admin. The admin cannot be the zero address. The cross domain admin is the only address which may call + * upgrade this contract. + * @param _newCrossDomainAdmin L1 address of the new cross domain admin. */ - function sendTokenTo( - address l2Token, - address to, - uint256 amount - ) external onlyAdmin { - IERC20(l2Token).safeTransfer(to, amount); + function _setCrossDomainAdmin(address _newCrossDomainAdmin) internal { + if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin(); + crossDomainAdmin = _newCrossDomainAdmin; + emit SetXDomainAdmin(_newCrossDomainAdmin); } /* @@ -92,4 +103,15 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller { * @dev This implementation must change on a per-chain basis, since each L2 network has their own method of deriving the L1 msg.sender. */ function _requireAdminSender() internal virtual; + + /* + * @notice Access control check for upgrading this proxy contract + * @dev This requires that _requireAdminSender() is properly implemented on all contracts which inherit WithdrawalHelperBase. + */ + function _authorizeUpgrade(address) internal override onlyAdmin {} + + // Reserve storage slots for future versions of this base contract to add state variables without + // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables + // are added. This is at bottom of contract to make sure it's always at the end of storage. + uint256[1000] private __gap; } diff --git a/test/evm/foundry/local/WithdrawalHelper.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol index 40c8bbe67..61924f3fa 100644 --- a/test/evm/foundry/local/WithdrawalHelper.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; import { Arbitrum_WithdrawalHelper } from "../../../../contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol"; @@ -24,7 +25,6 @@ contract Mock_Ovm_WithdrawalHelper is Ovm_WithdrawalHelper { uint32 _destinationCircleDomainId, address _l2Gateway, address _tokenRecipient, - address _hubPool, IOvm_SpokePool _spokePool ) Ovm_WithdrawalHelper( @@ -33,7 +33,6 @@ contract Mock_Ovm_WithdrawalHelper is Ovm_WithdrawalHelper { _destinationCircleDomainId, _l2Gateway, _tokenRecipient, - _hubPool, _spokePool ) {} @@ -141,18 +140,28 @@ contract WithdrawalAdapterTest is Test { tokenMessenger, CircleDomainIds.Ethereum, address(arbBridge), - hubPool, hubPool ); + proxy = address( + new ERC1967Proxy( + address(arbitrumWithdrawalHelper), + abi.encodeCall(WithdrawalHelperBase.initialize, (hubPool)) + ) + ); + arbitrumWithdrawalHelper = Arbitrum_WithdrawalHelper(payable(proxy)); + ovmWithdrawalHelper = new Mock_Ovm_WithdrawalHelper( l2Usdc, tokenMessenger, CircleDomainIds.Ethereum, address(ovmBridge), hubPool, - hubPool, IOvm_SpokePool(address(ovmSpokePool)) ); + proxy = address( + new ERC1967Proxy(address(ovmWithdrawalHelper), abi.encodeCall(WithdrawalHelperBase.initialize, (hubPool))) + ); + ovmWithdrawalHelper = Mock_Ovm_WithdrawalHelper(payable(proxy)); } // This test should call the gateway router contract. @@ -214,16 +223,20 @@ contract WithdrawalAdapterTest is Test { assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalHelper))); } - function testRescueTokensWithAdminMessage(uint256 amountToReturn, address rando) public { + function testUpgrade(uint256 amountToReturn, address rando) public { vm.assume(rando != hubPool); l2CustomToken.mint(address(ovmWithdrawalHelper), amountToReturn); + address newImplementation = address( + new Arbitrum_WithdrawalHelper(l2Usdc, tokenMessenger, CircleDomainIds.Ethereum, address(arbBridge), hubPool) + ); + // Should revert if we are an unauthorized user. vm.startPrank(rando); vm.expectRevert(Ovm_WithdrawalHelper.NotCrossDomainAdmin.selector); messenger.impersonateCall( address(ovmWithdrawalHelper), - abi.encodeCall(WithdrawalHelperBase.sendTokenTo, (address(l2CustomToken), hubPool, amountToReturn)) + abi.encodeCall(UUPSUpgradeable.upgradeTo, (newImplementation)) ); vm.stopPrank(); @@ -231,11 +244,8 @@ contract WithdrawalAdapterTest is Test { vm.startPrank(hubPool); messenger.impersonateCall( address(ovmWithdrawalHelper), - abi.encodeCall(WithdrawalHelperBase.sendTokenTo, (address(l2CustomToken), hubPool, amountToReturn)) + abi.encodeCall(UUPSUpgradeable.upgradeTo, (newImplementation)) ); vm.stopPrank(); - - assertEq(0, l2CustomToken.balanceOf(address(ovmWithdrawalHelper))); - assertEq(amountToReturn, l2CustomToken.balanceOf(hubPool)); } } From 96a4e1bb1e46f3cbb18d6fd1b72dee52eada2512 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 15:18:34 -0500 Subject: [PATCH 24/28] move init functions to chain-specific adapters Signed-off-by: bennett --- contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol | 8 ++++++++ contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol | 8 ++++++++ contracts/chain-adapters/l2/WithdrawalHelperBase.sol | 2 +- test/evm/foundry/local/WithdrawalHelper.t.sol | 4 ++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index 486766619..2fbe4e4be 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -52,6 +52,14 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { ) {} + /** + * @notice Initializes the withdrawal helper contract. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + */ + function initialize(address _crossDomainAdmin) public initializer { + __WithdrawalHelper_init(_crossDomainAdmin); + } + /* * @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the TOKEN_RECIPIENT L1 address. * @param l1Token Address of the L1 token to receive. diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol index 64ff48187..9823422e4 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol @@ -86,6 +86,14 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { l2Eth = spokePool.l2Eth(); } + /** + * @notice Initializes the withdrawal helper contract. + * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. + */ + function initialize(address _crossDomainAdmin) public initializer { + __WithdrawalHelper_init(_crossDomainAdmin); + } + /* * @notice Calls CCTP or the Optimism token gateway to withdraw tokens back to the recipient. * @param l2Token address of the l2Token to send back. diff --git a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol index 69faa46dd..39c7855c8 100644 --- a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -65,7 +65,7 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller, UUPSUp * @notice Initializes the withdrawal helper contract. * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract. */ - function initialize(address _crossDomainAdmin) public initializer { + function __WithdrawalHelper_init(address _crossDomainAdmin) public onlyInitializing { __UUPSUpgradeable_init(); _setCrossDomainAdmin(_crossDomainAdmin); } diff --git a/test/evm/foundry/local/WithdrawalHelper.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol index 61924f3fa..ea529b59a 100644 --- a/test/evm/foundry/local/WithdrawalHelper.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -145,7 +145,7 @@ contract WithdrawalAdapterTest is Test { proxy = address( new ERC1967Proxy( address(arbitrumWithdrawalHelper), - abi.encodeCall(WithdrawalHelperBase.initialize, (hubPool)) + abi.encodeCall(Ovm_WithdrawalHelper.initialize, (hubPool)) ) ); arbitrumWithdrawalHelper = Arbitrum_WithdrawalHelper(payable(proxy)); @@ -159,7 +159,7 @@ contract WithdrawalAdapterTest is Test { IOvm_SpokePool(address(ovmSpokePool)) ); proxy = address( - new ERC1967Proxy(address(ovmWithdrawalHelper), abi.encodeCall(WithdrawalHelperBase.initialize, (hubPool))) + new ERC1967Proxy(address(ovmWithdrawalHelper), abi.encodeCall(Ovm_WithdrawalHelper.initialize, (hubPool))) ); ovmWithdrawalHelper = Mock_Ovm_WithdrawalHelper(payable(proxy)); } From 167b8a9ddeff09555bb627b7a6419c1cffea36d5 Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 15:36:36 -0500 Subject: [PATCH 25/28] address comments Signed-off-by: bennett --- .../chain-adapters/l2/Arbitrum_WithdrawalHelper.sol | 9 +++------ contracts/chain-adapters/l2/WithdrawalHelperBase.sol | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index 2fbe4e4be..ed0cfd28d 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -23,11 +23,6 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { // Error which triggers when the supplied L1 token does not match the Arbitrum gateway router's expected L2 token. error InvalidTokenMapping(); - modifier onlyCrossDomainAdmin() { - require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin), "ONLY_CROSS_DOMAIN_ADMIN"); - _; - } - /* * @notice Constructs the Arbitrum_WithdrawalHelper. * @param _l2Usdc Address of native USDC on the L2. @@ -91,5 +86,7 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { } } - function _requireAdminSender() internal override onlyCrossDomainAdmin {} + function _requireAdminSender() internal override { + require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin), "ONLY_CROSS_DOMAIN_ADMIN"); + } } diff --git a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol index 39c7855c8..a5677bfd3 100644 --- a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -99,7 +99,7 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller, UUPSUp ) public virtual; /* - * @notice Checks that the L1 msg.sender is the `CROSS_DOMAIN_ADMIN` address. + * @notice Checks that the L1 msg.sender is the `crossDomainAdmin` address. * @dev This implementation must change on a per-chain basis, since each L2 network has their own method of deriving the L1 msg.sender. */ function _requireAdminSender() internal virtual; From 3470649879b3a79f3cfb23419ca1949e741d406a Mon Sep 17 00:00:00 2001 From: bennett Date: Fri, 4 Oct 2024 16:08:38 -0500 Subject: [PATCH 26/28] inherit errors Signed-off-by: bennett --- contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol | 2 +- contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol | 3 --- contracts/chain-adapters/l2/WithdrawalHelperBase.sol | 2 ++ test/evm/foundry/local/WithdrawalHelper.t.sol | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index ed0cfd28d..a6f874f72 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -87,6 +87,6 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { } function _requireAdminSender() internal override { - require(msg.sender == CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin), "ONLY_CROSS_DOMAIN_ADMIN"); + if (msg.sender != CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin)) revert NotCrossDomainAdmin(); } } diff --git a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol index 9823422e4..e1e632f52 100644 --- a/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Ovm_WithdrawalHelper.sol @@ -56,9 +56,6 @@ contract Ovm_WithdrawalHelper is WithdrawalHelperBase { // Address of the messenger contract on L2. This is by default defined in Lib_PredeployAddresses. address public constant MESSENGER = Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER; - // Error which triggers when the L1 msg.sender is not the cross domain admin address. - error NotCrossDomainAdmin(); - /* * @notice Constructs the Ovm_WithdrawalAdapter. * @param _l2Usdc Address of native USDC on the L2. diff --git a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol index a5677bfd3..4af4ac3fa 100644 --- a/contracts/chain-adapters/l2/WithdrawalHelperBase.sol +++ b/contracts/chain-adapters/l2/WithdrawalHelperBase.sol @@ -30,6 +30,8 @@ abstract contract WithdrawalHelperBase is CircleCCTPAdapter, MultiCaller, UUPSUp // Error which triggers when the cross domain admin was attempted to be set to the zero address. error InvalidCrossDomainAdmin(); + // Error which triggers when the caller of a protected function is not the cross domain admin. + error NotCrossDomainAdmin(); // Functions which contain this modifier should only be callable via a cross-chain call where the L1 msg.sender is the hub pool. modifier onlyAdmin() { diff --git a/test/evm/foundry/local/WithdrawalHelper.t.sol b/test/evm/foundry/local/WithdrawalHelper.t.sol index ea529b59a..36e8d53d9 100644 --- a/test/evm/foundry/local/WithdrawalHelper.t.sol +++ b/test/evm/foundry/local/WithdrawalHelper.t.sol @@ -233,7 +233,7 @@ contract WithdrawalAdapterTest is Test { // Should revert if we are an unauthorized user. vm.startPrank(rando); - vm.expectRevert(Ovm_WithdrawalHelper.NotCrossDomainAdmin.selector); + vm.expectRevert(WithdrawalHelperBase.NotCrossDomainAdmin.selector); messenger.impersonateCall( address(ovmWithdrawalHelper), abi.encodeCall(UUPSUpgradeable.upgradeTo, (newImplementation)) From 47c853716e0f7aa8a80444be1bd66296619695c9 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 7 Oct 2024 08:38:45 -0500 Subject: [PATCH 27/28] change _minGasLimit from a u256 to a u32 Signed-off-by: bennett --- contracts/Ovm_SpokePool.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/Ovm_SpokePool.sol b/contracts/Ovm_SpokePool.sol index 830d57727..574b3481f 100644 --- a/contracts/Ovm_SpokePool.sol +++ b/contracts/Ovm_SpokePool.sol @@ -23,7 +23,7 @@ interface IL2ERC20Bridge { address _remoteToken, address _to, uint256 _amount, - uint256 _minGasLimit, + uint32 _minGasLimit, bytes calldata _extraData ) external; } From 5ab22c33547c5886b639d0d30aef77b8c93d7349 Mon Sep 17 00:00:00 2001 From: bennett Date: Mon, 7 Oct 2024 08:41:30 -0500 Subject: [PATCH 28/28] update test interfaces Signed-off-by: bennett --- contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol | 2 +- contracts/test/MockBedrockStandardBridge.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol index a6f874f72..d46ff29e2 100644 --- a/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol +++ b/contracts/chain-adapters/l2/Arbitrum_WithdrawalHelper.sol @@ -86,7 +86,7 @@ contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase { } } - function _requireAdminSender() internal override { + function _requireAdminSender() internal view override { if (msg.sender != CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin)) revert NotCrossDomainAdmin(); } } diff --git a/contracts/test/MockBedrockStandardBridge.sol b/contracts/test/MockBedrockStandardBridge.sol index b55cd1a12..c154373c4 100644 --- a/contracts/test/MockBedrockStandardBridge.sol +++ b/contracts/test/MockBedrockStandardBridge.sol @@ -22,7 +22,7 @@ contract MockBedrockL2StandardBridge is IL2ERC20Bridge { address _remoteToken, address _to, uint256 _amount, - uint256, + uint32, bytes calldata ) external { // Check that caller has approved this contract to pull funds, mirroring mainnet's behavior