> ## 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](https://developers.circle.com/cctp/expanded-assets/concepts/denylist-layers.md).

> [!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.

## 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.

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.

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.

## 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.

`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](https://developers.circle.com/cctp/expanded-assets/quickstarts/transfer-eurc-ethereum-to-arc.md)—the
same pattern applies to your configured token: swap in your `tokenId` and adjust
the source and destination domains as needed.
