Skip to content

Commit 266fee0

Browse files
authored
feat(Arbitrum_Adapter): Support custom gas token (#585)
* feat(Arbitrum_Adapter): Support custom gas token - Example `createRetryableTicket` paying for submission with custom gas token and also sending custom gas token to L2: https://etherscan.io/tx/0xfbcd1f29a0db9971c885815e74499f3a545e7c0ee0a2d06ee4a94dc08c03f4e0 - Example `outboundTransfer` paying with custom gas token and sending a different ERC20 to L2: https://etherscan.io/tx/0x7f65fff856520cc419c3a9f43a61f3427ad495ddf5dc81276c2767ac74242e39 * Move custom gas token logic to new adapter * Add simple custom gas token funder contract * Update Arbitrum_CustomGasToken_Funder.sol * Update Arbitrum_CustomGasToken_Adapter.sol
1 parent d8123dd commit 266fee0

File tree

2 files changed

+309
-0
lines changed

2 files changed

+309
-0
lines changed
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.0;
3+
4+
import "./interfaces/AdapterInterface.sol";
5+
6+
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7+
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
8+
import "../external/interfaces/CCTPInterfaces.sol";
9+
import "../libraries/CircleCCTPAdapter.sol";
10+
11+
interface FunderInterface {
12+
function withdraw(IERC20 token, uint256 amount) external;
13+
}
14+
15+
/**
16+
* @title Staging ground for incoming and outgoing messages
17+
* @notice Unlike the standard Eth bridge, native token bridge escrows the custom ERC20 token which is
18+
* used as native currency on L2.
19+
* @dev Fees are paid in this token. There are certain restrictions on the native token:
20+
* - The token can't be rebasing or have a transfer fee
21+
* - The token must only be transferrable via a call to the token address itself
22+
* - The token must only be able to set allowance via a call to the token address itself
23+
* - 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
24+
* - The token must have a max of 2^256 - 1 wei total supply unscaled
25+
* - The token must have a max of 2^256 - 1 wei total supply when scaled to 18 decimals
26+
*/
27+
interface ArbitrumL1ERC20Bridge {
28+
/**
29+
* @notice Returns token that is escrowed in bridge on L1 side and minted on L2 as native currency.
30+
* @dev This function doesn't exist on the generic Bridge interface.
31+
*/
32+
function nativeToken() external view returns (address);
33+
}
34+
35+
/**
36+
* @title Inbox for user and contract originated messages
37+
* @notice Messages created via this inbox are enqueued in the delayed accumulator
38+
* to await inclusion in the SequencerInbox
39+
*/
40+
interface ArbitrumL1InboxLike {
41+
/**
42+
* @dev we only use this function to check the native token used by the bridge, so we hardcode the interface
43+
* to return an ArbitrumL1ERC20Bridge instead of a more generic Bridge interface.
44+
*/
45+
function bridge() external view returns (ArbitrumL1ERC20Bridge);
46+
47+
/**
48+
* @notice Put a message in the L2 inbox that can be reexecuted for some fixed amount of time if it reverts
49+
* @notice Overloads the `createRetryableTicket` function but is not payable, and should only be called when paying
50+
* for L1 to L2 message using a custom gas token.
51+
* @dev all tokenTotalFeeAmount will be deposited to callValueRefundAddress on L2
52+
* @dev Gas limit and maxFeePerGas should not be set to 1 as that is used to trigger the RetryableData error
53+
* @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.
54+
* @param to destination L2 contract address
55+
* @param l2CallValue call value for retryable L2 message
56+
* @param maxSubmissionCost Max gas deducted from user's L2 balance to cover base submission fee
57+
* @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.
58+
* @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.
59+
* @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)
60+
* @param maxFeePerGas price bid for L2 execution. Should not be set to 1 (magic value used to trigger the RetryableData error)
61+
* @param tokenTotalFeeAmount amount of fees to be deposited in native token to cover for retryable ticket cost
62+
* @param data ABI encoded data of L2 message
63+
* @return unique message number of the retryable transaction
64+
*/
65+
function createRetryableTicket(
66+
address to,
67+
uint256 l2CallValue,
68+
uint256 maxSubmissionCost,
69+
address excessFeeRefundAddress,
70+
address callValueRefundAddress,
71+
uint256 gasLimit,
72+
uint256 maxFeePerGas,
73+
uint256 tokenTotalFeeAmount,
74+
bytes calldata data
75+
) external returns (uint256);
76+
}
77+
78+
/**
79+
* @notice Layer 1 Gateway contract for bridging standard ERC20s to Arbitrum.
80+
*/
81+
interface ArbitrumL1ERC20GatewayLike {
82+
/**
83+
* @notice Deposit ERC20 token from Ethereum into Arbitrum.
84+
* @dev L2 address alias will not be applied to the following types of addresses on L1:
85+
* - an externally-owned account
86+
* - a contract in construction
87+
* - an address where a contract will be created
88+
* - an address where a contract lived, but was destroyed
89+
* @param _l1Token L1 address of ERC20
90+
* @param _refundTo Account, or its L2 alias if it have code in L1, to be credited with excess gas refund in L2
91+
* @param _to Account to be credited with the tokens in the L2 (can be the user's L2 account or a contract),
92+
* not subject to L2 aliasing. This account, or its L2 alias if it have code in L1, will also be able to
93+
* cancel the retryable ticket and receive callvalue refund
94+
* @param _amount Token Amount
95+
* @param _maxGas Max gas deducted from user's L2 balance to cover L2 execution
96+
* @param _gasPriceBid Gas price for L2 execution
97+
* @param _data encoded data from router and user
98+
* @return res abi encoded inbox sequence number
99+
*/
100+
function outboundTransferCustomRefund(
101+
address _l1Token,
102+
address _refundTo,
103+
address _to,
104+
uint256 _amount,
105+
uint256 _maxGas,
106+
uint256 _gasPriceBid,
107+
bytes calldata _data
108+
) external payable returns (bytes memory);
109+
110+
/**
111+
* @notice get ERC20 gateway for token.
112+
* @param _token ERC20 address.
113+
* @return address of ERC20 gateway.
114+
*/
115+
function getGateway(address _token) external view returns (address);
116+
}
117+
118+
/**
119+
* @notice Contract containing logic to send messages from L1 to Arbitrum.
120+
* @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be
121+
* called via delegatecall, which will execute this contract's logic within the context of the originating contract.
122+
* For example, the HubPool will delegatecall these functions, therefore its only necessary that the HubPool's methods
123+
* that call this contract's logic guard against reentrancy.
124+
* @dev This contract is very similar to Arbitrum_Adapter but it allows the caller to pay for submission
125+
* fees using a custom gas token. This is required to support certain Arbitrum orbit L2s and L3s.
126+
* @dev This contract
127+
*/
128+
129+
// solhint-disable-next-line contract-name-camelcase
130+
contract Arbitrum_CustomGasToken_Adapter is AdapterInterface, CircleCCTPAdapter {
131+
using SafeERC20 for IERC20;
132+
133+
// Amount of gas token allocated to pay for the base submission fee. The base submission fee is a parameter unique to
134+
// retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their
135+
// ticket’s calldata in the retry buffer. (current base submission fee is queryable via
136+
// ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address
137+
// 0x000000000000000000000000000000000000006E.
138+
// @dev Unlike in Arbitrum_Adapter, this is immutable because we don't know what precision the custom gas token has.
139+
uint256 public immutable L2_MAX_SUBMISSION_COST;
140+
141+
// L2 Gas price bid for immediate L2 execution attempt (queryable via standard eth*gasPrice RPC)
142+
uint256 public constant L2_GAS_PRICE = 5e9; // 5 gWei
143+
144+
uint256 public constant L2_CALL_VALUE = 0;
145+
146+
uint32 public constant RELAY_TOKENS_L2_GAS_LIMIT = 300_000;
147+
uint32 public constant RELAY_MESSAGE_L2_GAS_LIMIT = 2_000_000;
148+
149+
// This address on L2 receives extra gas token that is left over after relaying a message via the inbox.
150+
address public immutable L2_REFUND_L2_ADDRESS;
151+
152+
ArbitrumL1InboxLike public immutable L1_INBOX;
153+
154+
ArbitrumL1ERC20GatewayLike public immutable L1_ERC20_GATEWAY_ROUTER;
155+
156+
// This token is used to pay for l1 to l2 messages if its configured by an Arbitrum orbit chain.
157+
IERC20 public immutable CUSTOM_GAS_TOKEN;
158+
159+
FunderInterface public immutable CUSTOM_GAS_TOKEN_FUNDER;
160+
161+
/**
162+
* @notice Constructs new Adapter.
163+
* @param _l1ArbitrumInbox Inbox helper contract to send messages to Arbitrum.
164+
* @param _l1ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum.
165+
* @param _l2RefundL2Address L2 address to receive gas refunds on after a message is relayed.
166+
* @param _l1Usdc USDC address on L1.
167+
* @param _cctpTokenMessenger TokenMessenger contract to bridge via CCTP.
168+
* @param _customGasTokenFunder Contract that funds the custom gas token.
169+
*/
170+
constructor(
171+
ArbitrumL1InboxLike _l1ArbitrumInbox,
172+
ArbitrumL1ERC20GatewayLike _l1ERC20GatewayRouter,
173+
address _l2RefundL2Address,
174+
IERC20 _l1Usdc,
175+
ITokenMessenger _cctpTokenMessenger,
176+
FunderInterface _customGasTokenFunder,
177+
uint256 _l2MaxSubmissionCost
178+
) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Arbitrum) {
179+
L1_INBOX = _l1ArbitrumInbox;
180+
L1_ERC20_GATEWAY_ROUTER = _l1ERC20GatewayRouter;
181+
L2_REFUND_L2_ADDRESS = _l2RefundL2Address;
182+
CUSTOM_GAS_TOKEN = IERC20(L1_INBOX.bridge().nativeToken());
183+
require(address(CUSTOM_GAS_TOKEN) != address(0), "Invalid custom gas token");
184+
L2_MAX_SUBMISSION_COST = _l2MaxSubmissionCost;
185+
CUSTOM_GAS_TOKEN_FUNDER = _customGasTokenFunder;
186+
}
187+
188+
/**
189+
* @notice Send cross-chain message to target on Arbitrum.
190+
* @notice This contract must hold at least getL1CallValue() amount of the custom gas token
191+
* to send a message via the Inbox successfully, or the message will get stuck.
192+
* @param target Contract on Arbitrum that will receive message.
193+
* @param message Data to send to target.
194+
*/
195+
function relayMessage(address target, bytes memory message) external payable override {
196+
uint256 requiredL1TokenTotalFeeAmount = _pullCustomGas(RELAY_MESSAGE_L2_GAS_LIMIT);
197+
CUSTOM_GAS_TOKEN.safeIncreaseAllowance(address(L1_INBOX), requiredL1TokenTotalFeeAmount);
198+
L1_INBOX.createRetryableTicket(
199+
target, // destAddr destination L2 contract address
200+
L2_CALL_VALUE, // l2CallValue call value for retryable L2 message
201+
L2_MAX_SUBMISSION_COST, // maxSubmissionCost Max gas deducted from user's L2 balance to cover base fee
202+
L2_REFUND_L2_ADDRESS, // excessFeeRefundAddress maxgas * gasprice - execution cost gets credited here on L2
203+
L2_REFUND_L2_ADDRESS, // callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled
204+
RELAY_MESSAGE_L2_GAS_LIMIT, // maxGas Max gas deducted from user's L2 balance to cover L2 execution
205+
L2_GAS_PRICE, // gasPriceBid price bid for L2 execution
206+
requiredL1TokenTotalFeeAmount, // tokenTotalFeeAmount amount of fees to be deposited in native token.
207+
message // data ABI encoded data of L2 message
208+
);
209+
emit MessageRelayed(target, message);
210+
}
211+
212+
/**
213+
* @notice Bridge tokens to Arbitrum.
214+
* @notice This contract must hold at least getL1CallValue() amount of ETH or custom gas token
215+
* to send a message via the Inbox successfully, or the message will get stuck.
216+
* @param l1Token L1 token to deposit.
217+
* @param l2Token L2 token to receive.
218+
* @param amount Amount of L1 tokens to deposit and L2 tokens to receive.
219+
* @param to Bridge recipient.
220+
*/
221+
function relayTokens(
222+
address l1Token,
223+
address l2Token, // l2Token is unused for Arbitrum.
224+
uint256 amount,
225+
address to
226+
) external payable override {
227+
// Check if this token is USDC, which requires a custom bridge via CCTP.
228+
if (_isCCTPEnabled() && l1Token == address(usdcToken)) {
229+
_transferUsdc(to, amount);
230+
}
231+
// If not, we can use the Arbitrum gateway
232+
else {
233+
address erc20Gateway = L1_ERC20_GATEWAY_ROUTER.getGateway(l1Token);
234+
235+
// If custom gas token, call special functions that handle paying with custom gas tokens.
236+
uint256 requiredL1TokenTotalFeeAmount = _pullCustomGas(RELAY_MESSAGE_L2_GAS_LIMIT);
237+
238+
// Must use Inbox to bridge custom gas token.
239+
// Source: https://github.com/OffchainLabs/token-bridge-contracts/blob/5bdf33259d2d9ae52ddc69bc5a9cbc558c4c40c7/contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol#L33
240+
if (l1Token == address(CUSTOM_GAS_TOKEN)) {
241+
uint256 amountToBridge = amount + requiredL1TokenTotalFeeAmount;
242+
CUSTOM_GAS_TOKEN.safeIncreaseAllowance(address(L1_INBOX), amountToBridge);
243+
L1_INBOX.createRetryableTicket(
244+
to, // destAddr destination L2 contract address
245+
L2_CALL_VALUE, // l2CallValue call value for retryable L2 message
246+
L2_MAX_SUBMISSION_COST, // maxSubmissionCost Max gas deducted from user's L2 balance to cover base fee
247+
L2_REFUND_L2_ADDRESS, // excessFeeRefundAddress maxgas * gasprice - execution cost gets credited here on L2
248+
L2_REFUND_L2_ADDRESS, // callValueRefundAddress l2Callvalue gets credited here on L2 if retryable txn times out or gets cancelled
249+
RELAY_MESSAGE_L2_GAS_LIMIT, // maxGas Max gas deducted from user's L2 balance to cover L2 execution
250+
L2_GAS_PRICE, // gasPriceBid price bid for L2 execution
251+
amountToBridge, // tokenTotalFeeAmount amount of fees to be deposited in native token.
252+
"0x" // data ABI encoded data of L2 message
253+
);
254+
} else {
255+
IERC20(l1Token).safeIncreaseAllowance(erc20Gateway, amount);
256+
CUSTOM_GAS_TOKEN.safeIncreaseAllowance(erc20Gateway, requiredL1TokenTotalFeeAmount);
257+
258+
// To pay for gateway outbound transfer with custom gas token, encode the tokenTotalFeeAmount in the data field:
259+
// The data format should be (uint256 maxSubmissionCost, bytes extraData, uint256 tokenTotalFeeAmount).
260+
// Source: https://github.com/OffchainLabs/token-bridge-contracts/blob/5bdf33259d2d9ae52ddc69bc5a9cbc558c4c40c7/contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol#L57
261+
bytes memory data = abi.encode(L2_MAX_SUBMISSION_COST, "", requiredL1TokenTotalFeeAmount);
262+
L1_ERC20_GATEWAY_ROUTER.outboundTransferCustomRefund(
263+
l1Token,
264+
L2_REFUND_L2_ADDRESS,
265+
to,
266+
amount,
267+
RELAY_TOKENS_L2_GAS_LIMIT,
268+
L2_GAS_PRICE,
269+
data
270+
);
271+
}
272+
}
273+
emit TokensRelayed(l1Token, l2Token, amount, to);
274+
}
275+
276+
/**
277+
* @notice Returns required amount of gas token to send a message via the Inbox.
278+
* @return amount of gas token that this contract needs to hold in order for relayMessage to succeed.
279+
*/
280+
function getL1CallValue(uint32 l2GasLimit) public view returns (uint256) {
281+
return L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * l2GasLimit;
282+
}
283+
284+
function _pullCustomGas(uint32 l2GasLimit) internal returns (uint256) {
285+
uint256 requiredL1CallValue = getL1CallValue(l2GasLimit);
286+
CUSTOM_GAS_TOKEN_FUNDER.withdraw(CUSTOM_GAS_TOKEN, requiredL1CallValue);
287+
require(CUSTOM_GAS_TOKEN.balanceOf(address(this)) >= requiredL1CallValue, "Insufficient gas balance");
288+
return requiredL1CallValue;
289+
}
290+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
import "@openzeppelin/contracts/access/Ownable.sol";
5+
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6+
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7+
8+
contract Arbitrum_CustomGasToken_Funder is Ownable {
9+
using SafeERC20 for IERC20;
10+
11+
/**
12+
* @notice Withdraw tokens from the contract.
13+
* @param token Token to withdraw.
14+
* @param amount Amount of tokens to withdraw.
15+
*/
16+
function withdraw(IERC20 token, uint256 amount) external onlyOwner {
17+
token.safeTransfer(msg.sender, amount);
18+
}
19+
}

0 commit comments

Comments
 (0)