How to register a custom gateway token via Arbitrum DAO governance
This document is currently in public preview and may change significantly as feedback is captured from readers like you. Click the Request an update button at the top of this document or join the Arbitrum Discord to share your feedback.
Registering a custom token in Arbitrum's generic-custom gateway usually requires the parent chain token contract to call registerCustomL2Token and setGateway itself. When the parent chain token is non-upgradeable or otherwise can't make those calls, registration must go through Arbitrum DAO governance using the privileged forceRegisterTokenToL2 and setGateways paths.
The Arbitrum Foundation publishes a standardized action contract template — RegisterAndSetArbCustomGatewayAction — that wraps both calls in a single privileged action. Using the template is a helpful utility, not a requirement, but it gives proposals a known-safe shape and gives delegates a familiar artifact to verify.
Audience
This how-to serves two participants in the AIP process:
- Proposers — token issuers preparing an AIP to register their token.
- Delegates and voters — anyone evaluating a token-registration proposal that appears on Tally.
For step-by-step issuer-side implementation (deployment dependencies, deposit flows, post-registration validation), see the companion how-to in the Arbitrum technical docs.
When this template applies
The template is appropriate when all of the following hold:
- The parent chain ERC-20 token is already deployed and cannot be upgraded to add the
ICustomTokenmethods needed for self-service registration. - The child chain token contract — implementing
IArbToken— is already deployed. - The proposer is willing to author and shepherd a Constitutional AIP (this action requires "chain owner" permission).
If the parent chain token can be upgraded, the self-service registration path is faster and doesn't need a DAO vote.
What the action contract does
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;
import "../address-registries/interfaces.sol";
import "./TokenBridgeActionLib.sol";
contract RegisterAndSetArbCustomGatewayAction {
IL1AddressRegistry public immutable addressRegistry;
constructor(IL1AddressRegistry _addressRegistry) {
addressRegistry = _addressRegistry;
}
function perform(
address[] memory _l1Tokens,
address[] memory _l2Tokens,
uint256 _maxGasForRegister,
uint256 _gasPriceBidForRegister,
uint256 _maxSubmissionCostForRegister,
uint256 _maxGasForSetGateway,
uint256 _gasPriceBidForSetGateway,
uint256 _maxSubmissionCostForSetGateway
) external payable {
TokenBridgeActionLib.ensureAllContracts(_l1Tokens);
IL1CustomGateway customGateway = addressRegistry.customGateway();
customGateway.forceRegisterTokenToL2{
value: _maxGasForRegister * _gasPriceBidForRegister + _maxSubmissionCostForRegister
}(
_l1Tokens,
_l2Tokens,
_maxGasForRegister,
_gasPriceBidForRegister,
_maxSubmissionCostForRegister
);
address[] memory gateways = new address[](_l1Tokens.length);
for (uint256 i = 0; i < _l1Tokens.length; i++) {
gateways[i] = address(customGateway);
}
addressRegistry.gatewayRouter().setGateways{
value: _maxGasForSetGateway * _gasPriceBidForSetGateway + _maxSubmissionCostForSetGateway
}(
_l1Tokens,
gateways,
_maxGasForSetGateway,
_gasPriceBidForSetGateway,
_maxSubmissionCostForSetGateway
);
}
}
Source: RegisterAndSetArbCustomGatewayAction.sol.
Each of the two calls emits 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.
How a proposal is constructed
The proposer generates the proposal calldata using Foundry's cast. Save the following as reg-arb-custom.sh, set the L1 and L2 token addresses, and run it:
#!/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"
The script (sourced from the Foundation's reference gist) prints the four values that go into the Tally proposal:
- Target contract:
0x0000000000000000000000000000000000000064— theArbSysprecompile - Value:
0 destination(first argument): the L1 Timelock addressdata(second argument): the encoded calldata that schedules the batched call
For a fully worked example, see the BORING token registration payload gist.
Submitting the proposal
Once the calldata is generated, the rest of the AIP submission process follows the standard flow described in How to submit a DAO proposal:
- Forum post for off-chain discussion
- Snapshot poll for temperature check
- Tally on-chain proposal targeting
ArbSys.sendTxToL1(destination, data)with the values from the script - Standard voting period and timelock delays as described in the AIP lifecycle
On-chain execution after the proposal passes
Once the AIP succeeds on Tally, execution proceeds as follows:
- The Arbitrum Core governance contract calls
ArbSys.sendTxToL1, queuing a parent chain message from the child chain. - After the standard withdrawal delay (~7 days), the L1 outbox executes the message, which calls
scheduleBatchon the L1 Timelock. - After the timelock's 3-day delay (
DELAY_SECONDS = 259200), 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 on both chains and bridging through the generic-custom gateway works normally from that point on.
What delegates should verify
When evaluating a token-registration proposal that uses this template, check the following before voting:
- L1 token address in the calldata corresponds to a real, deployed parent chain token contract for the project named in the proposal.
- L2 token address corresponds to a deployed child chain contract that implements
IArbToken. The companion how-to in the Arbitrum technical docs details the interface requirements. - Action contract address matches
0x997668Ee3C575dC060F80B06db0a8B04C9558969(or the currently published canonical address — confirm against the Foundation announcement). - Upgrade Executor and L1 Timelock match the canonical Arbitrum DAO addresses (
0x3ffFbAdAF827559da092217e474760E2b2c3CeDdand0xE6841D92B0C345144506576eC13ECf5103aC7f49respectively). DELAY_SECONDSis259200(3 days). Lower delays should be flagged.PREDECESSORisbytes32(0)andSALTis computed from the standard scheme (as in the script).- The forum thread has had at least one week of off-chain discussion with no unresolved technical concerns.
Frequently asked questions
What if the L2 token address is wrong in the proposal?
Registration is one-time and irreversible per parent chain token address — forceRegisterTokenToL2 reverts on a second attempt. Recovery requires a second AIP with a new action contract that re-registers via different methods. Validate addresses carefully before voting.
Why does the proposal target the ArbSys precompile (0x...0064)?
Arbitrum DAO proposals execute on the child chain (Arbitrum One), 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 and 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.
Is this a Constitutional or non-Constitutional AIP?
It's Constitutional — the action requires "chain owner" permission to register tokens in the generic-custom gateway. Constitutional AIPs go through the longer 34-day execution path with the full L1 waiting period.