bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 30쪽
Step>
<Step title="Set the rate limit">
Call `setRateLimit` with the maximum absolute net flow (`|flowIn - flowOut|`)
per window in the token's smallest unit.
```typescript TypeScript theme={null}
const rateLimitAbi = [
{
name: "setRateLimit",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "rateLimit", type: "uint256" }],
outputs: [],
},
] as const;
await walletClient.writeContract({
address: tokenManager,
abi: rateLimitAbi,
functionName: "setRateLimit",
args: [5_000_000n * 10n ** 18n],
});
```
</Step>
<Step title="Set the max transfer amount">
Call `setMaxTransferAmount` to cap the size of a single outbound transfer.
```typescript TypeScript theme={null}
const maxTransferAmountAbi = [
{
name: "setMaxTransferAmount",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "maxTransferAmount", type: "uint256" }],
outputs: [],
},
] as const;
await walletClient.writeContract({
address: tokenManager,
abi: maxTransferAmountAbi,
functionName: "setMaxTransferAmount",
args: [500_000n * 10n ** 18n],
});
```
</Step>
<Step title="Set the rate-limit window (optional)">
The default window is six hours. To change it, call `setRateLimitWindow` with
the new window length in seconds.
```typescript TypeScript theme={null}
const rateLimitWindowAbi = [
{
name: "setRateLimitWindow",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "window", type: "uint256" }],
outputs: [],
},
] as const;
await walletClient.writeContract({
address: tokenManager,
abi: rateLimitWindowAbi,
functionName: "setRateLimitWindow",
args: [3600n], // 1 hour
});
```
</Step>
<Step title="Verify the configuration">
Read the current values from the `TokenManager` and compare them with the values
you selected for the preceding configuration steps. If you skipped the optional
window step, compare `rateLimitWindow` with the six-hour default. If `rateLimit`
is still `0`, transfers revert until you call `setRateLimit` with a non-zero
amount (in the token's smallest unit).
```typescript TypeScript theme={null}
const tokenManagerReadAbi = [
{
name: "rateLimit",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "maxTransferAmount",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "rateLimitWindow",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint256" }],
},
] as const;
const [rateLimit, maxTransferAmount, rateLimitWindow] = await Promise.all([
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "rateLimit",
}),
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "maxTransferAmount",
}),
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "rateLimitWindow",
}),
]);
console.log({ rateLimit, maxTransferAmount, rateLimitWindow });
if (rateLimit === 0n) {
throw new Error("rateLimit is 0: token flows are blocked");
}
```
</Step>
</Steps>
# How-to: Manage TokenManager roles
Source: https://developers.circle.com/cctp/expanded-assets/howtos/manage-token-manager-roles
Transfer ownership, replace the operator, and update the pauser on a CCTP for non-USDC TokenManager.
A `TokenManager` is a per-token, per-domain contract that controls crosschain
transfers for a custom token registered on CCTP for non-USDC. Each domain where
your token is active has its own independent `TokenManager`, and three roles
govern it: the owner (highest privilege, can change all roles), the operator
(sets rate limits and transfer caps), and the pauser (can pause and unpause the
token on this domain). You need to manage these roles when rotating keys,
delegating operator duties to a separate address, or responding to a security
incident.
## Prerequisites
Before you begin, ensure that you've:
* Registered a custom token using `registerCustomToken` or
`deployCrossChainToken`, and noted the `tokenId`. See
[Configure a custom token](/cctp/expanded-assets/quickstarts/configure-custom-token)
* Noted the `CrossChainTokenService` address for each domain where you want to
change roles. See
[Contract addresses](/cctp/expanded-assets/references/contract-addresses)
* Verified access to the wallet that currently holds the role you want to
change: the owner wallet for ownership, operator, or pauser changes; the
pauser wallet to call `pause` or `unpause`
## Steps
<Steps>
The snippets assume that `publicClient`, the appropriate wallet clients,
`serviceAddress`, `tokenId`, and target role addresses are already initialized.
Each code example includes only the ABI entries needed for that operation.
Receipt-handling code is omitted for brevity, but you should wait for each
state-changing transaction to confirm before reading the updated state or
submitting the next transaction.
<Step title="Discover the TokenManager address">
Every token has a separate `TokenManager` per domain. Resolve its address from
the `CrossChainTokenService` before making any role change.
```typescript TypeScript theme={null}
const serviceAbi = [
{
name: "resolveTokenManager",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "bytes32" }],
outputs: [{ name: "tokenManager", type: "address" }],
},
] as const;
const tokenManager = await publicClient.readContract({
address: serviceAddress,
abi: serviceAbi,
functionName: "resolveTokenManager",
args: [tokenId],
});
```
Role changes apply only to the local `TokenManager`. Repeat the operation
independently on each domain where the token is deployed.
</Step>
<Step title="Initiate ownership transfer">
The current owner calls `transferOwnership` to initiate. The `TokenManager`
enters a pending state—ownership has not changed yet. Ownership remains with the
current owner until the new owner accepts by calling `acceptOwnership` from
their address.
<Warning>
Transferring ownership to an address you do not control is irreversible. If
the new owner never calls `acceptOwnership`, the current owner retains
control—but there is no cancel mechanism, and the pending transfer remains
indefinitely. Verify the new address carefully before sending the transaction.
</Warning>
```typescript TypeScript theme={null}
const tokenManagerAbi = [
{
name: "transferOwnership",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "newOwner", type: "address" }],
outputs: [],
},
] as const;
const transferHash = await ownerWalletClient.writeContract({
address: tokenManager,
abi: tokenManagerAbi,
functionName: "transferOwnership",
args: [newOwnerAddress],
});
```
</Step>
<Step title="Accept ownership">
The pending owner calls `acceptOwnership`. Ownership changes after this
transaction confirms.
```typescript TypeScript theme={null}
const acceptOwnershipAbi = [
{
name: "acceptOwnership",
type: "function",
stateMutability: "nonpayable",
inputs: [],
outputs: [],
},
] as const;
const acceptHash = await newOwnerWalletClient.writeContract({
address: tokenManager,
abi: acceptOwnershipAbi,
functionName: "acceptOwnership",
args: [],
});
```
</Step>
<Step title="Replace the operator">
Unlike ownership, operator replacement is a single-step operation. The current
owner calls `transferOperatorship` and the change takes effect immediately. No
additional acceptance step is required.
```typescript TypeScript theme={null}
const transferOperatorshipAbi = [
{
name: "transferOperatorship",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "newOperator", type: "address" }],
outputs: [],
},
] as const;
const operatorHash = await ownerWalletClient.writeContract({
address: tokenManager,
abi: transferOperatorshipAbi,
functionName: "transferOperatorship",
args: [newOperatorAddress],
});
```
The operator can call `setRateLimit`, `setMaxTransferAmount`, and
`setRateLimitWindow` on the `TokenManager`. For details on configuring those
values, see
[Configure rate limits and caps](/cctp/expanded-assets/howtos/configure-rate-limits-and-caps).
</Step>
<Step title="Replace the pauser">
The pauser role defaults to the operator address at deployment time. If you
never call `updatePauser`, the operator is also the pauser. The current owner
can replace the pauser by calling `updatePauser`.
```typescript TypeScript theme={null}
const updatePauserAbi = [
{
name: "updatePauser",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "newPauser", type: "address" }],
outputs: [],
},
] as const;
const pauserHash = await ownerWalletClient.writeContract({
address: tokenManager,
abi: updatePauserAbi,
functionName: "updatePauser",
args: [newPauserAddress],
});
```
</Step>
<Step title="Pause and unpause the token">
The pauser can call `pause()` to block all crosschain transfers of this token on
this domain, and `unpause()` to restore them.
```typescript TypeScript theme={null}
const pauseAbi = [
{
name: "pause",
type: "function",
stateMutability: "nonpayable",
inputs: [],
outputs: [],
},
{
name: "unpause",
type: "function",
stateMutability: "nonpayable",
inputs: [],
outputs: [],
},
] as const;
const pauseHash = await pauserWalletClient.writeContract({
address: tokenManager,
abi: pauseAbi,
functionName: "pause",
args: [],
});
const unpauseHash = await pauserWalletClient.writeContract({
address: tokenManager,
abi: pauseAbi,
functionName: "unpause",
args: [],
});
```
</Step>
<Step title="Verify the final state">
After all transactions confirm, verify that `owner()` returns the new owner,
`operator()` returns the new operator, `pauser()` returns the new pauser, and
`paused()` returns `false` after `unpause()`.
```typescript TypeScript theme={null}
const tokenManagerReadAbi = [
{
name: "owner",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
{
name: "operator",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
{
name: "pauser",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
{
name: "paused",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "bool" }],
},
] as const;
const [owner, operator, pauser, paused] = await Promise.all([
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "owner",
}),
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "operator",
}),
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "pauser",
}),
publicClient.readContract({
address: tokenManager,
abi: tokenManagerReadAbi,
functionName: "paused",
}),
]);
if (owner.toLowerCase() !== newOwnerAddress.toLowerCase()) {
throw new Error("Owner was not updated");
}
if (operator.toLowerCase() !== newOperatorAddress.toLowerCase()) {
throw new Error("Operator was not updated");
}
if (pauser.toLowerCase() !== newPauserAddress.toLowerCase()) {
throw new Error("Pauser was not updated");
}
if (paused) {
throw new Error("TokenManager is still paused");
}
```
</Step>
</Steps>
# Configure a custom token
Source: https://developers.circle.com/cctp/expanded-assets/quickstarts/configure-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" },
// WA프로젝트로 돌아가기