Configure generic-custom gateway bridging
In this how-to, you'll learn how to bridge your own token between Ethereum (parent chain) and Arbitrum (child chain), using Arbitrum's generic-custom gateway. For alternative ways of bridging tokens, check out the token bridging overview.
For other gateway types, see:
- Standard gateway — for standard ERC-20 tokens with no custom logic.
- Custom gateway — for advanced bridge-level customization.
Familiarity with Arbitrum's token bridge system, smart contracts, and decentralized application development is required. If you're new to developing on Arbitrum, consider reviewing the Quickstart: Build an app with Arbitrum before proceeding. We'll use Arbitrum's SDK throughout this how-to, although no prior knowledge is required.
Audience and scope. This guide covers registration on Arbitrum One. The Solidity interfaces, SDK calls, and on-chain mechanics generalize to any Arbitrum chain — including Arbitrum Nova and chains you operate yourself — but the registration fallback for non-upgradeable parent chain tokens differs:
- Arbitrum One or Nova: register through Arbitrum DAO governance, or wrap your token (see Step 1).
- An Arbitrum chain you operate: as the chain owner, call
forceRegisterTokenToL2andsetGatewaysdirectly through your chain'sUpgradeExecutor. No DAO vote is involved. You will also need to register your chain with the SDK viaregisterCustomArbitrumNetworkbefore callinggetArbitrumNetwork.
We'll go through all the steps involved in the process. However, if you want to jump straight to the code, we've created a custom token bridging tutorial script that encapsulates the entire process.
Step 1: Review the prerequisites
As stated in the token bridge conceptual page, there are a few prerequisites to keep in mind while using this method to make a token bridgeable.
First of all, the parent chain counterpart of the token must conform to the ICustomToken interface, meaning that:
- It must have an
isArbitrumEnabledmethod that returns0xb1 - It must have a method that makes an external call to
L1CustomGateway.registerCustomL2Tokenspecifying the address of the child chain contract, and toL1GatewayRouter.setGatewayspecifying the address of the custom gateway. Make these calls only once to configure the gateway.
These methods are needed to register the token via the gateway contract. If your parent chain contract does not include these methods and is not upgradeable, you can register through one of these alternative paths:
- On Arbitrum One or Nova: submit an Arbitrum DAO proposal using the standardized token registrations template. The DAO's
UpgradeExecutorroutes the privilegedforceRegisterTokenToL2andsetGatewayscalls on your behalf. - On an Arbitrum chain you operate: as the chain owner, call
forceRegisterTokenToL2on the parent chain custom gateway andsetGatewayson the parent chain gateway router directly through your chain'sUpgradeExecutor. No DAO vote is required. - Any Arbitrum chain: deploy a new upgradeable wrapper contract that holds your existing token and itself implements
ICustomToken. Register the wrapper instead of the original.
Registration is a one-time event for any given parent chain token address.
Also, the child chain counterpart of the token must conform to the IArbToken interface, meaning that:
- It must have
bridgeMintandbridgeBurnmethods callable only by theL2CustomGatewaycontract. - It must have an
l1Addressview method that returns the token's address on the parent chain.
The interfaces above also place a few constraints on how your token implements standard ERC-20 behavior. Review these before continuing:
If you want your token to be compatible out of the box with all the tooling available (e.g., the Arbitrum bridge), we recommend that you keep the implementation of the IArbToken interface as close as possible to the L2GatewayToken implementation example.
For example, if an allowance check is added to the bridgeBurn() function, the token will not be easily withdrawable through the Arbitrum bridge UI, as the UI does not prompt an approval transaction of tokens by default (it expects the tokens to follow the recommended L2GatewayToken implementation).
Step 2: Create a token and deploy it on the parent chain
We‘ll begin the process by creating and deploying a sample token on the parent chain that we will later bridge. If you already have a token contract on the parent chain, you don’t need to perform this step.
However, you will need to upgrade the contract if it doesn’t include the required methods described in the previous step.
We first create a standard ERC-20 contract using OpenZeppelin’s implementation. We make only one adjustment to that implementation, for simplicity, although it is not required: we specify an initialSupply to be pre-minted and sent to the deployer address upon creation.
We’ll also add the required methods to make our token bridgeable via the generic-custom gateway.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./interfaces/ICustomToken.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title Interface needed to call function registerTokenToL2 of the L1CustomGateway
*/
interface IL1CustomGateway {
function registerTokenToL2(
address _l2Address,
uint256 _maxGas,
uint256 _gasPriceBid,
uint256 _maxSubmissionCost,
address _creditBackAddress
) external payable returns (uint256);
}
/**
* @title Interface needed to call function setGateway of the L2GatewayRouter
*/
interface IL2GatewayRouter {
function setGateway(
address _gateway,
uint256 _maxGas,
uint256 _gasPriceBid,
uint256 _maxSubmissionCost,
address _creditBackAddress
) external payable returns (uint256);
}
contract L1Token is Ownable, ICustomToken, ERC20 {
address private customGatewayAddress;
address private routerAddress;
bool private shouldRegisterGateway;
/**
* @dev See {ERC20-constructor} and {Ownable-constructor}
*
* An initial supply amount is passed, which is preminted to the deployer.
*/
constructor(address _customGatewayAddress, address _routerAddress, uint256 _initialSupply) ERC20("L1CustomToken", "LCT") {
customGatewayAddress = _customGatewayAddress;
routerAddress = _routerAddress;
_mint(msg.sender, _initialSupply * 10 ** decimals());
}
/// @dev we only set shouldRegisterGateway to true when in `registerTokenOnL2`
function isArbitrumEnabled() external view override returns (uint8) {
require(shouldRegisterGateway, "NOT_EXPECTED_CALL");
return uint8(0xb1);
}
/// @dev See {ICustomToken-registerTokenOnL2}
function registerTokenOnL2(
address l2CustomTokenAddress,
uint256 maxSubmissionCostForCustomGateway,
uint256 maxSubmissionCostForRouter,
uint256 maxGasForCustomGateway,
uint256 maxGasForRouter,
uint256 gasPriceBid,
uint256 valueForGateway,
uint256 valueForRouter,
address creditBackAddress
) public override payable onlyOwner {
// we temporarily set `shouldRegisterGateway` to true for the callback in registerTokenToL2 to succeed
bool prev = shouldRegisterGateway;
shouldRegisterGateway = true;
IL1CustomGateway(customGatewayAddress).registerTokenToL2{ value: valueForGateway }(
l2CustomTokenAddress,
maxGasForCustomGateway,
gasPriceBid,
maxSubmissionCostForCustomGateway,
creditBackAddress
);
IL2GatewayRouter(routerAddress).setGateway{ value: valueForRouter }(
customGatewayAddress,
maxGasForRouter,
gasPriceBid,
maxSubmissionCostForRouter,
creditBackAddress
);
shouldRegisterGateway = prev;
}
/// @dev See {ERC20-transferFrom}
function transferFrom(
address sender,
address recipient,
uint256 amount
) public override(ICustomToken, ERC20) returns (bool) {
return super.transferFrom(sender, recipient, amount);
}
/// @dev See {ERC20-balanceOf}
function balanceOf(address account) public view override(ICustomToken, ERC20) returns (uint256) {
return super.balanceOf(account);
}
}
We now deploy that token to the parent chain.
import { ethers } from 'hardhat';
import { providers, Wallet } from 'ethers';
import { getArbitrumNetwork } from '@arbitrum/sdk';
import 'dotenv/config';
const walletPrivateKey = process.env.DEVNET_PRIVKEY;
const parentProvider = new providers.JsonRpcProvider(process.env.PARENT_RPC);
const childProvider = new providers.JsonRpcProvider(process.env.CHILD_RPC);
const parentWallet = new Wallet(walletPrivateKey, parentProvider);
/**
* For the purpose of our tests, here we deploy a standard ERC-20 token (L1Token) to the parent chain.
* It sends its deployer (us) the initial supply of 1000.
*/
const main = async () => {
/**
* Use the Arbitrum network info to get the token bridge addresses needed to deploy the token.
*/
const arbitrumNetwork = await getArbitrumNetwork(childProvider);
const parentCustomGateway = arbitrumNetwork.tokenBridge.l1CustomGateway;
const parentGatewayRouter = arbitrumNetwork.tokenBridge.l1GatewayRouter;
/**
* Deploy our custom token smart contract to the parent chain.
* We give the custom token contract the address of the parent custom gateway and gateway router, plus the initial supply (premine).
*/
console.log('Deploying the test L1Token to the parent chain:');
const L1Token = await (await ethers.getContractFactory('L1Token')).connect(parentWallet);
const parentToken = await L1Token.deploy(parentCustomGateway, parentGatewayRouter, 1000);
await parentToken.deployed();
console.log(`L1Token is deployed to the parent chain at ${parentToken.address}`);
/**
* Get the deployer token balance.
*/
const tokenBalance = await parentToken.balanceOf(parentWallet.address);
console.log(`Initial token balance of deployer: ${tokenBalance}`);
};
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Step 3: Create a token and deploy it on the child chain
We’ll now create and deploy the counterpart of the token we created on the parent chain to the child chain.
We’ll create a standard ERC-20 contract using OpenZeppelin’s implementation, and add the required methods from IArbToken.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "./interfaces/IArbToken.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract L2Token is ERC20, IArbToken {
address public l2Gateway;
address public override l1Address;
modifier onlyL2Gateway() {
require(msg.sender == l2Gateway, "NOT_GATEWAY");
_;
}
constructor(address _l2Gateway, address _l1TokenAddress) ERC20("L2CustomToken", "LCT") {
l2Gateway = _l2Gateway;
l1Address = _l1TokenAddress;
}
/**
* @notice should increase token supply by amount, and should only be callable by the L2Gateway.
*/
function bridgeMint(address account, uint256 amount) external override onlyL2Gateway {
_mint(account, amount);
}
/**
* @notice should decrease token supply by amount, and should only be callable by the L2Gateway.
*/
function bridgeBurn(address account, uint256 amount) external override onlyL2Gateway {
_burn(account, amount);
}
// Add any extra functionality you want your token to have.
}
We now deploy that token to the child chain.
import { ethers } from 'hardhat';
import { providers, Wallet } from 'ethers';
import { getArbitrumNetwork } from '@arbitrum/sdk';
import 'dotenv/config';
const walletPrivateKey = process.env.DEVNET_PRIVKEY;
const childProvider = new providers.JsonRpcProvider(process.env.CHILD_RPC);
const childWallet = new Wallet(walletPrivateKey, childProvider);
const parentTokenAddress = '<address of the parent chain token deployed in the previous step>';
/**
* For the purpose of our tests, here we deploy a standard ERC-20 token (L2Token) to the child chain.
*/
const main = async () => {
/**
* Use the Arbitrum network info to get the token bridge addresses needed to deploy the token.
*/
const arbitrumNetwork = await getArbitrumNetwork(childProvider);
const childCustomGateway = arbitrumNetwork.tokenBridge.childCustomGateway;
/**
* Deploy our custom token smart contract to the child chain.
* We give the custom token contract the address of the child custom gateway, plus the address of its counterpart parent chain token.
*/
console.log('Deploying the test L2Token to the child chain:');
const L2Token = await (await ethers.getContractFactory('L2Token')).connect(childWallet);
const childToken = await L2Token.deploy(childCustomGateway, parentTokenAddress);
await childToken.deployed();
console.log(`L2Token is deployed to the child chain at ${childToken.address}`);
};
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Step 4: Register the custom token with the generic-custom gateway
Once we deploy both our contracts on their respective chains, it’s time to register the token in the generic-custom gateway.
As mentioned earlier, the parent chain token must complete the registration, and we’ve implemented the registerTokenOnL2 function to accomplish this. So now we only need to call that function.
When using this function, you will take two actions:
- Call the function
registerTokenToL2ofL1CustomGateway. This call will change thel1ToL2Tokeninternal mapping it holds and send a retryable ticket to the counterpartL2CustomGatewaycontract on the child chain, setting its mapping to the new values as well. - Call the function
setGatewayofL1GatewayRouter. This call will update thel1TokenToGatewayinternal mapping it holds and send a retryable ticket to the counterpartL2GatewayRoutercontract on the child chain to set its mapping to the new values.
To simplify the process, we’ll use Arbitrum’s SDK. We’ll call the registerCustomToken method of the AdminErc20Bridger class, which will call the registerTokenOnL2 method on the token passed as a parameter.
/**
* Register the custom token on the generic-custom gateway.
*/
const adminTokenBridger = new AdminErc20Bridger(arbitrumNetwork);
const registerTokenTx = await adminTokenBridger.registerCustomToken(parentToken.address, childToken.address, parentWallet, childProvider);
const registerTokenRec = await registerTokenTx.wait();
console.log(`Registering token txn confirmed on the parent chain! 🙌 Receipt: ${registerTokenRec.transactionHash}`);
/**
* The parent chain side is confirmed; now we wait for the child chain side to execute.
* Each parent-to-child message has a unique sequence number; we fetch them from the event logs via a helper.
*/
const parentToChildMsgs = await registerTokenRec.getParentToChildMessages(childProvider);
/**
* A single parent chain transaction can trigger any number of parent-to-child messages.
* `registerTokenOnL2` produces exactly two:
* (1) set the parent chain token to the custom gateway via the router
* (2) set the parent chain token to its child chain counterpart via the generic-custom gateway
* We confirm both are redeemed on the child chain.
*/
expect(parentToChildMsgs.length, 'Should be 2 messages.').to.eq(2);
const setTokenTx = await parentToChildMsgs[0].waitForStatus();
expect(setTokenTx.status, 'Set token not redeemed.').to.eq(ParentToChildMessageStatus.REDEEMED);
const setGateways = await parentToChildMsgs[1].waitForStatus();
expect(setGateways.status, 'Set gateways not redeemed.').to.eq(ParentToChildMessageStatus.REDEEMED);
console.log('Your custom token is now registered on the generic-custom gateway 🥳 Go ahead and make the deposit!');
Verify the registration
Once both retryables have redeemed, confirm the new mappings on both chains. The router and gateway expose view methods that return the registered counterpart for any given token; if they return the zero address, registration didn't land:
import { Contract } from 'ethers';
const routerAbi = ['function l1TokenToGateway(address) view returns (address)'];
const gatewayAbi = ['function l1ToL2Token(address) view returns (address)'];
const parentRouter = new Contract(arbitrumNetwork.tokenBridge.l1GatewayRouter, routerAbi, parentProvider);
const parentGateway = new Contract(arbitrumNetwork.tokenBridge.l1CustomGateway, gatewayAbi, parentProvider);
const childRouter = new Contract(arbitrumNetwork.tokenBridge.l2GatewayRouter, routerAbi, childProvider);
const childGateway = new Contract(arbitrumNetwork.tokenBridge.childCustomGateway, gatewayAbi, childProvider);
console.log('Parent router → gateway: ', await parentRouter.l1TokenToGateway(parentToken.address));
console.log('Parent gateway → child token: ', await parentGateway.l1ToL2Token(parentToken.address));
console.log('Child router → gateway: ', await childRouter.l1TokenToGateway(parentToken.address));
console.log('Child gateway → child token: ', await childGateway.l1ToL2Token(parentToken.address));
If any of these calls returns 0x0000…0000, the corresponding retryable likely failed. Inspect the message status with parentToChildMsgs[i].status() and manually redeem it if needed; see Parent-to-child messaging for the recovery procedure.
Conclusion
Upon completion, the parent and child chain tokens are connected via the generic-custom gateway.
You can bridge tokens between the parent and child chain using the origin parent chain token and the custom token deployed on the child chain, along with the router and gateway contracts from each layer.
For a fully working end-to-end implementation of every step on this page — contract sources, deployment scripts, registration call, and post-registration checks — see the custom-token-bridging tutorial package.
If you want to see an example of bridging a token from the parent to the child chain using Arbitrum's SDK, check out How to bridge tokens via Arbitrum's standard ERC-20 gateway, specifically Steps 2-5.
Frequently asked questions
Can I run the same register token process multiple times for the same parent chain token?
No, you can only register once a child chain token for the same parent chain token. After that, the call to registerTokenToL2 will revert if it runs again.
What can I do if my parent chain token is not upgradable?
It depends on which Arbitrum chain you're targeting:
- Arbitrum One or Nova: registration can be completed through an Arbitrum DAO proposal using the standardized template. See Register a custom gateway token via Arbitrum DAO governance for the full process.
- An Arbitrum chain you operate: as the chain owner, call
forceRegisterTokenToL2on the parent chain custom gateway andsetGatewayson the parent chain gateway router directly through your chain'sUpgradeExecutor. - Any Arbitrum chain: deploy a new upgradeable wrapper contract that holds your existing token and itself implements
ICustomToken, then register the wrapper instead.
One of the retryables didn't redeem. How do I recover?
registerCustomToken produces two parent-to-child retryables (one for the router, one for the gateway). If either fails to auto-redeem — for example, because the supplied gas wasn't enough — the corresponding child chain mapping won't update and the verification calls in Step 4 will return 0x0000…0000. Manually redeem the failed retryable from the child chain by calling redeem() on the message; see Parent-to-child messaging for the procedure. Registration succeeds as soon as both retryables are redeemed.
Can I set up the generic-custom gateway after a standard ERC-20 token has been deployed on the child chain?
Yes, if your token has a standard ERC-20 counterpart on the child chain, you can follow the process outlined on this page to register your custom child chain token. At that moment, your parent chain token will have two counterpart tokens on the child chain, but only your new custom child chain token will be minted when depositing tokens from the parent chain (parent-to-child chain bridging). Both child chain tokens will be withdrawable (child-to-parent chain bridging), so users holding the old standard ERC-20 token will be able to withdraw back to the parent chain (using the L2CustomGateway contract instead of the bridge UI) and then deposit to the child chain to get the new custom child chain tokens.
Next steps
Your token is now configured for bridging! Users can: