Circle USYC 백서

Circle USYC

원문

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
프로젝트로 돌아가기