Circle USYC 백서

Circle USYC

원문

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

ey = JSON.parse(process.env.SOLANA_PRIVATE_KEY!); const solanaKeypair = await createKeyPairSignerFromBytes( Uint8Array.from(solanaPrivateKey), ); // Solana CCTP Program Addresses (Devnet) const TOKEN_MESSENGER_MINTER_PROGRAM = address( "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", ); const MESSAGE_TRANSMITTER_PROGRAM = address( "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC", ); const USDC_MINT = address("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); const ASSOCIATED_TOKEN_PROGRAM = address( "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", ); // Arc Testnet Configuration const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY!; const ethAccount = privateKeyToAccount(EVM_PRIVATE_KEY as `0x${string}`); const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275"; const arcClient = createWalletClient({ chain: arcTestnet, transport: http(), account: ethAccount, }); // Transfer Parameters const AMOUNT = 1_000_000n; const DESTINATION_DOMAIN = 26; const ARC_DESTINATION_ADDRESS = ethAccount.address; const MAX_FEE = 500n; ``` ## Step 3: Implement the transfer logic The following sections outline the core transfer logic from Solana to Arc. For simplicity, this quickstart uses the same Arc wallet as the recipient and, in the direct-mint path, the wallet that submits `receiveMessage`. In production, these can be different addresses. In the two examples provided, the path diverges at the Solana burn instruction: * **Direct mint** uses `deposit_for_burn`, then retrieves an attestation and calls `receiveMessage` on Arc. * **Forwarding Service** uses `deposit_for_burn_with_hook`, then lets Circle handle the destination-side mint on Arc. <Tabs> <Tab title="Forwarding Service"> ### 3.1. Get forwarding fees and calculate the burn amount Before you burn USDC with the [Forwarding Service](/cctp/concepts/forwarding-service), query the CCTP fee endpoint with `forward=true`. The forwarding fee is dynamic, so fetch it immediately before the transfer. The returned `maxFee` must cover both the CCTP protocol fee and the forwarding fee. ```ts TypeScript theme={null} const FORWARDING_SERVICE_HOOK_DATA = Buffer.from( "636374702d666f72776172640000000000000000000000000000000000000000", "hex", ); async function getForwardingFees() { const response = await fetch( "https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/5/26?forward=true", { method: "GET", headers: { "Content-Type": "application/json" }, }, ); if (!response.ok) { throw new Error(`Failed to fetch fees: ${await response.text()}`); } return response.json(); } async function calculateForwardingAmounts() { const fees = await getForwardingFees(); const feeData = fees.find( (fee: { finalityThreshold: number }) => fee.finalityThreshold === 1000, ); if (!feeData) { throw new Error("Fast-transfer forwarding fees not available"); } const forwardFee = BigInt(feeData.forwardFee.med); const protocolFee = (AMOUNT * BigInt(Math.round(feeData.minimumFee * 100))) / 1_000_000n; const maxFee = forwardFee + protocolFee; const totalAmount = AMOUNT + maxFee; return { maxFee, totalAmount }; } ``` ### 3.2. Burn USDC with the forwarding service hook Use `deposit_for_burn_with_hook` on Solana. The forwarding hook data tells Circle to handle the destination-side `receiveMessage` call on Arc. ```ts TypeScript expandable theme={null} type BurnContext = { senderUsdcAccount: ReturnType<typeof address>; senderAuthorityPda: ReturnType<typeof address>; denylistPda: ReturnType<typeof address>; messageTransmitter: ReturnType<typeof address>; tokenMessenger: ReturnType<typeof address>; remoteTokenMessenger: ReturnType<typeof address>; tokenMinter: ReturnType<typeof address>; localToken: ReturnType<typeof address>; eventAuthority: ReturnType<typeof address>; messageTransmitterEventAuthority: ReturnType<typeof address>; messageSentEventAccount: Awaited<ReturnType<typeof generateKeyPairSigner>>; destAddressBytes32: Buffer; }; async function getBurnContext(): Promise<BurnContext> { const addressEncoder = getAddressEncoder(); const [senderUsdcAccount] = await getProgramDerivedAddress({ programAddress: ASSOCIATED_TOKEN_PROGRAM, seeds: [ addressEncoder.encode(solanaKeypair.address), addressEncoder.encode(TOKEN_PROGRAM_ADDRESS), addressEncoder.encode(USDC_MINT), ], }); const destAddressBytes32 = Buffer.concat([ Buffer.alloc(12), Buffer.from(ARC_DESTINATION_ADDRESS.slice(2), "hex"), ]); const [senderAuthorityPda] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("sender_authority")], }); const [denylistPda] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("denylist_account"), addressEncoder.encode(solanaKeypair.address), ], }); const [messageTransmitter] = await getProgramDerivedAddress({ programAddress: MESSAGE_TRANSMITTER_PROGRAM, seeds: [new TextEncoder().encode("message_transmitter")], }); const [tokenMessenger] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("token_messenger")], }); const [remoteTokenMessenger] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("remote_token_messenger"), new TextEncoder().encode(DESTINATION_DOMAIN.toString()), ], }); const [tokenMinter] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("token_minter")], }); const [localToken] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("local_token"), addressEncoder.encode(USDC_MINT), ], }); const [eventAuthority] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("__event_authority")], }); const [messageTransmitterEventAuthority] = await getProgramDerivedAddress({ programAddress: MESSAGE_TRANSMITTER_PROGRAM, seeds: [new TextEncoder().encode("__event_authority")], }); // Generate the MessageSent event account passed into the burn instruction as // a signer, where the MessageTransmitterV2 program stores the MessageSent event. const messageSentEventAccount = await generateKeyPairSigner(); return { senderUsdcAccount, senderAuthorityPda, denylistPda, messageTransmitter, tokenMessenger, remoteTokenMessenger, tokenMinter, localToken, eventAuthority, messageTransmitterEventAuthority, messageSentEventAccount, destAddressBytes32, }; } async function burnUSDCWithForwarding() { console.log("Burning USDC on Solana with Forwarding Service..."); const { maxFee, totalAmount } = await calculateForwardingAmounts(); const burnContext = await getBurnContext(); const amountBuffer = Buffer.alloc(8); amountBuffer.writeBigUInt64LE(totalAmount); const domainBuffer = Buffer.alloc(4); domainBuffer.writeUInt32LE(DESTINATION_DOMAIN); const maxFeeBuffer = Buffer.alloc(8); maxFeeBuffer.writeBigUInt64LE(maxFee); const finalityBuffer = Buffer.alloc(4); finalityBuffer.writeUInt32LE(1000); const hookLengthBuffer = Buffer.alloc(4); hookLengthBuffer.writeUInt32LE(FORWARDING_SERVICE_HOOK_DATA.length); const instructionData = new Uint8Array( Buffer.concat([ Buffer.from([111, 245, 62, 131, 204, 108, 223, 155]), amountBuffer, domainBuffer, burnContext.destAddressBytes32, Buffer.alloc(32), maxFeeBuffer, finalityBuffer, hookLengthBuffer, FORWARDING_SERVICE_HOOK_DATA, ]), ); const depositForBurnIx = { programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, accounts: [ { address: solanaKeypair.address, role: 3, signer: solanaKeypair }, { address: solanaKeypair.address, role: 3, signer: solanaKeypair }, { address: burnContext.senderAuthorityPda, role: 0 }, { address: burnContext.senderUsdcAccount, role: 1 }, { address: burnContext.denylistPda, role: 0 }, { address: burnContext.messageTransmitter, role: 1 }, { address: burnContext.tokenMessenger, role: 0 }, { address: burnContext.remoteTokenMessenger, role: 0 }, { address: burnContext.tokenMinter, role: 0 }, { address: burnContext.localToken, role: 1 }, { address: USDC_MINT, role: 1 }, { address: burnContext.messageSentEventAccount.address, role: 3, signer: burnContext.messageSentEventAccount, }, { address: MESSAGE_TRANSMITTER_PROGRAM, role: 0 }, { address: TOKEN_MESSENGER_MINTER_PROGRAM, role: 0 }, { address: TOKEN_PROGRAM_ADDRESS, role: 0 }, { address: SYSTEM_PROGRAM_ADDRESS, role: 0 }, { address: burnContext.eventAuthority, role: 0 }, { address: TOKEN_MESSENGER_MINTER_PROGRAM, role: 0 }, { address: burnContext.messageTransmitterEventAuthority, role: 0 }, { address: MESSAGE_TRANSMITTER_PROGRAM, role: 0 }, ], data: instructionData, }; const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); const transactionMessage = pipe( createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(solanaKeypair, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstruction(depositForBurnIx, tx), ); const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions, }); await sendAndConfirmTransaction(signedTransaction as any, { commitment: "confirmed", }); const signature = getSignatureFromTransaction(signedTransaction); console.log(`Burn transaction signature: ${signature}`); return signature; } ``` ### 3.3. Verify the forwarded mint After the burn is confirmed, poll the Iris API until it returns a `forwardTxHash`. That hash is the Arc destination mint transaction submitted by Circle. In the forwarding path, `forwardTxHash` is the completion signal for the destination-side mint. You do not need to retrieve an attestation and call `receiveMessage` yourself. ```ts TypeScript theme={null} async function waitForForwardedMint(transactionSignature: string) { console.log("Waiting for Forwarding Service to mint on Arc..."); while (true) { const response = await fetch( `https://iris-api-sandbox.circle.com/v2/messages/5?transactionHash=${transactionSignature}`, { method: "GET" }, ); if (!response.ok) { await new Promise((resolve) => setTimeout(resolve, 5000)); continue; } const data = await response.json(); const forwardTxHash = data?.messages?.[0]?.forwardTxHash; if (forwardTxHash) { console.log(`Forwarded Mint Tx: ${forwardTxHash}`); return forwardTxHash; } await new Promise((resolve) => setTimeout(resolve, 5000)); } } ``` </Tab> <Tab title="Direct mint"> ### 3.1. Burn USDC on Solana Call the `depositForBurn` instruction from the `TokenMessengerMinterV2` program to burn USDC on Solana: ```ts TypeScript expandable theme={null} const DIRECT_MINT_DISCRIMINATOR = Buffer.from([ 215, 60, 61, 46, 114, 55, 128, 176, ]); async function burnUSDCOnSolana() { console.log("Burning USDC on Solana..."); const addressEncoder = getAddressEncoder(); // Get the sender's USDC token account (Associated Token Account PDA) const [senderUsdcAccount] = await getProgramDerivedAddress({ programAddress: ASSOCIATED_TOKEN_PROGRAM, seeds: [ addressEncoder.encode(solanaKeypair.address), addressEncoder.encode(TOKEN_PROGRAM_ADDRESS), addressEncoder.encode(USDC_MINT), ], }); const destAddressBytes32 = Buffer.concat([ Buffer.alloc(12), Buffer.from(ARC_DESTINATION_ADDRESS.slice(2), "hex"), ]); // Derive PDAs (Program Derived Addresses) const [senderAuthorityPda] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("sender_authority")], }); const [denylistPda] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("denylist_account"), addressEncoder.encode(solanaKeypair.address), ], }); const [messageTransmitter] = await getProgramDerivedAddress({ programAddress: MESSAGE_TRANSMITTER_PROGRAM, seeds: [new TextEncoder().encode("message_transmitter")], }); const [tokenMessenger] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("token_messenger")], }); // NOTE: Domain is converted to string for PDA derivation in V2 const [remoteTokenMessenger] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("remote_token_messenger"), new TextEncoder().encode(DESTINATION_DOMAIN.toString()), ], }); const [tokenMinter] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("token_minter")], }); const [localToken] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [ new TextEncoder().encode("local_token"), addressEncoder.encode(USDC_MINT), ], }); // Derive event authority PDAs for Anchor CPI events const [eventAuthority] = await getProgramDerivedAddress({ programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, seeds: [new TextEncoder().encode("__event_authority")], }); const [messageTransmitterEventAuthority] = await getProgramDerivedAddress({ programAddress: MESSAGE_TRANSMITTER_PROGRAM, seeds: [new TextEncoder().encode("__event_authority")], }); // Generate the MessageSent event account passed into the burn instruction as // a signer, where the MessageTransmitterV2 program stores the MessageSent event. const messageSentEventAccount = await generateKeyPairSigner(); const amountBuffer = Buffer.alloc(8); amountBuffer.writeBigUInt64LE(AMOUNT); const domainBuffer = Buffer.alloc(4); domainBuffer.writeUInt32LE(DESTINATION_DOMAIN); const maxFeeBuffer = Buffer.alloc(8); maxFeeBuffer.writeBigUInt64LE(MAX_FEE); const finalityBuffer = Buffer.alloc(4); finalityBuffer.writeUInt32LE(1000); const instructionData = new Uint8Array( Buffer.concat([ DIRECT_MINT_DISCRIMINATOR, amountBuffer, domainBuffer, destAddressBytes32, Buffer.alloc(32), maxFeeBuffer, finalityBuffer, ]), ); const depositForBurnIx = { programAddress: TOKEN_MESSENGER_MINTER_PROGRAM, accounts: [ { address: solanaKeypair.address, role: 3, signer: solanaKeypair }, { address: solanaKeypair.address, role: 3, signer: solanaKeypair }, { address: senderAuthorityPda, role: 0 }, { address: senderUsdcAccount, role: 1 }, { address: denylistPda, role: 0 }, { address: messageTransmitter, role: 1 }, { address: tokenMessenger, role: 0 }, { address: remoteTokenMessenger, role: 0 }, { address: tokenMinter, role: 0 }, { address: localToken, role: 1 }, { address: USDC_MINT, role: 1 }, { address: messageSentEventAccount.address, role: 3, signer: messageSentEventAccount, }, { address: MESSAGE_TRANSMITTER_PROGRAM, role: 0 }, { address: TOKEN_MESSENGER_MINTER_PROGRAM, role: 0 }, { address: TOKEN_PROGRAM_ADDRESS, role: 0 }, { address: SYSTEM_PROGRAM_ADDRESS, role: 0 }, { address: eventAuthority, role: 0 }, { address: TOKEN_MESSENGER_MINTER_PROGRAM, role: 0 }, { address: messageTransmitterEventAuthority, role: 0 }, { address: MESSAGE_TRANSMITTER_PROGRAM, role: 0 }, ], data: instructionData, }; const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); const transactionMessage = pipe( createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(solanaKeypair, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstruction(depositForBurnIx, tx), ); const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions, }); await sendAndConfirmTransaction(signedTransaction as any, { commitment: "confirmed", }); const signature = getSignatureFromTransaction(signedTransaction); console.log(`Burn transaction signature: ${signature}`); return signature; } ``` ### 3.2. Retrieve attestation Retrieve the attestation required to complete the CCTP transfer by calling Circle's attestation API: ```ts TypeScript theme={null} async function retrieveAttestation(transactionSignature: string) { console.log("Retrieving attestation..."); const url = `https://iris-api-sandbox.circle.com/v2/messages/5?transactionHash=${transactionSignature}`; while (true) { try { const response = await fetch(url, { method: "GET" }); if (!response.ok) { if (response.status !== 404) { const text = await response.text().catch(() => ""); console.error( "Error fetching attestation:", `${response.status} ${response.statusText}${ text ? ` - ${text}` : "" }`, ); } await new Promise((resolve) => setTimeout(resolve, 5000)); continue; } const data = (await response.json()) as AttestationResponse; if (data?.messages?.[0]?.status === "complete") { console.log("Attestation retrieved successfully!"); return data.messages[0]; } console.log("Waiting for attestation..."); await new Promise((resolve) => setTimeout(resolve, 5000)); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Error fetching attestation:", message); await new Promise((resolve) => setTimeout(resolve, 5000)); } } } ``` ### 3.3. Mint USDC on Arc Testnet Call the `receiveMessage` function from the `MessageTransmitterV2` contract on Arc Testnet to mint USDC: ```ts TypeScript theme={null} async function mintUSDCOnArc(attestation: AttestationMessage) { console.log("Minting USDC on Arc testnet..."); const mintTx = await arcClient.sendTransaction({ to: ARC_MESSAGE_TRANSMITTER, data: encodeFunctionData({ abi: [ { type: "function", name: "receiveMessage", stateMutability: "nonpayable", inputs: [ { name: "message", type: "bytes" }, { name: "attestation", type: "bytes" }, ], outputs: [], }, ], functionName: "receiveMessage", args: [ attestation.message as `0x${string}`, attestation.attestation as `0x${string}`, ], }), }); console.log(`Mint transaction hash: ${mintTx}`); } ``` </Tab> </Tabs> ## Step 4: Complete script Create a `index.ts` file in your project directory and populate it with the complete code below for the path you want to test. <Tabs> <Tab title="Forwarding Service"> ```ts index.ts expandable theme={null} import { address, createKeyPairSignerFromBytes, createSolanaRpc, createSolanaRpcSubscriptions, createTransactionMessage, generateKeyPairSigner, getAddressEncoder, getProgramDerivedAddress, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstruction, signTransactionMessageWithSigners, } from "@solana/kit"; import { SYSTEM_PROGRAM_ADDRESS } from "@solana-program/system"; import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; import { privateKeyToAccount } from "viem/accounts"; type FeeQuote = { finalityThreshold: number; minimumFee: number; forwardFee: { med: number }; }; type BurnContext = { senderUsdcAccount: ReturnType<typeof address>; senderAuthorityPda: ReturnType<typeof address>; denylistPda: ReturnType<typeof address>; messageTransmitter: ReturnType<typeof address>; tokenMessenger: ReturnType<typeof address>; remoteTokenMessenger: ReturnType<typeof address>; tokenMinter: ReturnType<typeof address>; localToken: ReturnType<typeof address>; eventAuthority: ReturnType<typeof address>; messageTransmitterEventAuthority: ReturnType<typeof address>; messageSentEventAccount: Awaited<ReturnType<typeof generateKeyPairSigner>>; destAddressBytes32: Buffer; }; const SOLANA_RPC = "https://api.devnet.solana.com"; const SOLANA_WS = "wss://api.devnet.solana.com"; const rpc = createSolanaRpc(SOLANA_RPC); const
프로젝트로 돌아가기