> ## Documentation Index
> Fetch the complete documentation index at: https://developers.circle.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Before reading these docs, install the relevant Circle Skill to get pre-loaded context — skills are LLM-optimized and cover architecture decisions, correct flows, and common pitfalls. In Claude Code: run /plugin marketplace add circlefin/skills then /plugin install circle-skills@circle. With Vercel Skills CLI: run npx skills add circlefin/skills. Available skills: use-usdc https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-usdc/SKILL.md (balances, transfers, approvals on EVM and Solana); EURC has no skill — see https://developers.circle.com/stablecoins/what-is-eurc.md; bridge-stablecoin https://github.com/circlefin/skills/blob/master/plugins/circle/skills/bridge-stablecoin/SKILL.md (crosschain transfers via CCTP, USDC, EURC, and third-party assets); use-arc https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-arc/SKILL.md (Arc chain config, contract deployment, USDC bridging — Arc docs: https://docs.arc.io/llms.txt); use-circle-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-circle-wallets/SKILL.md (choose wallet type); use-developer-controlled-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-developer-controlled-wallets/SKILL.md (custodial wallets for payouts and treasury); use-user-controlled-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-user-controlled-wallets/SKILL.md (embedded wallets with Web2 login); use-modular-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-modular-wallets/SKILL.md (smart contract wallets with passkeys and ERC-4337); use-gateway https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-gateway/SKILL.md (unified USDC balance, nanopayments); use-smart-contract-platform https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-smart-contract-platform/SKILL.md (deploy, interact, monitor contracts). Pair any skill with the Circle MCP server for live SDK signatures, contract addresses, and chain IDs: {"mcpServers":{"circle":{"url":"https://api.circle.com/v1/codegen/mcp"}}}.
> Read the product overview first. Each product has an overview explaining when and why to use it. Read it before quickstarts.
> Pick the right wallet type. Developer-controlled (you hold keys), user-controlled (end user holds keys via PIN/social login), or modular (smart contract wallets with passkeys). See https://developers.circle.com/wallets.md.
> Use CCTP V2 for crosschain transfers. CCTP natively transfers USDC and EURC via burn-and-mint, and supports permissionless wrapping of third-party assets via lock-and-mint. V1 is legacy; always use V2 unless V1 is specified or the chain requires it (Noble, Sui, and Aptos still require V1). Migration guide: https://developers.circle.com/cctp/migration-from-v1-to-v2.md.
> Use Bridge Kit for frontend bridging. Wraps CCTP with a simpler SDK for user-facing apps. Use CCTP directly for backend transfers.
> Gas Station vs Paymaster. Gas Station sponsors gas for Circle Wallet transactions. Paymaster lets users pay gas in USDC. Different use cases — read both overviews.
> Gateway Nanopayments for sub-cent payments. Gasless USDC micropayments down to $0.000001 via x402 and batched settlement. For pay-per-request APIs, AI agent payments, streaming.
> Look up USDC addresses per chain. Never hardcode — use https://developers.circle.com/stablecoins/usdc-contract-addresses.md.
> Prefer SDKs over raw API calls. Node.js and Python SDKs handle auth, retries, and errors.
> API key required. Bearer token in Authorization header. Testnet and mainnet use separate keys and may use different base URLs depending on the product.
> Set up webhooks when available. Most operations are async. Webhooks deliver transaction confirmations and state changes.
> When calling list endpoints, paginate using pageSize and pageAfter until no nextPageAfter cursor is returned—stopping at the first page silently misses records.
> Building an AI agent? Start with the Agent Stack—Circle CLI, agent wallets, and nanopayments built for autonomous use cases: https://developers.circle.com/agent-stack.md.

# Configure a custom token

> Configure an existing ERC-20 as a custom BURN_MINT crosschain token on Ethereum Sepolia and Base Sepolia using CCTP for non-USDC.

