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