Configure custom gateway bridging
Before implementing and deploying a custom gateway, it is strongly encouraged to analyze the current solutions that Arbitrum's token bridge provides: the standard gateway and the generic-custom gateway. These solutions provide enough functionality to solve the majority of bridging needs from projects. And if you are in doubt about your current approach, you can always ask for assistance on our Discord server.
In this how-to, you'll learn how to bridge your own token between Ethereum (the parent chain) and Arbitrum (the child chain), using a custom gateway. For alternative ways of bridging tokens, check out the token bridging overview.
Familiarity with Arbitrum's token bridge system, smart contracts, and decentralized application development is expected. If you're new to developing on Arbitrum, consider reviewing our Quickstart: Build a dApp with Arbitrum (Solidity, Remix) before proceeding. We'll use Arbitrum's SDK throughout this how-to, although no prior knowledge is required.
We will go through all the steps involved in the process. However, if you want to jump straight to the code, we have created a custom gateway bridging tutorial script that encapsulates the entire process.
Step 0: Review the prerequisites (a.k.a. do I really need a custom gateway?)
Before implementing and deploying a custom gateway, it is strongly encouraged to analyze the current solutions that Arbitrum’s token bridge provides: the standard gateway and the generic-custom gateway. These solutions provide enough functionality to solve the majority of bridging needs from projects. And if you are in doubt about your current approach, you can always ask for assistance on our Discord server.
There are several prerequisites to consider when deploying your own custom gateway.
First of all, the parent chain counterpart of the gateway must implement the IL1ArbitrumGateway and ITokenGateway interfaces. This conformity means that it must have, at least:
- A method
outboundTransferCustomRefund, to handle forwarded calls fromL1GatewayRouter.outboundTransferCustomRefund. It should only allow calls from the router. - A method
outboundTransfer, to handle forwarded calls fromL1GatewayRouter.outboundTransfer. It should only allow calls from the router. - A method
finalizeInboundTransfer, to handle messages coming only from the child chain's gateway. - Two methods,
calculateL2TokenAddressandgetOutboundCalldata, to handle other bridging operations. - Methods to send cross-chain messages through the Inbox contract. You can view an example implementation in
sendTxToL2andsendTxToL2CustomRefundonL1ArbitrumMessenger.
Suppose you intend to use permissionless token registration in your gateway. In that case, your parent chain gateway should also have a registerCustomL2Token method, similar to the one method in Arbitrum’s generic-custom gateway.
On the other hand, the child chain counterpart of the gateway must conform to the ITokenGateway interface, meaning that it must have at least:
- A method
outboundTransfer, to handle external calls, and forwarded calls fromL2GatewayRouter.outboundTransfer. - A method
finalizeInboundTransfer, to handle messages coming only from the parent chain's gateway. - Two methods,
calculateL2TokenAddressandgetOutboundCalldata, to handle other bridging operations. - Methods to send cross-chain messages through the ArbSys precompile. You can view an example implementation of
sendTxToL1on L2ArbitrumMessenger.
What about my custom tokens?
If you are deploying custom gateways, you will likely want to support your custom tokens on both the parent and child chains. They also have several requirements they must comply with. You can find more information about it in How to bridge tokens via Arbitrum’s generic-custom gateway.
Step 1: Create a gateway and deploy it on the parent chain
The code in the following sections is intended for testing purposes only and doesn't guarantee any level of security. It hasn't undergone any formal audit or security analysis, so it isn't ready for production use. Exercise caution and due diligence while using this code in any environment.
We'll begin by creating our custom gateway and deploying it to the parent chain. A good example of a custom gateway is Arbitrum’s generic-custom gateway. It implements all required methods and adds additional methods, enabling the gateway to support a wide variety of tokens for bridging.
In this case, we’ll use a simpler approach. We’ll create a gateway that supports only one token and can be enabled or disabled by the contract owner. It will also implement all necessary methods. To simplify the deployment process even further, we won’t worry about setting the addresses of the counterpart gateway and the custom tokens at deployment time. Instead, we will use a function, setTokenBridgeInformation, that the contract owner will call to initialize the gateway.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "./interfaces/ICustomGateway.sol";
import "./CrosschainMessenger.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title Example implementation of a custom gateway to be deployed on L1
* @dev Inheritance of Ownable is optional. In this case we use it to call the function setTokenBridgeInformation
* and simplify the test
*/
contract L1CustomGateway is IL1CustomGateway, L1CrosschainMessenger, Ownable {
// Token bridge state variables
address public l1CustomToken;
address public l2CustomToken;
address public l2Gateway;
address public router;
// Custom functionality
bool public allowsDeposits;
/**
* Contract constructor, sets the L1 router to be used in the contract's functions and calls L1CrosschainMessenger's constructor
* @param router_ L1GatewayRouter address
* @param inbox_ Inbox address
*/
constructor(
address router_,
address inbox_
) L1CrosschainMessenger(inbox_) {
router = router_;
allowsDeposits = false;
}
/**
* Sets the information needed to use the gateway. To simplify the process of testing, this function can be called once
* by the owner of the contract to set these addresses.
* @param l1CustomToken_ address of the custom token on L1
* @param l2CustomToken_ address of the custom token on L2
* @param l2Gateway_ address of the counterpart gateway (on L2)
*/
function setTokenBridgeInformation(
address l1CustomToken_,
address l2CustomToken_,
address l2Gateway_
) public onlyOwner {
require(l1CustomToken == address(0), "Token bridge information already set");
l1CustomToken = l1CustomToken_;
l2CustomToken = l2CustomToken_;
l2Gateway = l2Gateway_;
// Allows deposits after the information has been set
allowsDeposits = true;
}
/// @dev See {ICustomGateway-outboundTransfer}
function outboundTransfer(
address l1Token,
address to,
uint256 amount,
uint256 maxGas,
uint256 gasPriceBid,
bytes calldata data
) public payable override returns (bytes memory) {
return outboundTransferCustomRefund(l1Token, to, to, amount, maxGas, gasPriceBid, data);
}
/// @dev See {IL1CustomGateway-outboundTransferCustomRefund}
function outboundTransferCustomRefund(
address l1Token,
address refundTo,
address to,
uint256 amount,
uint256 maxGas,
uint256 gasPriceBid,
bytes calldata data
) public payable override returns (bytes memory res) {
// Only execute if deposits are allowed
require(allowsDeposits == true, "Deposits are currently disabled");
// Only allow calls from the router
require(msg.sender == router, "Call not received from router");
// Only allow the custom token to be bridged through this gateway
require(l1Token == l1CustomToken, "Token is not allowed through this gateway");
address from;
uint256 seqNum;
{
bytes memory extraData;
uint256 maxSubmissionCost;
(from, maxSubmissionCost, extraData) = _parseOutboundData(data);
// The inboundEscrowAndCall functionality has been disabled, so no data is allowed
require(extraData.length == 0, "EXTRA_DATA_DISABLED");
// Escrowing the tokens in the gateway
IERC20(l1Token).transferFrom(from, address(this), amount);
// We override the res field to save on the stack
res = getOutboundCalldata(l1Token, from, to, amount, extraData);
// Trigger the crosschain message
seqNum = _sendTxToL2CustomRefund(
l2Gateway,
refundTo,
from,
msg.value,
0,
maxSubmissionCost,
maxGas,
gasPriceBid,
res
);
}
emit DepositInitiated(l1Token, from, to, seqNum, amount);
res = abi.encode(seqNum);
}
/// @dev See {ICustomGateway-finalizeInboundTransfer}
function finalizeInboundTransfer(
address l1Token,
address from,
address to,
uint256 amount,
bytes calldata data
) public payable override onlyCounterpartGateway(l2Gateway) {
// Only allow the custom token to be bridged through this gateway
require(l1Token == l1CustomToken, "Token is not allowed through this gateway");
// Decoding exitNum
(uint256 exitNum, ) = abi.decode(data, (uint256, bytes));
// Releasing the tokens in the gateway
IERC20(l1Token).transfer(to, amount);
emit WithdrawalFinalized(l1Token, from, to, exitNum, amount);
}
/// @dev See {ICustomGateway-getOutboundCalldata}
function getOutboundCalldata(
address l1Token,
address from,
address to,
uint256 amount,
bytes memory data
) public pure override returns (bytes memory outboundCalldata) {
bytes memory emptyBytes = "";
outboundCalldata = abi.encodeWithSelector(
ICustomGateway.finalizeInboundTransfer.selector,
l1Token,
from,
to,
amount,
abi.encode(emptyBytes, data)
);
return outboundCalldata;
}
/// @dev See {ICustomGateway-calculateL2TokenAddress}
function calculateL2TokenAddress(address l1Token) public view override returns (address) {
if (l1Token == l1CustomToken) {
return l2CustomToken;
}
return address(0);
}
/// @dev See {ICustomGateway-counterpartGateway}
function counterpartGateway() public view override returns (address) {
return l2Gateway;
}
/**
* Parse data received in outboundTransfer
* @param data encoded data received
* @return from account that initiated the deposit,
* maxSubmissionCost max gas deducted from user's L2 balance to cover base submission fee,
* extraData decoded data
*/
function _parseOutboundData(bytes memory data)
internal
pure
returns (
address from,
uint256 maxSubmissionCost,
bytes memory extraData
)
{
// Router encoded
(from, extraData) = abi.decode(data, (address, bytes));
// User encoded
(maxSubmissionCost, extraData) = abi.decode(extraData, (uint256, bytes));
}
// --------------------
// Custom methods
// --------------------
/**
* Disables the ability to deposit funds
*/
function disableDeposits() external onlyOwner {
allowsDeposits = false;
}
/**
* Enables the ability to deposit funds
*/
function enableDeposits() external onlyOwner {
require(l1CustomToken != address(0), "Token bridge information has not been set yet");
allowsDeposits = true;
}
}
IL1CustomGateway is an interface very similar to ICustomGateway, and L1CrosschainMessenger implements a method to send the cross-chain message to the child chain through the Inbox.
/**
* @title Minimum expected implementation of a crosschain messenger contract to be deployed on L1
*/
abstract contract L1CrosschainMessenger {
IInbox public immutable inbox;
/**
* Emitted when calling sendTxToL2CustomRefund
* @param from account that submitted the retryable ticket
* @param to account recipient of the retryable ticket
* @param seqNum id for the retryable ticket
* @param data data of the retryable ticket
*/
event TxToL2(
address indexed from,
address indexed to,
uint256 indexed seqNum,
bytes data
);
constructor(address inbox_) {
inbox = IInbox(inbox_);
}
modifier onlyCounterpartGateway(address l2Counterpart) {
// A message coming from the counterpart gateway was executed by the bridge
IBridge bridge = inbox.bridge();
require(msg.sender == address(bridge), "NOT_FROM_BRIDGE");
// And the outbox reports that the L2 address of the sender is the counterpart gateway
address l2ToL1Sender = IOutbox(bridge.activeOutbox()).l2ToL1Sender();
require(l2ToL1Sender == l2Counterpart, "ONLY_COUNTERPART_GATEWAY");
_;
}
/**
* Creates the retryable ticket to send over to L2 through the Inbox
* @param to account to be credited with the tokens in the destination layer
* @param refundTo account, or its L2 alias if it have code in L1, to be credited with excess gas refund in L2
* @param user account with rights to cancel the retryable and receive call value refund
* @param l1CallValue callvalue sent in the L1 submission transaction
* @param l2CallValue callvalue for the L2 message
* @param maxSubmissionCost max gas deducted from user's L2 balance to cover base submission fee
* @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 for the retryable
* @return seqnum id for the retryable ticket
*/
function _sendTxToL2CustomRefund(
address to,
address refundTo,
address user,
uint256 l1CallValue,
uint256 l2CallValue,
uint256 maxSubmissionCost,
uint256 maxGas,
uint256 gasPriceBid,
bytes memory data
) internal returns (uint256) {
uint256 seqNum = inbox.createRetryableTicket{ value: l1CallValue }(
to,
l2CallValue,
maxSubmissionCost,
refundTo,
user,
maxGas,
gasPriceBid,
data
);
emit TxToL2(user, to, seqNum, data);
return seqNum;
}
}
We now deploy that gateway to the parent chain.
const { ethers } = require('hardhat');
const { providers, Wallet, BigNumber } = require('ethers');
const { getArbitrumNetwork, ParentToChildMessageStatus } = require('@arbitrum/sdk');
const {
AdminErc20Bridger,
Erc20Bridger,
} = require('@arbitrum/sdk/dist/lib/assetBridger/erc20Bridger');
require('dotenv').config();
/**
* Set up: instantiate L1 / L2 wallets connected to providers
*/
const walletPrivateKey = process.env.DEVNET_PRIVKEY;
const l1Provider = new providers.JsonRpcProvider(process.env.L1RPC);
const l2Provider = new providers.JsonRpcProvider(process.env.L2RPC);
const l1Wallet = new Wallet(walletPrivateKey, l1Provider);
const l2Wallet = new Wallet(walletPrivateKey, l2Provider);
const main = async () => {
/**
* Use l2Network to create an Arbitrum SDK AdminErc20Bridger instance
* We'll use AdminErc20Bridger for its convenience methods around registering tokens to a custom gateway
*/
const l2Network = await getArbitrumNetwork(l2Provider);
const erc20Bridger = new Erc20Bridger(l2Network);
const adminTokenBridger = new AdminErc20Bridger(l2Network);
const l1Router = l2Network.tokenBridge.parentGatewayRouter;
const l2Router = l2Network.tokenBridge.childGatewayRouter;
const inbox = l2Network.ethBridge.inbox;
/**
* Deploy our custom gateway to L1
*/
const L1CustomGateway = await await ethers.getContractFactory('L1CustomGateway', l1Wallet);
console.log('Deploying custom gateway to L1');
const l1CustomGateway = await L1CustomGateway.deploy(l1Router, inbox);
await l1CustomGateway.deployed();
console.log(`Custom gateway is deployed to L1 at ${l1CustomGateway.address}`);
const l1CustomGatewayAddress = l1CustomGateway.address;
};
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});