Configuring an existing ERC-20 as a crosschain token requires calling
`registerCustomToken` on each blockchain where your token is deployed, using the
same `deployer` wallet and the same `bytes32` salt on every call. That pair
deterministically computes your token's crosschain identifier (`tokenId`), which
links your token deployments across blockchains. The resulting connection is
deployer-owned: you set rate limits, max transfer amount, and hold the pauser
role. After configuration, two additional steps are required before any transfer
succeeds: granting the `TokenManager` the minter role on your ERC-20 on each
blockchain, and setting a non-zero rate limit. For denylist behavior on existing
ERC-20 custom configurations, see
[Denylist layers](/cctp/expanded-assets/concepts/denylist-layers).

<Warning>
  Three conditions silently block or break configuration if not met:

  * **Same wallet and salt on every blockchain**: The `tokenId` is derived from
    `(deployer address, salt)`. Using a different wallet or a different salt on
    any blockchain produces a different `tokenId`—no error is thrown, and the
    bridge connection is silently broken.
  * **Rate limit starts at zero**: Every `TokenManager` deploys with
    `rateLimit = 0`, which blocks all transfers. You must call `setRateLimit` with
    a non-zero value on each blockchain before any transfer can proceed.
  * **Minter role required on every blockchain**: The `TokenManager` burns on the
    source blockchain and mints on the destination blockchain. You must grant it
    the minter role on your ERC-20 on each blockchain before transfers can
    succeed.
</Warning>

## Prerequisites

Before you begin, ensure that you've:

