rpcSubscriptions = createSolanaRpcSubscriptions(SOLANA_WS);
const solanaPrivateKey = JSON.parse(process.env.SOLANA_PRIVATE_KEY!);
const solanaKeypair = await createKeyPairSignerFromBytes(
Uint8Array.from(solanaPrivateKey),
);
const TOKEN_MESSENGER_MINTER_PROGRAM = address(
"CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe",
);
const MESSAGE_TRANSMITTER_PROGRAM = address(
"CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC",
);
const USDC_MINT = address("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU");
const ASSOCIATED_TOKEN_PROGRAM = address(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
);
const ethAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY! as `0x${string}`,
);
const AMOUNT = 1_000_000n;
const DESTINATION_DOMAIN = 26;
const ARC_DESTINATION_ADDRESS = ethAccount.address;
const FORWARDING_SERVICE_HOOK_DATA = Buffer.from(
"636374702d666f72776172640000000000000000000000000000000000000000",
"hex",
);
const FORWARDING_DISCRIMINATOR = Buffer.from([
111, 245, 62, 131, 204, 108, 223, 155,
]);
async function getForwardingFeeQuote() {
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()}`);
}
const fees = (await response.json()) as FeeQuote[];
const feeData = fees.find((fee) => fee.finalityThreshold === 1000);
if (!feeData) {
throw new Error("Fast-transfer forwarding fees not available");
}
return feeData;
}
async function calculateForwardingAmounts() {
const feeData = await getForwardingFeeQuote();
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;
console.log("Forward fee:", Number(forwardFee) / 1_000_000, "USDC");
console.log("Protocol fee:", Number(protocolFee) / 1_000_000, "USDC");
console.log("Max fee:", Number(maxFee) / 1_000_000, "USDC");
console.log("Total to burn:", Number(totalAmount) / 1_000_000, "USDC");
return { maxFee, totalAmount };
}
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(totalAmount: bigint, maxFee: bigint) {
console.log("Burning USDC on Solana with Forwarding Service...");
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([
FORWARDING_DISCRIMINATOR,
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;
}
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));
}
}
async function main() {
console.log("Solana address:", solanaKeypair.address);
console.log("Arc recipient:", ARC_DESTINATION_ADDRESS);
// [1] Quote forwarding fees and derive the total source-chain burn amount.
const { maxFee, totalAmount } = await calculateForwardingAmounts();
// [2] Burn on Solana with the forwarding hook enabled.
const burnSignature = await burnUSDCWithForwarding(totalAmount, maxFee);
// [3] Poll until Iris returns the destination mint transaction hash.
await waitForForwardedMint(burnSignature);
console.log(
"USDC transfer from Solana Devnet to Arc Testnet completed with Forwarding Service.",
);
}
main().catch(console.error);
```
</Tab>
<Tab title="Direct mint">
```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 { createWalletClient, http, encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
const SOLANA_RPC = "https://api.devnet.solana.com";
const SOLANA_WS = "wss://api.devnet.solana.com";
const rpc = createSolanaRpc(SOLANA_RPC);
const rpcSubscriptions = createSolanaRpcSubscriptions(SOLANA_WS);
const solanaPrivateKey = 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",
);
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,
});
const AMOUNT = 1_000_000n;
const DESTINATION_DOMAIN = 26;
const ARC_DESTINATION_ADDRESS = ethAccount.address;
const MAX_FEE = 500n;
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();
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();
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;
}
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));
}
}
}
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}`);
}
async function main() {
// [1] Burn USDC on Solana Devnet.
const burnSignature = await burnUSDCOnSolana();
// [2] Poll until Iris returns a complete attestation.
const attestation = await retrieveAttestation(burnSignature);
// [3] Submit the destination-side mint on Arc Testnet.
await mintUSDCOnArc(attestation);
console.log("USDC transfer from Solana Devnet to Arc Testnet completed.");
}
main().catch(console.error);
```
</Tab>
</Tabs>
## Step 5: Test the script
Run the following command to execute the script:
```shell Shell theme={null}
npm run start
```
Once the script runs and the transfer is finalized, a confirmation message is
logged in the console.
<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>
# Transfer USDC between Starknet and Arc
Source: https://developers.circle.com/cctp/quickstarts/transfer-usdc-starknet-arc
Build scripts to transfer USDC between Arc Testnet and Starknet Sepolia using CCTP
Transfer USDC between Starknet Sepolia and Arc Testnet using the CCTP
burn-attest-mint flow.
<Note>
On Starknet, CCTP uses Cairo contracts and a combined `TokenMessengerMinterV2`.
Before you integrate beyond these examples, read
[CCTP Starknet contracts and interfaces](/cctp/references/starknet-contracts).
</Note>
Pick the tab that matches the direction of your transfer.
<Tabs>
<Tab title="Starknet to Arc">
This quickstart demonstrates how to transfer USDC from Starknet Sepolia to Arc
Testnet using CCTP. You use
[`starknet.js`](https://www.npmjs.com/package/starknet) to approve and burn USDC
on Starknet, and [`viem`](https://viem.sh/) to mint USDC on Arc Testnet. When
you finish, you will have executed a full burn-attest-mint flow.
프로젝트로 돌아가기