Register a custom gateway token via Arbitrum DAO governance
Self-service registration requires the parent chain token to implement ICustomToken and call registerCustomL2Token and setGateway itself. When the parent chain token is non-upgradeable, immutable, or otherwise can't make those calls, registration must go through Arbitrum DAO governance using the privileged forceRegisterTokenToL2 and setGateways paths.
Offchain Labs publishes a standardized calldata template — RegisterAndSetArbCustomGatewayAction — together with a payload generator script. Using the template is a helpful utility, not a requirement, but it reduces manual debugging and gives reviewers a known-safe shape to verify against.
When to use this path
Use this article if:
- Your parent chain token is already deployed and can't be upgraded to add the
ICustomTokenmethods. - You'd rather not wrap the token and register the wrapper instead.
- A child chain counterpart implementing
IArbTokenis already deployed.
If your parent chain token is upgradeable or you're building it from scratch, follow the self-service generic-custom gateway setup instead — it's faster and doesn't require a governance vote.
How the action contract works
RegisterAndSetArbCustomGatewayAction is a one-shot action contract executed by the DAO's UpgradeExecutor via the L1 timelock. It performs the two registration calls in a single privileged transaction:
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.16;
contract RegisterAndSetArbCustomGatewayAction {
IL1AddressRegistry public immutable addressRegistry;
function perform(
address[] memory _l1Tokens,
address[] memory _l2Tokens,
uint256 _maxGasForRegister,
uint256 _gasPriceBidForRegister,
uint256 _maxSubmissionCostForRegister,
uint256 _maxGasForSetGateway,
uint256 _gasPriceBidForSetGateway,
uint256 _maxSubmissionCostForSetGateway
) external payable {
// 1. forceRegisterTokenToL2 on the L1 generic-custom gateway
// 2. setGateways on the L1 gateway router (pointing _l1Tokens at the generic-custom gateway)
}
}
Source: RegisterAndSetArbCustomGatewayAction.sol.
The two calls each emit a retryable ticket from the parent chain to the child chain. Both retryables are auto-redeemed when the action contract supplies enough submission cost, after which the token is fully registered on both chains.
Prerequisites
Before generating a proposal, make sure:
- The parent chain ERC-20 token is already deployed.
- The child chain token contract implementing
IArbTokenis already deployed. - Both addresses are final and immutable — once registered, the mapping cannot be changed.
- You have Foundry installed locally (the payload generator uses
cast).
Step 1: Generate the proposal calldata
Save the following script as reg-arb-custom.sh. Set L1_TOKEN_ADDRESS and L2_TOKEN_ADDRESS to your token's parent and child chain addresses. Leave the other constants alone — they reference live governance contracts on Ethereum and the canonical action address.
#!/usr/bin/env bash
set -euo pipefail
# Token addresses (modify these)
L1_TOKEN_ADDRESS="0x000000000000000000000000000000000000dead"
L2_TOKEN_ADDRESS="0x000000000000000000000000000000000000dead"
# Governance constants (do not modify)
readonly L1_ACTION_ADDRESS="0x997668Ee3C575dC060F80B06db0a8B04C9558969"
readonly L1_UPGRADE_EXECUTOR="0x3ffFbAdAF827559da092217e474760E2b2c3CeDd"
readonly L1_TIMELOCK="0xE6841D92B0C345144506576eC13ECf5103aC7f49"
readonly MAX_SUBMISSION_FEE="0.0005"
readonly TOTAL_VALUE="0.001"
readonly DELAY_SECONDS=259200
L1CALL=$(cast calldata \
"perform(address[],address[],uint256,uint256,uint256,uint256,uint256,uint256)" \
"[$L1_TOKEN_ADDRESS]" \
"[$L2_TOKEN_ADDRESS]" \
0 \
0 \
"$(cast to-wei "$MAX_SUBMISSION_FEE")" \
0 \
0 \
"$(cast to-wei "$MAX_SUBMISSION_FEE")")
L1CALLVALUE=$(cast to-wei "$TOTAL_VALUE")
L2CALL=$(cast calldata \
"execute(address,bytes)" \
"$L1_ACTION_ADDRESS" \
"$L1CALL")
PREDECESSOR=$(cast to-bytes32 0x00)
SALT=$(cast keccak \
"$(cast abi-encode \
"a(uint256[],address[])" \
"[1]" \
"[$L1_ACTION_ADDRESS]")")
FINAL_CALLDATA=$(cast calldata \
"scheduleBatch(address[],uint256[],bytes[],bytes32,bytes32,uint256)" \
"[$L1_UPGRADE_EXECUTOR]" \
"[$L1CALLVALUE]" \
"[$L2CALL]" \
"$PREDECESSOR" \
"$SALT" \
"$DELAY_SECONDS")
echo "===== Proposal ====="
echo "Target Contract: 0x0000000000000000000000000000000000000064"
echo "Value: 0"
echo "arbSysSendTxToL1Args.l1Timelock: " $L1_TIMELOCK
echo "arbSysSendTxToL1Args.calldata:"
echo "$FINAL_CALLDATA"
Run it:
chmod +x reg-arb-custom.sh
./reg-arb-custom.sh
The script prints the four values you'll paste into the Tally proposal: the target contract (ArbSys precompile at 0x...0064), the value (always 0), the L1 Timelock address, and the encoded calldata that schedules the batched call on the L1 timelock.
For a fully worked example, see the BORING token registration payload gist.
Step 2: Submit the Tally proposal
Open a new proposal in Tally targeting the Arbitrum Core governance contract:
- Target contract:
0x0000000000000000000000000000000000000064(theArbSysprecompile) - Value:
0 - Function:
sendTxToL1(address destination, bytes data)destination: the L1 Timelock address printed by the scriptdata: the calldata printed by the script
Before publishing, post a draft on the Arbitrum Foundation forum for community review. Token registrations have historically passed without controversy, but the off-chain feedback step catches encoding mistakes.
Step 3: After the proposal passes
Once the proposal succeeds, the execution sequence is:
- The Arbitrum Core governance contract calls
ArbSys.sendTxToL1, queuing a message from the child chain to the parent chain. - After the standard withdrawal delay, the L1 outbox executes the message, calling
scheduleBatchon the L1 Timelock. - After the timelock's
DELAY_SECONDS(3 days) elapses, anyone can callexecuteBatch, which has theUpgradeExecutorinvokeRegisterAndSetArbCustomGatewayAction.perform. - The action contract calls
forceRegisterTokenToL2on the parent chain generic-custom gateway andsetGatewayson the parent chain gateway router. Each call sends a retryable ticket to the child chain. - Both retryables auto-redeem (they're funded by the
MAX_SUBMISSION_FEEconstants), updating the L2 mappings.
When all retryables are redeemed, the token is registered. Deposits and withdrawals through the generic-custom gateway will work normally from that point on.
Frequently asked questions
What if I get the L2 address wrong in the proposal?
Registration is one-time and irreversible per (parent token) address — forceRegisterTokenToL2 reverts on a second attempt. If a bad mapping is registered, recovery requires a second governance proposal with a new action contract that reregisters via different methods. Validate addresses carefully before submitting.
Why does the proposal target the ArbSys precompile (0x...0064)?
Arbitrum DAO proposals execute on the child chain, but the registration calls happen on the parent chain. ArbSys.sendTxToL1 is the precompile that creates outbound messages from the child chain to the parent chain. The proposal's child chain transaction queues the parent chain call; the L1 Timelock + UpgradeExecutor then dispatch it.
Is the standardized template mandatory?
No. Per the Foundation announcement, the template is a helpful utility, not a requirement. Custom proposals that achieve the same registration result are valid, but reviewers and the Foundation will scrutinize them more closely.
Resources
- Forum announcement: Standardized Token Registrations Template
RegisterAndSetArbCustomGatewayAction.sol(action contract source)- Payload generator gist (script used in Step 1)
- Self-service generic-custom gateway setup
- Token bridging concept