bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 43쪽
Step 4: Full script
Create an `index.ts` file in your project directory and paste the full script
following. Keep `receive_message.mv` in the `precompiled-move-scripts` directory
next to it.
```ts index.ts expandable theme={null}
import {
Account,
Aptos,
AptosConfig,
Ed25519PrivateKey,
MoveVector,
Network,
} from "@aptos-labs/ts-sdk";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
type Attestation = {
message: string;
attestation: string;
};
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const aptosPrivateKey = process.env.APTOS_PRIVATE_KEY;
if (!evmPrivateKey || !aptosPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and APTOS_PRIVATE_KEY in .env");
}
const APTOS_DOMAIN = 9;
const ARC_TESTNET_DOMAIN = 26;
const ARC_USDC_ADDRESS =
"0x3600000000000000000000000000000000000000" as Address;
const TOKEN_MESSENGER_V2_ADDRESS =
"0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;
const BYTECODE_PATH = fileURLToPath(
new URL("./precompiled-move-scripts/receive_message.mv", import.meta.url),
);
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const FAST_TRANSFER = process.env.FAST_TRANSFER === "true";
const MIN_FINALITY_THRESHOLD = FAST_TRANSFER ? 1_000 : 2_000;
const MAX_FEE = FAST_TRANSFER ? BigInt(process.env.FAST_MAX_FEE ?? "100") : 0n;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const aptosClient = new Aptos(new AptosConfig({ network: Network.TESTNET }));
const aptosAccount = Account.fromPrivateKey({
privateKey: new Ed25519PrivateKey(aptosPrivateKey),
});
const usdcAbi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
]);
const tokenMessengerV2Abi = parseAbi([
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)",
]);
async function approveUSDC() {
console.log("Approving Arc USDC transfer...");
const approvalHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_USDC_ADDRESS,
abi: usdcAbi,
functionName: "approve",
args: [TOKEN_MESSENGER_V2_ADDRESS, AMOUNT],
});
const approvalReceipt = await publicClient.waitForTransactionReceipt({
hash: approvalHash,
});
if (approvalReceipt.status !== "success") {
throw new Error(`USDC approval reverted: ${approvalHash}`);
}
console.log(`Approval confirmed: ${approvalHash}`);
}
async function burnUSDC() {
const transferMode = FAST_TRANSFER ? "Fast" : "Standard";
console.log(
`Burning ${Number(AMOUNT) / 1_000_000} USDC on Arc (${transferMode} Transfer)...`,
);
const mintRecipient = aptosAccount.accountAddress.toString() as Hex;
const destinationCaller = `0x${"0".repeat(64)}` as Hex;
const burnHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: TOKEN_MESSENGER_V2_ADDRESS,
abi: tokenMessengerV2Abi,
functionName: "depositForBurn",
args: [
AMOUNT,
APTOS_DOMAIN,
mintRecipient,
ARC_USDC_ADDRESS,
destinationCaller,
MAX_FEE,
MIN_FINALITY_THRESHOLD,
],
});
const burnReceipt = await publicClient.waitForTransactionReceipt({
hash: burnHash,
});
if (burnReceipt.status !== "success") {
throw new Error(`Deposit-for-burn reverted: ${burnHash}`);
}
console.log(`Deposit-for-burn confirmed: ${burnHash}`);
return burnHash;
}
async function retrieveAttestation(
transactionHash: string,
): Promise<Attestation> {
const url = `${IRIS_API_URL}/v2/messages/${ARC_TESTNET_DOMAIN}?transactionHash=${transactionHash}`;
console.log(`Polling Iris V2 for attestation: ${url}`);
while (true) {
const response = await fetch(url);
if (!response.ok && response.status !== 404) {
throw new Error(`Iris V2 returned HTTP ${response.status}`);
}
if (response.ok) {
const data = (await response.json()) as {
messages?: Array<{ message?: string; attestation?: string }>;
};
const result = data.messages?.[0];
if (
result?.message &&
result.attestation &&
result.attestation !== "PENDING"
) {
return {
message: result.message,
attestation: result.attestation,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
async function receiveMessage({ message, attestation }: Attestation) {
console.log("Receiving the message on Aptos...");
const bytecode = new Uint8Array(await readFile(BYTECODE_PATH));
const transaction = await aptosClient.transaction.build.simple({
sender: aptosAccount.accountAddress,
data: {
bytecode,
functionArguments: [
MoveVector.U8(Buffer.from(message.replace("0x", ""), "hex")),
MoveVector.U8(Buffer.from(attestation.replace("0x", ""), "hex")),
],
},
});
const pendingTransaction = await aptosClient.signAndSubmitTransaction({
signer: aptosAccount,
transaction,
});
const receiveTransaction = await aptosClient.waitForTransaction({
transactionHash: pendingTransaction.hash,
});
console.log(
`Receive-message confirmed: https://explorer.aptoslabs.com/txn/${receiveTransaction.hash}`,
);
return receiveTransaction.hash;
}
export default async function main() {
await approveUSDC();
const burnTransactionHash = await burnUSDC();
const attestation = await retrieveAttestation(burnTransactionHash);
console.log("Attestation received from Iris V2.");
const receiveTransactionHash = await receiveMessage(attestation);
console.log("USDC transfer completed.");
return {
evmTransactionHash: burnTransactionHash,
aptosTransactionHash: receiveTransactionHash,
};
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log("Starting CCTP flow...");
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
```
## Step 5: Test the script
Run the following command to execute the script:
```shell Shell theme={null}
npm run start
```
When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:
```bash Shell theme={null}
Starting CCTP flow...
Approving Arc USDC transfer...
Approval confirmed: 0x...
Burning 1 USDC on Arc (Fast Transfer)...
Deposit-for-burn confirmed: 0x...
Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/26?transactionHash=<arc-transaction-hash>
Attestation received from Iris V2.
Receiving the message on Aptos...
Receive-message confirmed: https://explorer.aptoslabs.com/txn/<aptos-transaction-hash>
USDC transfer completed.
```
Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 2 seconds with no
timeout, so allow the process to continue while Iris prepares the attestation.
<Note>
**Rate limit:** The attestation service rate limit is 40 requests per second. If
you exceed this limit, the service blocks all API requests for the next five
minutes and returns an HTTP 429 (Too Many Requests) response.
</Note>
</Tab>
</Tabs>
# Transfer USDC from Ethereum to Arc
Source: https://developers.circle.com/cctp/quickstarts/transfer-usdc-ethereum-to-arc
Build a script to transfer USDC between EVM blockchains using CCTP
This guide demonstrates how to transfer USDC from Ethereum Sepolia to Arc
testnet using CCTP. You use the [viem](https://viem.sh/) framework to interact
with [CCTP contracts](/cctp/references/contract-addresses) and the
[CCTP API](/api-reference/cctp/all/get-messages-v2) to retrieve attestations.
<Tip>
**Use [Bridge Kit](https://www.npmjs.com/package/@circle-fin/bridge-kit) to
simplify crosschain transfers with CCTP.**
This quickstart shows how to transfer USDC from to
 using a manual CCTP integration. The example is for learning
or for developers who need a manual integration.
To streamline this, use Bridge Kit to transfer USDC in just a few lines of code.
</Tip>
## Prerequisites
Before you begin, ensure that you've:
* Installed [Node.js v22.18+](https://nodejs.org/)
* Prepared an EVM testnet wallet with the private key available
* Added Arc testnet network 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 USDC from the [Circle Faucet](https://faucet.circle.com)
* Arc testnet USDC from the [Circle Faucet](https://faucet.circle.com) if you
choose the direct mint path below, because the destination wallet must pay
gas to call `receiveMessage`
## Step 1. Set up the project
### 1.1. Create the project and install dependencies
```shell theme={null}
# Set up your directory and initialize a Node.js project
mkdir cctp-evm-transfer
cd cctp-evm-transfer
npm init -y
# Set up module type and start command
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env index.ts"
# Install runtime dependencies
npm install viem
# Install dev dependencies
npm install --save-dev @types/node 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 burn transactions. The direct-mint path also uses
the same key to submit the destination mint on Arc.
<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 the script
This section covers the necessary setup for the transfer script, including
defining keys and addresses, and configuring the wallet client for interacting
with the source and destination chains.
### 2.1. Define configuration constants
The script predefines the contract addresses, transfer amount, and maximum fee.
Update the `DESTINATION_ADDRESS` with your wallet address.
For simplicity, this quickstart uses the same EOA as the Ethereum Sepolia source
signer and the Arc recipient. In production, these can be different addresses.
```ts TypeScript theme={null}
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238";
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
"0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa";
const ARC_TESTNET_MESSAGE_TRANSMITTER =
"0xe737e5cebeeba77efe34d4aa090756590b1ce275";
// Transfer Parameters
const DESTINATION_ADDRESS = account.address; // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n; // 1 USDC (1 USDC = 1,000,000 subunits)
const maxFee = 500n; // 0.0005 USDC (500 subunits)
// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(
2,
)}`; // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
"0x0000000000000000000000000000000000000000000000000000000000000000"; // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()
// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0; // Source domain ID for Ethereum Sepolia
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc testnet
```
### 2.2. Set up wallet clients
The wallet client configures the appropriate network settings using `viem`. The
direct-mint path below uses clients for both Ethereum Sepolia and Arc testnet.
The [Forwarding Service](/cctp/concepts/forwarding-service) path only needs the
source-chain client on Ethereum Sepolia.
```ts TypeScript theme={null}
// Set up the wallet clients
const sepoliaClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
});
const arcClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account,
});
```
## Step 3: Implement the transfer logic
The following sections outline the core transfer logic.
The path diverges at the source-chain burn transaction:
* **Direct mint** uses `depositForBurn`, then retrieves an attestation and calls
`receiveMessage` on Arc.
* **Forwarding Service** uses `depositForBurnWithHook`, 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, 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 =
"0x636374702d666f72776172640000000000000000000000000000000000000000" as `0x${string}`;
async function getForwardingFees() {
const response = await fetch(
`https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/${ETHEREUM_SEPOLIA_DOMAIN}/${ARC_TESTNET_DOMAIN}?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. Approve the total burn amount
Approve the total amount you will burn on the source chain. For the forwarding
path, that is the transfer amount plus the forwarding and protocol fees.
```ts TypeScript theme={null}
async function approveUSDC(amount: bigint) {
console.log("Approving USDC transfer...");
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
],
functionName: "approve",
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, amount],
}),
});
console.log(`USDC Approval Tx: ${approveTx}`);
}
```
### 3.3. Burn USDC with the Forwarding Service hook
Use `depositForBurnWithHook` on the source chain. The forwarding hook data tells
Circle to handle the destination-side `receiveMessage` call on Arc.
```ts TypeScript theme={null}
async function burnUSDCWithForwarding(totalAmount: bigint, maxFee: bigint) {
console.log("Burning USDC on Ethereum Sepolia with Forwarding Service...");
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurnWithHook",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
{ name: "hookData", type: "bytes" },
],
outputs: [],
},
],
functionName: "depositForBurnWithHook",
args: [
totalAmount,
ARC_TESTNET_DOMAIN,
DESTINATION_ADDRESS_BYTES32,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000,
FORWARDING_SERVICE_HOOK_DATA,
],
}),
});
console.log(`Burn Tx: ${burnTx}`);
return burnTx;
}
```
### 3.4. 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(transactionHash: 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/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`,
{ 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. Approve USDC
Grant approval for the
[`TokenMessengerV2` contract](/cctp/references/contract-addresses) deployed on
Ethereum Sepolia to withdraw USDC from your wallet. This allows the contract to
burn USDC when you initiate the transfer.
```ts TypeScript theme={null}
async function approveUSDC() {
console.log("Approving USDC transfer...");
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
],
functionName: "approve",
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000n], // 10 USDC allowance
}),
});
console.log(`USDC Approval Tx: ${approveTx}`);
}
```
### 3.2. Burn USDC
Call the `depositForBurn` function from the
[`TokenMessengerV2` contract](/cctp/references/contract-interfaces#depositforburn)
deployed on Ethereum Sepolia to burn USDC on that source chain. You specify the
following parameters:
* **Burn amount**: The amount of USDC to burn
* **Destination domain**: The target blockchain for minting USDC (see
[supported chains and domains](/cctp/concepts/supported-chains-and-domains))
* **Mint recipient**: The wallet address that will receive the minted USDC
* **Burn token**: The contract address of the USDC token being burned on the
source chain
* **Destination caller**: The address on the target chain to call
`receiveMessage`
* **Max fee**: The maximum [fee](/cctp/concepts/fees) allowed for the transfer
* **Finality t프로젝트로 돌아가기