Skip to main content
CCTP on Stellar has two behaviors you must account for when integrating: a 32-byte address format that does not distinguish accounts from contracts, and a seven-decimal USDC precision that differs from other CCTP supported blockchains. See CCTP Stellar Contracts and Interfaces for the contract addresses and interfaces.
Always use CctpForwarder when routing CCTP USDC to a Stellar address. CCTP treats mintRecipient as a contract, so skipping the forwarder and pointing that field at a user account or muxed address will not send funds to the wallet you intended. USDC can end up stranded.

Stellar address types

On Stellar, addresses are strkey strings composed of a type identifier and a 32-byte payload. The type identifier determines the account type: user accounts (G) carry an Ed25519 public key, contracts (C) carry a contract ID hash. Muxed accounts (M) are a type of G account that additionally embeds a numeric identifier alongside the Ed25519 public key (see Stellar’s muxed accounts documentation). CCTP messages store only the raw 32-byte payload without the type identifier, so the protocol cannot distinguish between address types and assumes the mintRecipient is always a contract. Use CctpForwarder when transferring to Stellar to ensure funds are forwarded to the intended recipient.

Use CctpForwarder for Stellar recipients

CctpForwarder is a publicly callable onchain contract that receives minted USDC on Stellar and atomically forwards it to forwardRecipient. Encode forwardRecipient in hook data as a Stellar strkey. The prefix G, M, or C identifies the recipient address type.
On the source burn, both mintRecipient and destinationCaller must be set to the CctpForwarder contract address. Otherwise funds will not reach the forwarder or can get stuck.

How it works

Call mint_and_forward on CctpForwarder through the Stellar Soroban client for your language. Pass the raw CCTP message and attestation bytes. The following shows the onchain contract interface. It is not a TypeScript or JavaScript function you call directly. Your Soroban client builds an invokeHostFunction operation from these arguments.
mint_and_forward(message: Bytes, attestation: Bytes)
For TypeScript, @stellar/stellar-sdk documents how to encode arguments and how to simulate, sign, and submit transactions against Stellar RPC. Inside mint_and_forward, CctpForwarder does the following:
  1. Validates the message.
  2. Extracts forwardRecipient from hook data.
  3. Calls receive_message on MessageTransmitter, which mints USDC to CctpForwarder.
  4. Transfers the minted USDC to forwardRecipient.
  5. Runs atomically. Any failure reverts the invocation.
The CctpForwarder flow is non-custodial. The mint and the payout to forwardRecipient both run onchain in that single Soroban invocation. Circle does not take custody of the minted balance in between.

Hook format

The hook data begins with the reserved magic bytes cctp-forward, followed by versioning and payload fields. On Stellar, bytes 28 onward carry the length of forwardRecipient, the forwardRecipient strkey, and any optional trailing bytes for integrator use.
BytesTypeData
0-23bytes24Magic. cctp-forward for Circle-relayed; all zero bytes for self-relay
24-27uint32Version, set to 0
28-31uint32L: length of forwardRecipient in bytes
32..(32+L-1)bytesforwardRecipient as a strkey
(32+L)..bytesOptional integrator-defined payload; omit if unused

Building forwarder hook data (example)

The following helper functions validate Stellar contract strkey inputs and build the hookData payload for an EVM depositForBurnWithHook call:
TypeScript
import { StrKey } from "@stellar/stellar-sdk";

/**
 * Validates that the input is a Stellar contract address (C…) and decodes it
 * to a 0x-prefixed bytes32 hex string suitable for EVM contract calls.
 *
 * @param strkey - Stellar contract address (C…)
 * @returns 0x-prefixed 64-character hex string
 * @throws If the input is not a valid contract address
 */
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")}`;
}

/**
 * Builds the hookData buffer for a CCTP Forwarder burn message.
 *
 * Hook data layout:
 *   bytes  0–23: reserved (zeroed)
 *   bytes 24–27: hook data version (u32 BE, currently 0)
 *   bytes 28–31: forward_recipient byte length (u32 BE)
 *   bytes 32+  : forward_recipient (UTF-8 encoded Stellar strkey)
 *
 * @param forwardRecipientStrkey - Stellar strkey of the final token recipient (C…, G…, or M…)
 * @returns Hook data as a 0x-prefixed hex string
 */
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); // hook version = 0
  hookData.writeUInt32BE(recipientBytes.length, 28); // recipient byte length
  recipientBytes.copy(hookData, 32); // recipient strkey as UTF-8
  return `0x${hookData.toString("hex")}`;
}

interface DepositForBurnWithHookParams {
  amount: bigint;
  destinationDomain: number;
  mintRecipient: `0x${string}`;
  burnToken: `0x${string}`;
  destinationCaller: `0x${string}`;
  maxFee: bigint;
  minFinalityThreshold: number;
  hookData: `0x${string}`;
}

/**
 * Prepares all arguments for an EVM `depositForBurnWithHook` call targeting
 * Stellar via the CCTP Forwarder. Converts Stellar strkeys to 0x-prefixed
 * bytes32 hex strings and encodes the hook data.
 *
 * @param amount - Token amount to burn (in EVM token decimals)
 * @param cctpForwarderStrkey - Stellar strkey of the CCTP Forwarder contract (C…), used as mintRecipient and destinationCaller
 * @param burnToken - EVM address of the token to burn
 * @param maxFee - Maximum fee for the burn
 * @param minFinalityThreshold - Minimum finality threshold (1000 = fast, 2000 = standard)
 * @param forwardRecipientStrkey - Stellar strkey of the final token recipient (C…, G…, or M…), encoded in hookData
 */
