Minting is safe to retry
CCTP minting is idempotent. Each attestation contains a unique nonce that can only be used once. If you submit the same attestation multiple times, only the first successful transaction mints USDC. Subsequent attempts revert with a “nonce already used” error but don’t result in duplicate minting. This means you can safely retry a failed mint without risking double-spending.Common mint failure reasons
| Failure reason | Symptoms | Solution |
|---|---|---|
| Insufficient gas | Transaction reverts or times out | Increase gas limit and retry |
| Nonce already used | Transaction reverts with nonce error | The mint already succeeded; check recipient balance |
| Wrong contract address | Transaction may succeed with no USDC minted | Verify you’re using the correct MessageTransmitterV2 address for the destination blockchain |
| Destination caller restriction | Transaction reverts | Check if the burn specified a destinationCaller; only that address can mint |
| Token account doesn’t exist (Solana) | Transaction fails | Create the recipient’s USDC token account first |
| Attestation expired | Transaction reverts | Use re-attestation API to get a fresh attestation |
Verify the current state
Before retrying, check whether the mint already succeeded:1
Check recipient balance
Query the recipient’s USDC balance on the destination blockchain. If the
expected amount is present, the mint already completed.
2
Check for existing mint transaction
Search the destination blockchain’s block explorer for
receiveMessage
transactions from your wallet to the MessageTransmitterV2 contract.3
Check attestation status
Query the attestation API to confirm
you have a
complete status:Retry the mint transaction
If the mint hasn’t completed, submit a newreceiveMessage transaction using
your attestation.
- EVM chains
- Solana
Call
receiveMessage on the MessageTransmitterV2 contract:TypeScript
import {
createWalletClient,
createPublicClient,
http,
encodeFunctionData,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationData {
message: string;
attestation: string;
}
const PRIVATE_KEY = process.env.EVM_PRIVATE_KEY!;
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
const walletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account,
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
// MessageTransmitterV2 contract address - verify for your destination chain
// See: https://developers.circle.com/cctp/references/contract-addresses
const MESSAGE_TRANSMITTER_V2 = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";
async function retryMint(data: AttestationData) {
console.log("Retrying mint transaction...");
try {
const txHash = await walletClient.sendTransaction({
to: MESSAGE_TRANSMITTER_V2,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "receiveMessage",
stateMutability: "nonpayable",
inputs: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
],
outputs: [],
},
],
functionName: "receiveMessage",
args: [
data.message as `0x${string}`,
data.attestation as `0x${string}`,
],
}),
});
console.log(`Mint transaction submitted: ${txHash}`);
// Wait for confirmation
const receipt = await publicClient.waitForTransactionReceipt({
hash: txHash,
});
if (receipt.status === "success") {
console.log("Mint successful!");
return { success: true, txHash };
} else {
console.log("Mint transaction reverted");
return { success: false, txHash };
}
} catch (error) {
// Check if the error indicates nonce already used
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("nonce") || errorMessage.includes("already")) {
console.log(
"Nonce already used - mint may have already completed. Check recipient balance.",
);
}
throw error;
}
}
// Use attestation data from the API
const attestationData: AttestationData = {
message: "0x00000001000000000000001a...", // Full message hex from API
attestation: "0xde09db65dea64090570d8143...", // Full attestation hex from API
};
await retryMint(attestationData);
Call
receiveMessage on the MessageTransmitterV2 program. For Solana, ensure
the recipient’s USDC token account exists before calling receiveMessage.TypeScript
import crypto from "crypto";
import {
address,
createKeyPairSignerFromBytes,
createSolanaRpc,
createSolanaRpcSubscriptions,
createTransactionMessage,
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";
interface AttestationData {
message: string;
attestation: string;
}
// Solana Configuration
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 MESSAGE_TRANSMITTER_PROGRAM = address(
"CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC",
);
const TOKEN_MESSENGER_MINTER_PROGRAM = address(
"CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe",
);
const USDC_MINT = address("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU");
const ASSOCIATED_TOKEN_PROGRAM = address(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
);
async function retryMintOnSolana(
attestationData: AttestationData,
sourceDomain: number,
) {
console.log("Retrying mint on Solana...");
const addressEncoder = getAddressEncoder();
// Derive receiver's USDC token account
const [receiverUsdcAccount] = await getProgramDerivedAddress({
programAddress: ASSOCIATED_TOKEN_PROGRAM,
seeds: [
addressEncoder.encode(solanaKeypair.address),
addressEncoder.encode(TOKEN_PROGRAM_ADDRESS),
addressEncoder.encode(USDC_MINT),
],
});
// Derive required PDAs
const [messageTransmitter] = await getProgramDerivedAddress({
programAddress: MESSAGE_TRANSMITTER_PROGRAM,
seeds: [new TextEncoder().encode("message_transmitter")],
});
const [authorityPda] = await getProgramDerivedAddress({
programAddress: MESSAGE_TRANSMITTER_PROGRAM,
seeds: [new TextEncoder().encode("message_transmitter_authority")],
});
// Calculate used nonces PDA
const messageBytes = Buffer.from(attestationData.message.slice(2), "hex");
const nonce = messageBytes.readBigUInt64BE(12);
const firstNonce = (nonce / 6400n) * 6400n;
const firstNonceBuffer = Buffer.alloc(8);
firstNonceBuffer.writeBigUInt64BE(firstNonce);
const sourceDomainBuffer = Buffer.alloc(4);
sourceDomainBuffer.writeUInt32BE(sourceDomain);
const [usedNonces] = await getProgramDerivedAddress({
programAddress: MESSAGE_TRANSMITTER_PROGRAM,
seeds: [
new TextEncoder().encode("used_nonces"),
sourceDomainBuffer,
firstNonceBuffer,
],
});
// Derive TokenMessengerMinterV2 PDAs
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(sourceDomain.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 sourceTokenBytes = messageBytes.slice(133, 165);
const [tokenPair] = await getProgramDerivedAddress({
programAddress: TOKEN_MESSENGER_MINTER_PROGRAM,
seeds: [
new TextEncoder().encode("token_pair"),
sourceDomainBuffer,
sourceTokenBytes,
],
});
const [custody] = await getProgramDerivedAddress({
programAddress: TOKEN_MESSENGER_MINTER_PROGRAM,
seeds: [
new TextEncoder().encode("custody"),
addressEncoder.encode(USDC_MINT),
],
});
const [eventAuthority] = await getProgramDerivedAddress({
programAddress: MESSAGE_TRANSMITTER_PROGRAM,
seeds: [new TextEncoder().encode("__event_authority")],
});
const [tokenProgramEventAuthority] = await getProgramDerivedAddress({
programAddress: TOKEN_MESSENGER_MINTER_PROGRAM,
seeds: [new TextEncoder().encode("__event_authority")],
});
// Build instruction
const discriminator = crypto
.createHash("sha256")
.update("global:receive_message")
.digest()
.slice(0, 8);
const messageBuffer = Buffer.from(attestationData.message.slice(2), "hex");
const attestationBuffer = Buffer.from(
attestationData.attestation.slice(2),
"hex",
);
const messageLenBuffer = Buffer.alloc(4);
messageLenBuffer.writeUInt32LE(messageBuffer.length);
const attestationLenBuffer = Buffer.alloc(4);
attestationLenBuffer.writeUInt32LE(attestationBuffer.length);
const instructionData = new Uint8Array(
Buffer.concat([
discriminator,
messageLenBuffer,
messageBuffer,
attestationLenBuffer,
attestationBuffer,
]),
);
const receiveMessageIx = {
programAddress: MESSAGE_TRANSMITTER_PROGRAM,
accounts: [
{ address: solanaKeypair.address, role: 3, signer: solanaKeypair },
{ address: solanaKeypair.address, role: 0 },
{ address: authorityPda, role: 0 },
{ address: messageTransmitter, role: 0 },
{ address: usedNonces, role: 1 },
{ address: TOKEN_MESSENGER_MINTER_PROGRAM, role: 0 },
{ address: SYSTEM_PROGRAM_ADDRESS, role: 0 },
{ address: eventAuthority, role: 0 },
{ address: MESSAGE_TRANSMITTER_PROGRAM, role: 0 },
{ address: tokenMessenger, role: 0 },
{ address: remoteTokenMessenger, role: 0 },
{ address: tokenMinter, role: 1 },
{ address: localToken, role: 1 },
{ address: tokenPair, role: 0 },
{ address: receiverUsdcAccount, role: 1 },
{ address: custody, role: 1 },
{ address: TOKEN_PROGRAM_ADDRESS, role: 0 },
{ address: tokenProgramEventAuthority, role: 0 },
{ address: TOKEN_MESSENGER_MINTER_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(receiveMessageIx, tx),
);
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
rpc,
rpcSubscriptions,
});
try {
await sendAndConfirmTransaction(
signedTransaction as Parameters<typeof sendAndConfirmTransaction>[0],
{
commitment: "confirmed",
},
);
const signature = getSignatureFromTransaction(signedTransaction);
console.log(`Mint successful! Signature: ${signature}`);
return signature;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("already been processed")) {
console.log("Nonce already used - mint may have already completed.");
}
throw error;
}
}
// Use attestation data from the API
const attestationData: AttestationData = {
message: "0x000000000000000500000000...", // Full message hex from API
attestation: "0xdc485fb2f9a8f68c871f4ca7386dee9086ff9d43...", // Full attestation hex from API
};
await retryMintOnSolana(attestationData, 0); // 0 = Ethereum Sepolia domain
Note: The recipient’s USDC token account must exist before calling
receiveMessage. If the account doesn’t exist, create it using the Associated
Token Program before retrying the mint.Handle destination caller restrictions
If the burn specified adestinationCaller address, only that address can call
receiveMessage. If you’re seeing authorization errors:
- Check the
destinationCallerfield in the attestation’sdecodedMessage - If it’s not
0x0000...0000, ensure you’re calling from the specified address