* Installed [Node.js v22+](https://nodejs.org/)
* Deployed your ERC-20 token on both Ethereum Sepolia and Base Sepolia, and
  noted both token contract addresses
* Verified that your token uses the same number of decimals on every blockchain
* Prepared an Ethereum-compatible testnet wallet whose private key you have
  available—this is the `deployer` wallet, and it must be the same wallet used
  on every blockchain
* Funded the wallet with Sepolia ETH and Base Sepolia ETH for gas

## Step 1: Set up the project

### 1.1. Create the project and install dependencies

```bash theme={null}
mkdir cctpx-register
cd cctpx-register
npm init -y

npm pkg set type=module
npm pkg set scripts.start="tsx --env-file=.env index.ts"

npm install viem
npm install --save-dev @types/node tsx typescript
```

### 1.2. Configure TypeScript (optional)

<Tip>
  This step is optional. It helps prevent missing types in your IDE or editor.
</Tip>

Create a `tsconfig.json` file:

```shell theme={null}
npx tsc --init
```

Then, update the `tsconfig.json` file:

```shell theme={null}
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF
```

### 1.3. Set environment variables

Open `.env` in your editor and add:

```text theme={null}
PRIVATE_KEY=YOUR_DEPLOYER_PRIVATE_KEY
```

`PRIVATE_KEY` is the private key for the wallet that deploys the `TokenManager`
contracts. You must use this same wallet on every blockchain where you configure
the token.

<Tip>
  Open `.env` in your editor rather than writing values with shell commands, and
  add `.env` to your `.gitignore`. This prevents credentials from leaking into
  your shell history or version control.
</Tip>

The `npm run start` command loads variables from `.env` using Node.js native
env-file support.

<Warning>
  This example uses one or more private keys for local testing. In production,
  use a secure key management solution and never expose or share private keys.
</Warning>

## Step 2: Configure clients and choose a salt

Create `index.ts`. Set up public and wallet clients for both blockchains, then
define a `bytes32` salt. The salt, together with your `deployer` address,
determines your token's `tokenId`—it must be identical on every blockchain where
you configure the token.

```typescript theme={null}
import {
  createPublicClient,
  createWalletClient,
  encodePacked,
  http,
  keccak256,
  toBytes,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia, sepolia } from "viem/chains";

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

const sepoliaPublic = createPublicClient({
  chain: sepolia,
  transport: http(),
});

const sepoliaWallet = createWalletClient({
  account,
  chain: sepolia,
  transport: http(),
});

const basePublic = createPublicClient({
  chain: baseSepolia,
  transport: http(),
});

const baseWallet = createWalletClient({
  account,
  chain: baseSepolia,
  transport: http(),
});

// CrossChainTokenService — same address on all testnets
const CCTS_ADDRESS = "0x63753E722bd2C2A5DF6EE19C5106662208B81077" as const;

// Replace with your ERC-20 addresses on each testnet
const SEPOLIA_TOKEN = "0xYourSepoliaTokenAddress" as `0x${string}`;
const BASE_TOKEN = "0xYourBaseTokenAddress" as `0x${string}`;

// bytes32 salt — must be the same on every blockchain you configure.
// Use any unique bytes32 value; hashing a descriptive string is one approach.
const salt: `0x${string}` = keccak256(toBytes("my-token-v1"));
```

## Step 3: Configure on Ethereum Sepolia

### 3.1. Compute your `tokenId`

The `tokenId` is purely deterministic: it derives from your `deployer` address
and salt using two chained view calls. Computing it before submitting any
transactions lets you note the value and confirm it is identical after Base
Sepolia configuration.

```typescript theme={null}
const serviceAbi = [
  {
    name: "customTokenDeploySalt",
    type: "function",
    stateMutability: "pure",
    inputs: [
      { name: "deployer", type: "address" },
      { name: "salt", type: "bytes32" },
    ],
    outputs: [{ name: "deploySalt", type: "bytes32" }],
  },
  {
    name: "crossChainTokenId",
    type: "function",
    stateMutability: "pure",
    inputs: [
      { name: "deployer", type: "address" },
      { name: "salt", type: "bytes32" },
    ],
    outputs: [{ name: "tokenId", type: "bytes32" }],
  },
  {
    name: "resolveTokenManager",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "tokenId", type: "bytes32" }],
    outputs: [{ name: "tokenManager", type: "address" }],
  },
  {
    name: "resolveTokenAddress",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "tokenId", type: "bytes32" }],
    outputs: [{ name: "token", type: "address" }],
  },
] as const;

// Derive the internal deploy salt from your (deployer, salt) pair
const deploySalt = await sepoliaPublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "customTokenDeploySalt",
  args: [account.address, salt],
});

// Compute the tokenId — pass deploySalt here, not the raw salt
const tokenId = await sepoliaPublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "crossChainTokenId",
  args: [account.address, deploySalt],
});

console.log("Your tokenId:", tokenId);
// Note this value — it will be identical on every blockchain you configure.
```

### 3.2. Call `registerCustomToken`

<Warning>
  **Verify the live ABI before copying this struct.** The sandbox contract's
  `DeploymentParams` may omit `initialSupply` while the production contract
  includes it, or vice versa. A struct with the wrong number of fields causes
  the transaction to revert. Fetch the ABI from the contract source for the
  blockchain you are targeting before submitting, and add or remove
  `initialSupply` accordingly. The following code includes it based on the
  documented production struct.
</Warning>

`registerCustomToken` is `payable`. No configuration fee is charged on testnet,
so you can omit `value` from both calls in this quickstart. If you are
configuring on mainnet, verify whether a fee applies before submitting.

```typescript theme={null}
const registerAbi = [
  {
    name: "registerCustomToken",
    type: "function",
    stateMutability: "payable",
    inputs: [
      { name: "salt", type: "bytes32" },
      { name: "tokenAddress", type: "address" },
      {
        name: "params",
        type: "tuple",
        components: [
          { name: "tokenManagerType", type: "uint8" },
          { name: "tokenManagerOwner", type: "bytes" },
          { name: "tokenManagerOperator", type: "bytes" },
          { name: "tokenOwner", type: "bytes" },
          { name: "tokenMinter", type: "bytes" },
          // WARNING: may be absent in the sandbox ABI — verify before submitting
          { name: "initialSupply", type: "uint256" },
          {
            name: "tokenManagerSettings",
            type: "tuple",
            components: [
              { name: "rateLimit", type: "uint256" },
              { name: "maxTransferAmount", type: "uint256" },
            ],
          },
        ],
      },
    ],
    outputs: [{ name: "tokenId", type: "bytes32" }],
  },
] as const;

// Encode addresses as 20-byte packed bytes.
// Do NOT use encodeAbiParameters, which pads to 32 bytes and is incompatible
// with the contract's address decoding.
const ownerBytes = encodePacked(["address"], [account.address]);
const operatorBytes = encodePacked(["address"], [account.address]);

const sepoliaRegisterHash = await sepoliaWallet.writeContract({
  address: CCTS_ADDRESS,
  abi: registerAbi,
  functionName: "registerCustomToken",
  args: [
    salt,
    SEPOLIA_TOKEN,
    {
      tokenManagerType: 1, // BURN_MINT
      tokenManagerOwner: ownerBytes,
      tokenManagerOperator: operatorBytes,
      tokenOwner: "0x", // omit for an existing ERC-20
      tokenMinter: "0x", // omit for an existing ERC-20
      initialSupply: 0n, // see ABI warning above
      tokenManagerSettings: {
        rateLimit: 0n, // intentionally 0; you set a non-zero value in step 7
        maxTransferAmount: 0n, // 0 = no per-transfer cap; set a non-zero value if you need a ceiling
      },
    },
  ],
});

await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaRegisterHash });
console.log("Registered on Ethereum Sepolia:", sepoliaRegisterHash);
```

## Step 4: Configure on Base Sepolia

Call `registerCustomToken` on Base Sepolia using the same `deployer` wallet and
the same `salt`. Pass the Base Sepolia token address. The `tokenId` is derived
from `(deployer address, salt)`—not from blockchain state—so it is identical to
the one computed in step 3.

```typescript theme={null}
const baseRegisterHash = await baseWallet.writeContract({
  address: CCTS_ADDRESS,
  abi: registerAbi,
  functionName: "registerCustomToken",
  args: [
    salt, // same salt as Ethereum Sepolia — required for matching tokenId
    BASE_TOKEN, // Base Sepolia token address
    {
      tokenManagerType: 1,
      tokenManagerOwner: ownerBytes,
      tokenManagerOperator: operatorBytes,
      tokenOwner: "0x",
      tokenMinter: "0x",
      initialSupply: 0n,
      tokenManagerSettings: {
        rateLimit: 0n,
        maxTransferAmount: 0n, // 0 = no per-transfer cap; set a non-zero value if you need a ceiling
      },
    },
  ],
});

await basePublic.waitForTransactionReceipt({ hash: baseRegisterHash });
console.log("Registered on Base Sepolia:", baseRegisterHash);
console.log("tokenId (same on both chains):", tokenId);
```

## Step 5: Retrieve the TokenManager addresses

Call `resolveTokenManager` on each blockchain to get the deployed `TokenManager`
address. You need these addresses to grant the minter role in the next step and
to call `setRateLimit` in step 7.

```typescript theme={null}
const sepoliaTokenManager = await sepoliaPublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "resolveTokenManager",
  args: [tokenId],
});

const baseTokenManager = await basePublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "resolveTokenManager",
  args: [tokenId],
});

console.log("Sepolia TokenManager:", sepoliaTokenManager);
console.log("Base TokenManager:", baseTokenManager);
```

## Step 6: Grant the minter role

The `TokenManager` burns your token on the source blockchain and mints it on the
destination blockchain. You must grant it the minter role on your ERC-20 on each
blockchain before transfers can succeed.

The exact call depends on your token's access-control interface. For
FiatToken-compatible contracts (such as USDC-style tokens), call
`configureMinter` from the `masterMinter` address. Replace the function name and
ABI with whatever your contract exposes.

```typescript theme={null}
// Example for FiatToken-compatible contracts.
// Replace with the minting authorization method your token exposes.
const fiatTokenMinterAbi = [
  {
    name: "configureMinter",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "minter", type: "address" },
      { name: "minterAllowedAmount", type: "uint256" },
    ],
    outputs: [{ name: "ok", type: "bool" }],
  },
] as const;

const UNLIMITED = 2n ** 256n - 1n;

// Grant minter role on Ethereum Sepolia — caller must be the masterMinter
const sepoliaMinterHash = await sepoliaWallet.writeContract({
  address: SEPOLIA_TOKEN,
  abi: fiatTokenMinterAbi,
  functionName: "configureMinter",
  args: [sepoliaTokenManager, UNLIMITED],
});
await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaMinterHash });

// Grant minter role on Base Sepolia — caller must be the masterMinter
const baseMinterHash = await baseWallet.writeContract({
  address: BASE_TOKEN,
  abi: fiatTokenMinterAbi,
  functionName: "configureMinter",
  args: [baseTokenManager, UNLIMITED],
});
await basePublic.waitForTransactionReceipt({ hash: baseMinterHash });

console.log("Minter role granted on both chains");
```

If your token uses a different access-control pattern (such as OpenZeppelin
`AccessControl` or a custom owner-managed role), call the equivalent grant
function on each deployment.

## Step 7: Set a non-zero rate limit

Every `TokenManager` deploys with `rateLimit = 0`. A zero rate limit blocks all
outbound and inbound transfers for that token on that blockchain. Call
`setRateLimit` on each `TokenManager` with a non-zero value.

Only the operator can call `setRateLimit`. If you encoded your address as the
operator in `DeploymentParams`, you can call it directly. If the operator field
was left empty (`"0x"`), only the service operator can set the rate
limit—contact Circle to adjust it.

```typescript theme={null}
const tokenManagerAbi = [
  {
    name: "setRateLimit",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [{ name: "rateLimit", type: "uint256" }],
    outputs: [],
  },
] as const;

// Replace with an appropriate limit for your token and expected transfer volume.
// This example sets a limit of 1 million tokens assuming 6 decimal places.
const rateLimit = 1_000_000n * 10n ** 6n;

const sepoliaRateLimitHash = await sepoliaWallet.writeContract({
  address: sepoliaTokenManager,
  abi: tokenManagerAbi,
  functionName: "setRateLimit",
  args: [rateLimit],
});
await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaRateLimitHash });

const baseRateLimitHash = await baseWallet.writeContract({
  address: baseTokenManager,
  abi: tokenManagerAbi,
  functionName: "setRateLimit",
  args: [rateLimit],
});
await basePublic.waitForTransactionReceipt({ hash: baseRateLimitHash });

console.log("Rate limits set on both chains");
```

## Step 8: Verify the configuration

Call `resolveTokenAddress` on each blockchain to confirm that the `tokenId`
resolves to your token addresses.

```typescript theme={null}
const sepoliaResolved = await sepoliaPublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "resolveTokenAddress",
  args: [tokenId],
});

const baseResolved = await basePublic.readContract({
  address: CCTS_ADDRESS,
  abi: serviceAbi,
  functionName: "resolveTokenAddress",
  args: [tokenId],
});

console.log("Sepolia resolved token:", sepoliaResolved); // should match SEPOLIA_TOKEN
console.log("Base resolved token:", baseResolved); // should match BASE_TOKEN
```

Both addresses should match the token addresses you configured. Your token is
now configured for crosschain transfers on Ethereum Sepolia and Base Sepolia. To
initiate your first transfer, see
[Transfer EURC from Ethereum to Arc](/cctp/expanded-assets/quickstarts/transfer-eurc-ethereum-to-arc)—the
same pattern applies to your configured token: swap in your `tokenId` and adjust
the source and destination domains as needed.