function prepareEvmDepositForBurnWithHookToStellar(
  amount: bigint,
  cctpForwarderStrkey: string,
  burnToken: `0x${string}`,
  maxFee: bigint,
  minFinalityThreshold: number,
  forwardRecipientStrkey: string,
): DepositForBurnWithHookParams {
  const cctpForwarderHex = contractStrkeyToBytes32(cctpForwarderStrkey);
  const hookData = buildCctpForwarderHookData(forwardRecipientStrkey);

  return {
    amount,
    destinationDomain: 27,
    mintRecipient: cctpForwarderHex,
    burnToken,
    destinationCaller: cctpForwarderHex,
    maxFee,
    minFinalityThreshold,
    hookData,
  };
}

Stellar addresses in CCTP messages and API responses

Stellar addresses are strkey strings. CCTP message fields store only 32-byte address payloads. They omit the strkey encoding, including the G, M, or C type marker, so the raw bytes in the message do not say whether the address is an account or a contract. mintRecipient is always assumed to be a contract address. You must use CctpForwarder to make transfers to Stellar.

CCTP message fields

The following tables describe each address field in the CCTP message, explain how Stellar uses it during a mint (inbound) or burn (outbound), and indicate whether you need to design around the address type. For the full message layout, see the CCTP Technical Guide.

Inbound transfers to Stellar destination

FieldOperationMust design around address type?
senderValidate against the source domain TokenMessenger mapping.No
recipientSelect the Stellar contract that handles the destination receive_message.No, always a contract (C)
destinationCallerRestrict who may call receive_message (require_auth compares bytes).No, compare raw bytes
burnTokenMap the burned token identifier to Stellar USDC.No, known asset contract
messageSenderNot used operationally on Stellar.No
mintRecipientMint USDC to this 32-byte destination on Stellar.Yes, always assumed to be a contract; use CctpForwarder for Stellar recipients

Outbound transfers from Stellar source

FieldOperationMust design around address type?
sender32 byte address payload of the Stellar TokenMessengerMinterV2 used to perform the burn.No
burnTokenIdentify the Stellar USDC contract that is burned.No
mintRecipientEncode the recipient on the destination blockchain.No
messageSenderRecord caller context (not used operationally on Stellar).No
destinationCallerEncode which address may call receive on the destination blockchain.No
recipientEncode the handler contract on the destination blockchain.No

Null address fields in API responses

When a CCTP message involves Stellar, the Get messages endpoint returns all address fields in decodedMessage and decodedMessageBody as null because the API cannot distinguish a 32-byte Stellar account from a contract. To read those addresses, parse the raw hex in the message field directly. The following example shows an Ethereum-to-Stellar transfer response with typical null address fields:
JSON
{
  "messages": [
    {
      "message": "0x...",
      "eventNonce": "0",
      "attestation": "0x...",
      "cctpVersion": 2,
      "status": "complete",
      "decodedMessage": {
        "sourceDomain": "0",
        "destinationDomain": "27",
        "nonce": "0x0000000000000000000000000000000000000000000000000000000000000000",
        "sender": null,
        "recipient": null,
        "destinationCaller": null,
        "minFinalityThreshold": "1000",
        "finalityThresholdExecuted": "2000",
        "messageBody": "0x...",
        "decodedMessageBody": {
          "burnToken": null,
          "mintRecipient": null,
          "amount": "1000000",
          "messageSender": null,
          "maxFee": "0",
          "feeExecuted": "0",
          "expirationBlock": "0",
          "hookData": null
        }
      }
    }
  ]
}

USDC precision for CCTP and Stellar

Stellar represents USDC in seven-decimal subunits while other CCTP-supported blockchains use six. How CCTP handles that difference depends on whether Stellar is the source or destination blockchain. Regardless of direction, the amount field in a CCTP message is always in six-decimal subunits.
Stellar wallets and SDKs often display seven fractional digits. Use six-decimal subunits in amount when handling CCTP messages offchain.

Stellar as the source

When Stellar is the source blockchain, the burn debits only through the sixth decimal digit of the user’s balance. Anything in the seventh decimal place stays in the user’s account.
  1. A user bridges 0.1234567 USDC from Stellar to the destination blockchain.
  2. Stellar burns 0.1234560 USDC.
  3. 0.0000007 USDC stays in the user’s Stellar account.
  4. The CCTP message amount is 123456 (six-decimal subunits).
  5. The destination blockchain mints 0.123456 USDC to the recipient.

Stellar as the destination

When Stellar is the destination blockchain, the mint converts the six-decimal message amount into seven by scaling the integer by 10 (for example, 123456 becomes 1234560 seven-decimal subunits).
  1. A user bridges 0.123456 USDC from the source blockchain to Stellar.
  2. The CCTP message amount is 123456 (six-decimal subunits).
  3. Stellar mints 0.1234560 USDC to the recipient.