Circle USYC 백서

Circle USYC

원문

bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 32쪽

tion 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> `deployCrossChainToken` is `payable`. No fee is charged on testnet, so you can omit `value`. If you are deploying on mainnet, verify whether a fee applies before submitting. ```typescript theme={null} const deployAbi = [ { name: "deployCrossChainToken", type: "function", stateMutability: "payable", inputs: [ { name: "salt", type: "bytes32" }, { name: "name", type: "string" }, { name: "symbol", type: "string" }, { name: "decimals", type: "uint8" }, { 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 sepoliaDeployHash = await sepoliaWallet.writeContract({ address: CCTS_ADDRESS, abi: deployAbi, functionName: "deployCrossChainToken", args: [ salt, TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS, { tokenManagerType: 0, // NATIVE_CROSSCHAIN_TOKEN tokenManagerOwner: ownerBytes, tokenManagerOperator: operatorBytes, tokenOwner: ownerBytes, tokenMinter: ownerBytes, 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 }, }, ], }); await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaDeployHash }); const sepoliaTokenAddress = await sepoliaPublic.readContract({ address: CCTS_ADDRESS, abi: serviceAbi, functionName: "resolveTokenAddress", args: [tokenId], }); console.log("Deployed on Ethereum Sepolia:", sepoliaDeployHash); console.log("CrossChainToken address:", sepoliaTokenAddress); ``` The `CrossChainTokenIdClaimed` event emits `tokenAddress` as `address(0)` for native deploys—this is expected behavior, not an error. Use `resolveTokenAddress` to read the real token address after the receipt confirms. ## Step 4: Fetch a fee quote for the remote deploy Before calling `deployRemoteCrossChainToken`, fetch a fee quote from Iris. The quote covers destination gas and Circle's message delivery. Pass `DeployTokenMessage` as the message type so Iris prices a deploy operation rather than a transfer. If your deployment wallet has funds on Base Sepolia, you can instead call `deployCrossChainToken` directly on Base Sepolia with the same wallet, salt, and token metadata, then skip to step 7. This avoids the CCTP message entirely and is useful when you have funds available on each blockchain. ```typescript theme={null} const quoteRes = await fetch(`${IRIS_BASE}/v2/quote/cctpx/${tokenId}/0/6`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: "1", feeToken: zeroAddress, requests: [ { type: "FORWARD", params: { msgType: "DeployTokenMessage", autoExecuteHookData: false, }, }, ], }), }); if (!quoteRes.ok) throw new Error(await quoteRes.text()); const { signedQuote, feeTotalAmount } = await quoteRes.json(); const claim = { signedQuote, refundAddress: zeroAddress, }; ``` ## Step 5: Deploy to Base Sepolia Call `deployRemoteCrossChainToken` on Ethereum Sepolia, targeting Base Sepolia (domain 6). Pass the same salt, token metadata, and `DeploymentParams` as in step 3—any mismatch produces a different `tokenId` on the destination. ```typescript theme={null} const remoteDeployAbi = [ { name: "deployRemoteCrossChainToken", type: "function", stateMutability: "payable", inputs: [ { name: "salt", type: "bytes32" }, { name: "destinationDomain", type: "uint32" }, { name: "name", type: "string" }, { name: "symbol", type: "string" }, { name: "decimals", type: "uint8" }, { 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" }, ], }, ], }, { name: "signedFeeQuote", type: "tuple", components: [ { name: "signedQuote", type: "bytes" }, { name: "refundAddress", type: "address" }, ], }, ], outputs: [], }, ] as const; const remoteDeployHash = await sepoliaWallet.writeContract({ address: CCTS_ADDRESS, abi: remoteDeployAbi, functionName: "deployRemoteCrossChainToken", args: [ salt, 6, // Base Sepolia domain TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS, { tokenManagerType: 0, // NATIVE_CROSSCHAIN_TOKEN tokenManagerOwner: ownerBytes, tokenManagerOperator: operatorBytes, tokenOwner: ownerBytes, tokenMinter: ownerBytes, initialSupply: 0n, tokenManagerSettings: { rateLimit: 0n, maxTransferAmount: 0n, }, }, claim, ], value: BigInt(feeTotalAmount), }); await sepoliaPublic.waitForTransactionReceipt({ hash: remoteDeployHash }); console.log("Remote deploy message sent:", remoteDeployHash); ``` ## Step 6: Confirm delivery on Base Sepolia Poll `resolveTokenAddress` on the Base Sepolia service until it returns a non-zero address. Circle typically delivers the message in a few minutes after the Ethereum Sepolia transaction confirms. ```typescript theme={null} const zeroAddr = "0x0000000000000000000000000000000000000000" as `0x${string}`; let baseTokenAddress: `0x${string}` = zeroAddr; while (baseTokenAddress === zeroAddr) { await new Promise((resolve) => setTimeout(resolve, 15_000)); baseTokenAddress = await basePublic.readContract({ address: CCTS_ADDRESS, abi: serviceAbi, functionName: "resolveTokenAddress", args: [tokenId], }); } console.log("CrossChainToken deployed on Base Sepolia:", baseTokenAddress); ``` ## Step 7: Set rate limits Every `TokenManager` deploys with `rateLimit = 0`, which blocks all transfers. 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. ```typescript theme={null} const tokenManagerAbi = [ { name: "setRateLimit", type: "function", stateMutability: "nonpayable", inputs: [{ name: "rateLimit", type: "uint256" }], outputs: [], }, ] as const; 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], }); // 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 blockchains"); ``` ## Step 8: Verify Call `resolveTokenAddress` and `resolveTokenManager` on both blockchains to confirm the `tokenId` resolves correctly. ```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 CrossChainToken:", sepoliaResolved); console.log("Base Sepolia CrossChainToken:", baseResolved); console.log("Sepolia TokenManager:", sepoliaTokenManager); console.log("Base Sepolia TokenManager:", baseTokenManager); ``` Your token is now deployed on both blockchains and ready for crosschain transfers. Before enabling fast transfers, note that fast transfers require additional Circle coordination—FX pricing and backend indexing must be configured by Circle before fast transfers work for newly deployed tokens. Once the token is deployed and rate limits are set, transferring it follows the same pattern as [Transfer EURC from Ethereum to Arc](/cctp/expanded-assets/quickstarts/transfer-eurc-ethereum-to-arc)—swap in your new `tokenId`. # Transfer EURC from Ethereum to Arc Source: https://developers.circle.com/cctp/expanded-assets/quickstarts/transfer-eurc-ethereum-to-arc Send EURC from Ethereum Sepolia to Arc Testnet end to end with CCTP for non-USDC. Approve the source `TokenManager`, fetch a fee quote from Iris, and initiate a fast transfer of EURC from Ethereum Sepolia to Arc Testnet. This route uses the sandbox EURC token ID that Iris lists on domains 0 and 26. The same pattern applies to any configured token—swap in another `tokenId` and destination domain as needed. <Warning> This quickstart uses fast transfer (`minFinalityThreshold = 1000`). Fast transfers require Iris FX pricing to be configured for the token. If Iris returns `FX_SYMBOL_NOT_CONFIGURED`, use standard transfer instead (`minFinalityThreshold = 2000`, omit `PRE_FINALITY` from the quote `requests` array). See step 7 for details. </Warning> ## Prerequisites Before you begin, ensure that you've: * Installed [Node.js v22+](https://nodejs.org/) * Prepared an EVM testnet wallet with the private key available * Added Arc Testnet to your wallet ([network details](https://docs.arc.io/arc/references/connect-to-arc#wallet-setup)) * Funded your wallet with the following testnet tokens: * Sepolia ETH (native token) from a [public faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia) * Sepolia EURC from the [Circle Faucet](https://faucet.circle.com) * Arc Testnet ETH if you plan to submit the destination mint yourself ## Step 1: Set up the project ### 1.1. Create the project and install dependencies ```bash theme={null} # Set up your directory and initialize a Node.js project mkdir cctpx-quickstart cd cctpx-quickstart npm init -y # Set up module type and start command npm pkg set type=module npm pkg set scripts.start="tsx --env-file=.env index.ts" # Install runtime dependencies npm install viem # Install dev dependencies 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_ETHEREUM_SEPOLIA_PRIVATE_KEY ``` * `PRIVATE_KEY` is the private key for the Ethereum Sepolia EOA that signs the source chain approval and transfer transactions. The direct-mint path also uses the same key to submit the destination mint on Arc Testnet. <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 addresses Create `index.ts` and configure clients for Ethereum Sepolia and Arc Testnet. ```typescript TypeScript theme={null} import { createPublicClient, createWalletClient, encodePacked, http, zeroAddress, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { arcTestnet, 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 arcPublic = createPublicClient({ chain: arcTestnet, transport: http(), }); const arcWallet = createWalletClient({ account, chain: arcTestnet, transport: http(), }); const SEPOLIA_CCTS = "0x63753E722bd2C2A5DF6EE19C5106662208B81077" as const; const ARC_CCTS = "0x63753E722bd2C2A5DF6EE19C5106662208B81077" as const; const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as const; const ETHEREUM_DOMAIN = 0; const ARC_DOMAIN = 26; const IRIS_BASE = "https://iris-api-sandbox.circle.com"; // Sandbox EURC — Iris deployments on domains 0 (Sepolia) and 26 (Arc) const tokenId = "0x2587821a0ee7daa174b95436b5dab1731cfa1844775b010217d3c0dd02a4eecd" as `0x${string}`; const recipientOnArc = account.address; ``` The Arc wallet is only required for the direct mint path. For forwarded mint, `arcPublic` is enough to confirm the destination balance. ## Step 3: Fetch token metadata Query the Iris API to retrieve the token's decimal precision. Use the returned `decimals` value to express the transfer amount in the token's smallest unit. EURC uses 6 decimals. ```typescript TypeScript theme={null} type CctpxTokenInfo = { tokenId: string; name: string; symbol: string; decimals: number; }; const tokenInfoRes = await fetch( `${IRIS_BASE}/v2/cctpx/tokens?tokenid=${tokenId}`, ); if (!tokenInfoRes.ok) { throw new Error(await tokenInfoRes.text()); } const { data: tokenList } = (await tokenInfoRes.json()) as { data: CctpxTokenInfo[]; }; const tokenInfo = tokenList[0]; if (!tokenInfo) { throw new Error(`Token ${tokenId} not found`); } const tokenDecimals = tokenInfo.decimals; const transferAmount = 10n ** BigInt(tokenDecimals) / 10n; // 0.1 EURC ``` ## Step 4: Approve the source `TokenManager` Approval goes to the per-token `TokenManager`, not to `CrossChainTokenService`. Read the local `TokenManager` address from the service, then call `approve` on the token. ```typescript TypeScript theme={null} const serviceAbi = [ { 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; const tokenManager = await sepoliaPublic.readContract({ address: SEPOLIA_CCTS, abi: serviceAbi, functionName: "resolveTokenManager", args: [tokenId], }); const tokenAddress = await sepoliaPublic.readContract({ address: SEPOLIA_CCTS, abi: serviceAbi, functionName: "resolveTokenAddress", args: [tokenId], }); const erc20Abi = [ { name: "approve", type: "function", stateMutability: "nonpayable", inputs: [ { name: "spender", type: "address" }, { name: "amount", type: "uint256" }, ], outputs: [{ name: "ok", type: "bool" }], }, { name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "balance", type: "uint256" }], }, ] as const; const approveHash = await sepoliaWallet.writeContract({ address: tokenAddress, abi: erc20Abi, functionName: "approve", args: [tokenManager, transferAmount], }); await sepoliaPublic.waitForTransactionReceipt({ hash: approveHash }); ``` ## Step 5: Check the fast transfer allowance This quickstart uses a [fast transfer](/cctp/expanded-assets/concepts/architecture#transfer-finality-and-fast-burn-allowance) (`minFinalityThreshold = 1000`), which consumes per-token fast burn allowance. Confirm remaining capacity before you quote fees. ```typescript TypeScript theme={null} type CctpxAllowance = { tokenId: string; allowance: number; }; const allowanceRes = await fetch(`${IRIS_BASE}/v2/cctpx/allowances`); if (!allowanceRes.ok) { throw new Error(await allowanceRes.text()); } const allowanceBody = (await allowanceRes.json()) as { allowances: CctpxAllowance[]; }; const allowance = allowanceBody.allowances.find( (item) => item.tokenId.toLowerCase() === tokenId.toLowerCase(), )?.allowance; if ( allowance === undefined || allowance < Number(transferAmount / 10n ** BigInt(tokenDecimals)) ) { throw new Error("Insufficient CCTPx allowance for a fast transfer"); } ``` ## Step 6: Get a fee quote Both paths call the same Iris fee-quote endpoint. Forwarding is selected in the quote `requests` array—not by a different source chain function. Use **Forwarded mint** to have Circle complete the destination mint, or **Direct mint** to submit `receiveMessage` yourself. <Tabs> <Tab title="Forwarded mint"> Include a `FORWARD` request so the quote covers destination mint gas. Pass the Arc Testnet recipient as `destinationAddress` in the forward params. ```typescript TypeScript theme={null} type FeeQuote = { signedQuote: `0x${string}`; feeTotalAmount: string; }; async function fetchFeeQuote(): Promise<FeeQuote> { const response = await fetch( `${IRIS_BASE}/v2/quote/cctpx/${tokenId}/${ETHEREUM_DOMAIN}/${ARC_DOMAIN}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: transferAmount.toString(), feeToken: zeroAddress, requests: [ { type: "PRE_FINALITY" }, { type: "FORWARD", params: { msgType: "TransferMessage", destinationAddress: recipientOnArc, }, }, ], }), }, ); if (!response.ok) { throw new Error(await response.text()); } return response.json(); } const quote = await fetchFeeQuote(); ``` </Tab> <Tab title="Direct mint"> Request pre-finality fees only. You will poll Iris and mint on Arc Testnet in a later step. ```typescript TypeScript theme={null} type FeeQuote = { signedQuote: `0x${string}`; feeTotalAmount: string; }; async function fetchFeeQuote(): Promise<FeeQuote> { const response = await fetch( `${IRIS_BASE}/v2/quote/cctpx/${tokenId}/${ETHEREUM_DOMAIN}/${ARC_DOMAIN}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: transferAmount.toString(), feeToken: zeroAddress, requests: [{ type: "PRE_FINALITY" }], }), }, ); if (!response.ok) { throw new Error(await response.text()); } return response.json(); } const quote = await fetchFeeQuote(); ``` </Tab> </Tabs> ## Step 7: Initiate the transfer Call `crossChainTransfer` on the source service. The onchain call is the same for both completion paths. The example uses `bytes32(0)` for `destinationCaller` to allow permissionless relay, and
프로젝트로 돌아가기