Skip to content

Commit abec4e5

Browse files
authored
feat: L3 withdrawal helpers (#605)
* feat: L3 withdrawal helpers Signed-off-by: bennett <[email protected]> --------- Signed-off-by: bennett <[email protected]>
1 parent c0917a2 commit abec4e5

File tree

7 files changed

+684
-14
lines changed

7 files changed

+684
-14
lines changed

contracts/Ovm_SpokePool.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface IL2ERC20Bridge {
2323
address _remoteToken,
2424
address _to,
2525
uint256 _amount,
26-
uint256 _minGasLimit,
26+
uint32 _minGasLimit,
2727
bytes calldata _extraData
2828
) external;
2929
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
// Arbitrum only supports v0.8.19
4+
// See https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum
5+
pragma solidity ^0.8.19;
6+
7+
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8+
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
9+
import { ArbitrumL2ERC20GatewayLike } from "../../interfaces/ArbitrumBridge.sol";
10+
import { WithdrawalHelperBase } from "./WithdrawalHelperBase.sol";
11+
import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol";
12+
import { CrossDomainAddressUtils } from "../../libraries/CrossDomainAddressUtils.sol";
13+
14+
/**
15+
* @title Arbitrum_WithdrawalHelper
16+
* @notice This contract interfaces with L2-L1 token bridges and withdraws tokens to a single address on L1.
17+
* @dev This contract should be deployed on Arbitrum L2s which only use CCTP or the canonical Arbitrum gateway router to withdraw tokens.
18+
* @custom:security-contact [email protected]
19+
*/
20+
contract Arbitrum_WithdrawalHelper is WithdrawalHelperBase {
21+
using SafeERC20 for IERC20;
22+
23+
// Error which triggers when the supplied L1 token does not match the Arbitrum gateway router's expected L2 token.
24+
error InvalidTokenMapping();
25+
26+
/*
27+
* @notice Constructs the Arbitrum_WithdrawalHelper.
28+
* @param _l2Usdc Address of native USDC on the L2.
29+
* @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2.
30+
* @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this is 0.
31+
* @param _l2GatewayRouter Address of the Arbitrum l2 gateway router contract.
32+
* @param _tokenRecipient L1 Address which will unconditionally receive tokens withdrawn from this contract.
33+
*/
34+
constructor(
35+
IERC20 _l2Usdc,
36+
ITokenMessenger _cctpTokenMessenger,
37+
uint32 _destinationCircleDomainId,
38+
address _l2GatewayRouter,
39+
address _tokenRecipient
40+
)
41+
WithdrawalHelperBase(
42+
_l2Usdc,
43+
_cctpTokenMessenger,
44+
_destinationCircleDomainId,
45+
_l2GatewayRouter,
46+
_tokenRecipient
47+
)
48+
{}
49+
50+
/**
51+
* @notice Initializes the withdrawal helper contract.
52+
* @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract.
53+
*/
54+
function initialize(address _crossDomainAdmin) public initializer {
55+
__WithdrawalHelper_init(_crossDomainAdmin);
56+
}
57+
58+
/*
59+
* @notice Calls CCTP or the Arbitrum gateway router to withdraw tokens back to the TOKEN_RECIPIENT L1 address.
60+
* @param l1Token Address of the L1 token to receive.
61+
* @param l2Token Address of the L2 token to send back.
62+
* @param amountToReturn Amount of l2Token to send back.
63+
*/
64+
function withdrawToken(
65+
address l1Token,
66+
address l2Token,
67+
uint256 amountToReturn
68+
) public override {
69+
// If the l2TokenAddress is UDSC, we need to use the CCTP bridge.
70+
if (l2Token == address(usdcToken) && _isCCTPEnabled()) {
71+
_transferUsdc(TOKEN_RECIPIENT, amountToReturn);
72+
} else {
73+
// Otherwise, we use the Arbitrum ERC20 Gateway router.
74+
ArbitrumL2ERC20GatewayLike tokenBridge = ArbitrumL2ERC20GatewayLike(L2_TOKEN_GATEWAY);
75+
// If the gateway router's expected L2 token address does not match then revert. This check does not actually
76+
// impact whether the bridge will succeed, since the ERC20 gateway router only requires the L1 token address, but
77+
// it is added here to potentially catch scenarios where there was a mistake in the calldata.
78+
if (tokenBridge.calculateL2TokenAddress(l1Token) != l2Token) revert InvalidTokenMapping();
79+
//slither-disable-next-line unused-return
80+
tokenBridge.outboundTransfer(
81+
l1Token, // _l1Token. Address of the L1 token to bridge over.
82+
TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the recipient.
83+
amountToReturn, // _amount.
84+
"" // _data. We don't need to send any data for the bridging action.
85+
);
86+
}
87+
}
88+
89+
function _requireAdminSender() internal view override {
90+
if (msg.sender != CrossDomainAddressUtils.applyL1ToL2Alias(crossDomainAdmin)) revert NotCrossDomainAdmin();
91+
}
92+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
pragma solidity ^0.8.0;
4+
5+
import { WithdrawalHelperBase } from "./WithdrawalHelperBase.sol";
6+
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7+
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
8+
import { WETH9Interface } from "../../external/interfaces/WETH9Interface.sol";
9+
import { ITokenMessenger } from "../../external/interfaces/CCTPInterfaces.sol";
10+
import { Lib_PredeployAddresses } from "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol";
11+
import { LibOptimismUpgradeable } from "@openzeppelin/contracts-upgradeable/crosschain/optimism/LibOptimismUpgradeable.sol";
12+
import { IL2ERC20Bridge } from "../../Ovm_SpokePool.sol";
13+
14+
/**
15+
* @notice Minimal interface for the Ovm_SpokePool contract. This interface is called to pull state from the network's
16+
* spoke pool contract to be used by this withdrawal adapter.
17+
*/
18+
interface IOvm_SpokePool {
19+
// Returns the address of the token bridge for the input l2 token.
20+
function tokenBridges(address token) external view returns (address);
21+
22+
// Returns the address of the l1 token set in the spoke pool for the input l2 token.
23+
function remoteL1Tokens(address token) external view returns (address);
24+
25+
// Returns the address for the representation of ETH on the l2.
26+
function l2Eth() external view returns (address);
27+
28+
// Returns the address of the wrapped native token for the L2.
29+
function wrappedNativeToken() external view returns (WETH9Interface);
30+
31+
// Returns the amount of gas the contract allocates for a token withdrawal.
32+
function l1Gas() external view returns (uint32);
33+
}
34+
35+
/**
36+
* @title Ovm_WithdrawalAdapter
37+
* @notice This contract interfaces with L2-L1 token bridges and withdraws tokens to a single address on L1.
38+
* @dev This contract should be deployed on OpStack L2s which both have a Ovm_SpokePool contract deployed to the L2
39+
* network AND only use token bridges defined in the Ovm_SpokePool. A notable exception to this requirement is Optimism,
40+
* which has a special SNX bridge (and thus this adapter will NOT work for Optimism).
41+
* @custom:security-contact [email protected]
42+
*/
43+
contract Ovm_WithdrawalHelper is WithdrawalHelperBase {
44+
using SafeERC20 for IERC20;
45+
46+
// Address for the wrapped native token on this chain. For Ovm standard bridges, we need to unwrap
47+
// this token before initiating the withdrawal. Normally, it is 0x42..006, but there are instances
48+
// where this address is different.
49+
WETH9Interface public immutable wrappedNativeToken;
50+
// Address of the corresponding spoke pool on L2. This is to piggyback off of the spoke pool's supported
51+
// token routes/defined token bridges.
52+
IOvm_SpokePool public immutable spokePool;
53+
// Address of native ETH on the l2. For OpStack chains, this address is used to indicate a native ETH withdrawal.
54+
// In general, this address is 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000.
55+
address public immutable l2Eth;
56+
// Address of the messenger contract on L2. This is by default defined in Lib_PredeployAddresses.
57+
address public constant MESSENGER = Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER;
58+
59+
/*
60+
* @notice Constructs the Ovm_WithdrawalAdapter.
61+
* @param _l2Usdc Address of native USDC on the L2.
62+
* @param _cctpTokenMessenger Address of the CCTP token messenger contract on L2.
63+
* @param _destinationCircleDomainId Circle's assigned CCTP domain ID for the destination network. For Ethereum, this
64+
* is 0.
65+
* @param _l2Gateway Address of the Optimism ERC20 L2 standard bridge contract.
66+
* @param _tokenRecipient The L1 address which will unconditionally receive tokens from withdrawals by this contract.
67+
* @param _crossDomainAdmin Address of the admin on L1. This address is the only one which may tell this contract to send tokens to an
68+
* L2 address.
69+
* @param _spokePool The contract address of the Ovm_SpokePool which is deployed on this L2 network.
70+
*/
71+
constructor(
72+
IERC20 _l2Usdc,
73+
ITokenMessenger _cctpTokenMessenger,
74+
uint32 _destinationCircleDomainId,
75+
address _l2Gateway,
76+
address _tokenRecipient,
77+
IOvm_SpokePool _spokePool
78+
) WithdrawalHelperBase(_l2Usdc, _cctpTokenMessenger, _destinationCircleDomainId, _l2Gateway, _tokenRecipient) {
79+
spokePool = _spokePool;
80+
81+
// These addresses should only change network-by-network, or after a bridge upgrade, so we define them once in the constructor.
82+
wrappedNativeToken = spokePool.wrappedNativeToken();
83+
l2Eth = spokePool.l2Eth();
84+
}
85+
86+
/**
87+
* @notice Initializes the withdrawal helper contract.
88+
* @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract.
89+
*/
90+
function initialize(address _crossDomainAdmin) public initializer {
91+
__WithdrawalHelper_init(_crossDomainAdmin);
92+
}
93+
94+
/*
95+
* @notice Calls CCTP or the Optimism token gateway to withdraw tokens back to the recipient.
96+
* @param l2Token address of the l2Token to send back.
97+
* @param amountToReturn amount of l2Token to send back.
98+
* @dev The l1Token parameter is unused since we obtain the l1Token to receive by querying the state of the Ovm_SpokePool deployed
99+
* to this network.
100+
* @dev This function is a copy of the `_bridgeTokensToHubPool` function found on the Ovm_SpokePool contract here:
101+
* https://github.com/across-protocol/contracts/blob/65191dbcded95c8fe050e0f95eb7848e3784e61f/contracts/Ovm_SpokePool.sol#L148.
102+
* New lines of code correspond to instances where this contract queries state from the spoke pool, such as determining
103+
* the appropriate token bridge for the withdrawal or finding the remoteL1Token to withdraw.
104+
*/
105+
function withdrawToken(
106+
address,
107+
address l2Token,
108+
uint256 amountToReturn
109+
) public override {
110+
// Fetch the current l1Gas defined in the Ovm_SpokePool.
111+
uint32 l1Gas = spokePool.l1Gas();
112+
// If the token being bridged is WETH then we need to first unwrap it to ETH and then send ETH over the
113+
// canonical bridge. On Optimism, this is address 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000.
114+
if (l2Token == address(wrappedNativeToken)) {
115+
WETH9Interface(l2Token).withdraw(amountToReturn); // Unwrap into ETH.
116+
l2Token = l2Eth; // Set the l2Token to ETH.
117+
IL2ERC20Bridge(Lib_PredeployAddresses.L2_STANDARD_BRIDGE).withdrawTo{ value: amountToReturn }(
118+
l2Token, // _l2Token. Address of the L2 token to bridge over.
119+
TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the l1 pool contract.
120+
amountToReturn, // _amount.
121+
l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations
122+
"" // _data. We don't need to send any data for the bridging action.
123+
);
124+
}
125+
// If the token is USDC && CCTP bridge is enabled, then bridge USDC via CCTP.
126+
else if (l2Token == address(usdcToken) && _isCCTPEnabled()) {
127+
_transferUsdc(TOKEN_RECIPIENT, amountToReturn);
128+
}
129+
// Note we'll default to withdrawTo instead of bridgeERC20To unless the remoteL1Tokens mapping is set for
130+
// the l2Token. withdrawTo should be used to bridge back non-native L2 tokens
131+
// (i.e. non-native L2 tokens have a canonical L1 token). If we should bridge "native L2" tokens then
132+
// we'd need to call bridgeERC20To and give allowance to the tokenBridge to spend l2Token from this contract.
133+
// Therefore for native tokens we should set ensure that remoteL1Tokens is set for the l2Token.
134+
else {
135+
IL2ERC20Bridge tokenBridge = IL2ERC20Bridge(
136+
spokePool.tokenBridges(l2Token) == address(0)
137+
? Lib_PredeployAddresses.L2_STANDARD_BRIDGE
138+
: spokePool.tokenBridges(l2Token)
139+
);
140+
address remoteL1Token = spokePool.remoteL1Tokens(l2Token);
141+
if (remoteL1Token != address(0)) {
142+
// If there is a mapping for this L2 token to an L1 token, then use the L1 token address and
143+
// call bridgeERC20To.
144+
IERC20(l2Token).safeIncreaseAllowance(address(tokenBridge), amountToReturn);
145+
tokenBridge.bridgeERC20To(
146+
l2Token, // _l2Token. Address of the L2 token to bridge over.
147+
remoteL1Token, // Remote token to be received on L1 side. If the
148+
// remoteL1Token on the other chain does not recognize the local token as the correct
149+
// pair token, the ERC20 bridge will fail and the tokens will be returned to sender on
150+
// this chain.
151+
TOKEN_RECIPIENT, // _to
152+
amountToReturn, // _amount
153+
l1Gas, // _l1Gas
154+
"" // _data
155+
);
156+
} else {
157+
tokenBridge.withdrawTo(
158+
l2Token, // _l2Token. Address of the L2 token to bridge over.
159+
TOKEN_RECIPIENT, // _to. Withdraw, over the bridge, to the l1 pool contract.
160+
amountToReturn, // _amount.
161+
l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations
162+
"" // _data. We don't need to send any data for the bridging action.
163+
);
164+
}
165+
}
166+
}
167+
168+
function _requireAdminSender() internal view override {
169+
if (LibOptimismUpgradeable.crossChainSender(MESSENGER) != crossDomainAdmin) revert NotCrossDomainAdmin();
170+
}
171+
}

0 commit comments

Comments
 (0)