bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 51쪽
n Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const messageBytes = Buffer.from(
attestation.message.replace("0x", ""),
"hex",
);
const attestationBytes = Buffer.from(
attestation.attestation.replace("0x", ""),
"hex",
);
const txHash = await submitSorobanTx(
server,
STELLAR_CCTP_FORWARDER,
"mint_and_forward",
[xdr.ScVal.scvBytes(messageBytes), xdr.ScVal.scvBytes(attestationBytes)],
);
console.log(`mint_and_forward Tx: ${txHash}`);
}
```
## Step 4: Full script
Create an `index.ts` file in your project directory and paste the full script
below so you can run the flow from one file.
```ts index.ts expandable theme={null}
import {
Contract,
Keypair,
StrKey,
rpc,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// ============ Configuration Constants ============
// Contract Addresses
const ARC_USDC = "0x3600000000000000000000000000000000000000";
const ARC_TOKEN_MESSENGER = "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";
const STELLAR_CCTP_FORWARDER =
"CA66Q2WFBND6V4UEB7RD4SAXSVIWMD6RA4X3U32ELVFGXV5PJK4T4VSZ";
// Transfer Parameters
const AMOUNT = 1_000_000n; // 1 USDC (1 USDC = 1,000,000 subunits)
const MAX_FEE = 500n; // 0.0005 USDC (500 subunits)
// Chain-specific Parameters
const ARC_TESTNET_DOMAIN = 26; // Source domain ID for Arc Testnet
const STELLAR_DOMAIN = 27; // Destination domain ID for Stellar
// Stellar Soroban Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
// Set up wallet clients
const evmAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const arcWalletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account: evmAccount,
});
const arcPublicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
// Hook Data — encodes the forward recipient for the CCTP Forwarder contract
function contractStrkeyToBytes32(strkey: string): `0x${string}` {
if (!StrKey.isValidContract(strkey)) {
throw new Error(`Invalid contract strkey: ${strkey}`);
}
return `0x${Buffer.from(StrKey.decodeContract(strkey)).toString("hex")}`;
}
function buildCctpForwarderHookData(
forwardRecipientStrkey: string,
): `0x${string}` {
const isValid =
StrKey.isValidEd25519PublicKey(forwardRecipientStrkey) ||
StrKey.isValidContract(forwardRecipientStrkey) ||
StrKey.isValidMed25519PublicKey(forwardRecipientStrkey);
if (!isValid) {
throw new Error(
`Invalid forward recipient: ${forwardRecipientStrkey} (expected G..., C..., or M... address)`,
);
}
const recipientBytes = Buffer.from(forwardRecipientStrkey, "utf8");
const hookData = Buffer.alloc(32 + recipientBytes.length);
hookData.writeUInt32BE(0, 24);
hookData.writeUInt32BE(recipientBytes.length, 28);
recipientBytes.copy(hookData, 32);
return `0x${hookData.toString("hex")}`;
}
const stellarKeypair = Keypair.fromSecret(
process.env.STELLAR_SECRET_KEY as string,
);
// Falls back to the Stellar public key derived from STELLAR_SECRET_KEY
// when FORWARD_RECIPIENT is unset or empty.
const forwardRecipient =
process.env.FORWARD_RECIPIENT || stellarKeypair.publicKey();
const hookData = buildCctpForwarderHookData(forwardRecipient);
// ============ CCTP Flow Functions ============
async function approveUSDC() {
console.log("Approving USDC spend on Arc...");
const approveTx = await arcWalletClient.sendTransaction({
to: ARC_USDC as `0x${string}`,
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: [ARC_TOKEN_MESSENGER as `0x${string}`, AMOUNT],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: approveTx });
console.log(`Approve Tx: ${approveTx}`);
}
async function burnUSDC() {
console.log("Burning USDC on Arc (with hook)...");
const cctpForwarderBytes32 = contractStrkeyToBytes32(STELLAR_CCTP_FORWARDER);
const burnTx = await arcWalletClient.sendTransaction({
to: ARC_TOKEN_MESSENGER as `0x${string}`,
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: [
AMOUNT,
STELLAR_DOMAIN,
cctpForwarderBytes32, // mintRecipient = Stellar CCTP Forwarder
ARC_USDC as `0x${string}`,
cctpForwarderBytes32, // destinationCaller = Stellar CCTP Forwarder
MAX_FEE,
2000, // Standard Transfer finality threshold
hookData,
],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: burnTx });
console.log(`Burn Tx: ${burnTx}`);
return burnTx;
}
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ARC_TESTNET_DOMAIN}?transactionHash=${transactionHash}`;
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));
}
}
}
async function submitSorobanTx(
server: rpc.Server,
contractId: string,
method: string,
args: xdr.ScVal[],
) {
const account = await server.getAccount(stellarKeypair.publicKey());
const contract = new Contract(contractId);
const tx = new TransactionBuilder(account, {
fee: "10000000",
networkPassphrase: STELLAR_NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(120)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${JSON.stringify(simulated)}`);
}
const prepared = rpc.assembleTransaction(tx, simulated).build();
prepared.sign(stellarKeypair);
const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`Send failed: ${JSON.stringify(sendResult)}`);
}
let getResult = await server.getTransaction(sendResult.hash);
while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(sendResult.hash);
}
if (getResult.status !== "SUCCESS") {
throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
}
return sendResult.hash;
}
async function mintAndForwardOnStellar(attestation: AttestationMessage) {
console.log("Minting and forwarding USDC on Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const messageBytes = Buffer.from(
attestation.message.replace("0x", ""),
"hex",
);
const attestationBytes = Buffer.from(
attestation.attestation.replace("0x", ""),
"hex",
);
const txHash = await submitSorobanTx(
server,
STELLAR_CCTP_FORWARDER,
"mint_and_forward",
[xdr.ScVal.scvBytes(messageBytes), xdr.ScVal.scvBytes(attestationBytes)],
);
console.log(`mint_and_forward Tx: ${txHash}`);
}
// ============ Main Execution ============
async function main() {
await approveUSDC();
const burnTx = await burnUSDC();
const attestation = await retrieveAttestation(burnTx);
await mintAndForwardOnStellar(attestation);
console.log("USDC transfer from Arc to Stellar completed!");
}
main().catch(console.error);
```
## 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}
Approving USDC spend on Arc...
Approve Tx: 0x65d6504333ac76cf952975dad29d4a31d8de28c59d7c97ee8ac5d0c360c0e70c
Burning USDC on Arc (with hook)...
Burn Tx: 0x73e1eb9224ca5778be763b4e8afc11cfa63e7ae3caa8b2748ce73f4f3d07181a
Retrieving attestation...
Waiting for attestation...
Attestation retrieved successfully!
Minting and forwarding USDC on Stellar...
mint_and_forward Tx: <stellar-transaction-hash>
USDC transfer from Arc to Stellar completed!
```
Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 5 seconds with no
timeout, so if it appears to stop responding at `Waiting for attestation...`,
allow at least five minutes before investigating.
<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 between Sui and Arc
Source: https://developers.circle.com/cctp/quickstarts/transfer-usdc-sui-arc
Build scripts to transfer USDC between Sui Testnet and Arc Testnet using CCTP
Transfer USDC between Sui Testnet and Arc Testnet using the CCTP
burn-attest-mint flow.
<Note>
On Sui, CCTP V2 burn and mint are multi-step programmable transactions. Each
flow calls `TokenMessengerMinterV2`, `MessageTransmitterV2`, and the
`StablecoinHandler` in one transaction. Package and State object IDs differ from
[CCTP V1 on Sui](/cctp/v1/sui-packages).
These examples treat Sui USDC as a
[SIP-58 address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances):
burn redeems with `0x2::coin::redeem_funds`, and after mint a follow-up
`0x2::coin::send_funds` folds the `public_transfer`'d Coin into the recipient
address balance. Omit these two transactions if you're not using SIP-58
balances.
</Note>
Pick the tab that matches the direction of your transfer.
<Tabs>
<Tab title="Sui to Arc">
This quickstart demonstrates how to transfer USDC from Sui Testnet to Arc
Testnet using CCTP. You use the
[`@mysten/sui`](https://www.npmjs.com/package/@mysten/sui) SDK to burn USDC on
Sui, and [`viem`](https://viem.sh/) to mint USDC on Arc Testnet. When you
finish, you will have executed a full burn-attest-mint flow.
You should be comfortable using a terminal and Node.js. Familiarity with Sui
programmable transactions and basic EVM usage helps you follow and adapt the
script. Examples use Arc Testnet as the destination, but you can use any
[supported EVM blockchain](/cctp/concepts/supported-chains-and-domains).
<Note>
This script uses `SuiGrpcClient`, which requires a Sui **gRPC** endpoint (not
JSON-RPC). The default fullnode URL below serves gRPC on port `443`.
</Note>
## Prerequisites
Before you begin this tutorial, ensure you have:
* Installed [Node.js v22.18+](https://nodejs.org/)
* Prepared a Sui Testnet wallet with the private key available (`suiprivkey1…`)
* Funded your account with testnet SUI for transaction fees
* Funded your account with Sui Testnet USDC from the
[Circle Faucet](https://faucet.circle.com) as an
[address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances)
(SIP-58). If USDC is only held as Coin objects, deposit it first with
`0x2::coin::send_funds`.
* Prepared an EVM wallet with the private key available for Arc Testnet
* Added the Arc Testnet network to your wallet
([network details](https://docs.arc.io/arc/references/connect-to-arc#wallet-setup))
* Funded your wallet with Arc Testnet ETH for gas fees
## Step 1: Set up the project
This step shows you how to prepare your project and environment.
### 1.1. Set up your development environment
Create a new directory, initialize and set up a new Node.js project, and install
the required dependencies:
```bash Shell theme={null}
mkdir cctp-sui-to-arc
cd cctp-sui-to-arc
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env --import=tsx index.ts"
npm install @mysten/sui viem
npm install --save-dev tsx typescript @types/node
```
### 1.2. Initialize and configure the project
<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. Configure environment variables
Open `.env` in your editor and add:
```text theme={null}
EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
SUI_PRIVATE_KEY=YOUR_SUI_PRIVATE_KEY
```
* `EVM_PRIVATE_KEY` is the private key for the EVM wallet used to receive USDC
on Arc Testnet.
* `SUI_PRIVATE_KEY` is the Sui Testnet private key (`suiprivkey1…`) used to sign
the burn transaction.
<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
Define the Sui and Arc Testnet parameters, then configure the wallet clients for
each chain.
### 2.1. Define configuration constants
The script predefines the Sui CCTP V2 package and State object IDs, the Arc
`MessageTransmitterV2` address, the transfer amount, and the CCTP domain IDs.
```ts TypeScript theme={null}
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
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 SUI_DOMAIN = 8; // Source domain ID for Sui Testnet
const ARC_DOMAIN = 26; // Destination domain ID for Arc Testnet
const ARC_MESSAGE_TRANSMITTER =
"0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
/** Sandbox Sui CCTP V2 package and State object IDs. */
const CCTP_V2_CONFIG = {
mtV2PackageId:
"0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
mtV2StateId:
"0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
tmmV2PackageId:
"0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
tmmV2StateId:
"0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
handlerPackageId:
"0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
handlerStateId:
"0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
usdcPackageId:
"0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
treasuryId:
"0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
};
const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const MAX_FEE = 0n;
const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
```
`SUI_DOMAIN` is the source domain used when requesting the attestation. The
destination domain for Arc Testnet is 26. Sui Testnet USDC uses 6 decimals, so
`1_000_000` represents 1 USDC.
### 2.2. Set up wallet clients
The EVM clients connect to Arc Testnet with `viem`. The Sui client uses
`SuiGrpcClient` on Testnet and signs transactions with an Ed25519 keypair
decoded from `SUI_PRIVATE_KEY`.
```ts TypeScript theme={null}
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const suiPrivateKey = process.env.SUI_PRIVATE_KEY;
if (!evmPrivateKey || !suiPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and SUI_PRIVATE_KEY in .env");
}
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const suiClient = new SuiGrpcClient({
network: "testnet",
baseUrl: "https://fullnode.testnet.sui.io:443",
});
const suiSigner = Ed25519Keypair.fromSecretKey(
decodeSuiPrivateKey(suiPrivateKey).secretKey,
);
const messageTransmitterAbi = parseAbi([
"function receiveMessage(bytes message, bytes attestation) returns (bool success)",
]);
```
### 2.3. Format the Arc recipient for Sui
Sui `deposit_for_burn` accepts the mint recipient as a 32-byte address. An EVM
address is converted by left-padding it with zeros.
```ts TypeScript theme={null}
function padAddressToBytes32(address: string): Hex {
return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
}
```
## Step 3: Implement the transfer logic
This step implements the core transfer logic: burn on Sui, poll for an
attestation, then mint on Arc. The Sui burn is a single programmable
transaction: redeem from the SIP-58 address balance, then the CCTP Move calls.
### 3.1. Burn USDC on Sui
Build a programmable transaction that:
1. Calls `0x2::coin::redeem_funds` to unwrap the burn amount from the sender's
[address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances)
into a `Coin<USDC>`
2. Calls `deposit_for_burn` on `TokenMessengerMinterV2`
3. Calls `handler::burn` on the `StablecoinHandler`
4. Calls `complete_burn` to emit the CCTP message
You specify the following parameters for `deposit_for_burn`:
* **Burn amount**: The amount of USDC to burn in Sui subunits (6 decimals)
* **Destination domain**: The target blockchain for minting USDC (26 for Arc
Testnet)
* **Mint recipient**: The EVM wallet address that receives minted USDC on Arc,
left-padded to 32 bytes
* **Destination caller**: The zero address, allowing any caller to submit the
receive transaction on Arc
* **Max fee**: The maximum [fee](/cctp/concepts/fees) allowed for the transfer
* **Finality threshold**: `2000` for a
[Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)
```ts TypeScript theme={null}
async function burnUSDC() {
console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Sui...`);
const tx = new Transaction();
const [coin] = tx.moveCall({
target: "0x2::coin::redeem_funds",
typeArguments프로젝트로 돌아가기