Skip to main content

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 ICustomToken methods.
  • You'd rather not wrap the token and register the wrapper instead.
  • A child chain counterpart implementing IArbToken is 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 IArbToken is 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 (the ArbSys precompile)
  • Value: 0
  • Function: sendTxToL1(address destination, bytes data)
    • destination: the L1 Timelock address printed by the script
    • data: 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:

  1. The Arbitrum Core governance contract calls ArbSys.sendTxToL1, queuing a message from the child chain to the parent chain.
  2. After the standard withdrawal delay, the L1 outbox executes the message, calling scheduleBatch on the L1 Timelock.
  3. After the timelock's DELAY_SECONDS (3 days) elapses, anyone can call executeBatch, which has the UpgradeExecutor invoke RegisterAndSetArbCustomGatewayAction.perform.
  4. The action contract calls forceRegisterTokenToL2 on the parent chain generic-custom gateway and setGateways on the parent chain gateway router. Each call sends a retryable ticket to the child chain.
  5. Both retryables auto-redeem (they're funded by the MAX_SUBMISSION_FEE constants), 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