> ## Documentation Index
> Fetch the complete documentation index at: https://developers.circle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How-to: Transfer USDC with upfront fees

> Pay CCTP fees upfront on the source blockchain so the full USDC amount mints on the destination blockchain.

This example transfers 10 USDC from Base Sepolia to Arc Testnet, paying both the
Fast Transfer and Forwarding Service fees [upfront](/cctp/concepts/upfront-fees)
so the recipient receives the full transfer amount on the destination
blockchain. You can use the same steps for any EVM source blockchain where both
upfront fees and Fast Transfer are available, and any destination blockchain
that the Forwarding Service supports.

## Prerequisites

Before you begin, ensure that you've:

* Installed [Node.js v22+](https://nodejs.org/)
* Created a TypeScript project and installed the `viem` package
* Created a wallet with the private key accessible from your local development
  environment
* Funded the wallet on Base Sepolia with:
  * Base Sepolia ETH for gas from a
    [public faucet](https://www.alchemy.com/faucets/base-sepolia)
  * Base Sepolia USDC from the [Circle Faucet](https://faucet.circle.com)
* Created a `.env` file with your private key

<Warning>
  Keep your private key secret. Store it in a `.env` file that's listed in
  `.gitignore`, and never commit it or expose it in logs or shell history.
</Warning>

## Steps

### Step 1: Request a signed quote from the Quote API

Request a quote from the
[Quote API](/api-reference/cctp/all/create-usdc-burn-quote) for the fees you
want to pay upfront. This example requests both `FORWARD` (Forwarding Service)
and `PRE_FINALITY` (Fast Transfer) fees, and sets `feeToken` to the source
blockchain's USDC address to pay the fee in USDC. The following request uses
source domain 6 (Base Sepolia) and destination domain 26 (Arc Testnet).

The USDC address and decimals come from the token definition in
[`viem/tokens`](https://viem.sh/tokens), so you don't hardcode either one:

```typescript theme={null}
import { parseUnits } from "viem";
import { baseSepolia } from "viem/chains";
import { usdc } from "viem/tokens";

// USDC on the source blockchain, including its address and decimals
const sourceUsdc = usdc(baseSepolia.id);

// The quote binds to this amount, so reuse it for the onchain burn
const transferAmount = parseUnits("10", sourceUsdc.decimals);

const response = await fetch(
  "https://iris-api-sandbox.circle.com/v2/quote/burn/usdc/6/26",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      amount: transferAmount.toString(), // minor units
      feeToken: sourceUsdc.address, // omit to pay the fee in the native gas token
      requests: [
        { type: "FORWARD" },
        // Omit if the source blockchain doesn't support Fast Transfer
        { type: "PRE_FINALITY" },
      ],
    }),
  },
);

const quote = await response.json();
console.log("Quote:", JSON.stringify(quote, null, 2));
```

**Example response:**

```json theme={null}
{
  "signedQuote": "0x0100000000000000000000000000000000000000000000000000000000000000...c9afdd35731b00",
  "issuedAt": 1783407750,
  "expiry": {
    "mode": "BLOCK_NUMBER",
    "expiresAtBlock": 43819792,
    "blockEstimatedAt": 1783407872
  },
  "feeTotalAmount": "24453",
  "feeToken": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  "items": [
    {
      "type": "FORWARD",
      "amount": "23153",
      "args": ["..."],
      "argsHash": "0x6cea1d25ab9b4b9edfc5c788082c28c6669c6fc2f35fa8e047c7774e60c66d4c"
    },
    {
      "type": "PRE_FINALITY",
      "amount": "1300",
      "args": ["..."],
      "argsHash": "0x5743df2ad8e9696c8a5e70bfced256f3160e68d49ce305f8c7fcd0c90b035496"
    }
  ],
  "nonce": "0",
  "metadata": {
    "exchangeRates": {
      "feeTokenUsd": "1.000000",
      "destinationTokenUsd": "1.000000"
    }
  }
}
```

The `signedQuote` is the blob you submit onchain (truncated here for brevity).
The `feeTotalAmount` is the total fee in `feeToken` minor units, and `items`
breaks it down per fee type. This quote uses `BLOCK_NUMBER` expiry, so it stays
valid until `expiresAtBlock` on the source blockchain.

<Tip>
  Quotes expire after a short, per-blockchain window (see
  [Quote expiry](/cctp/concepts/upfront-fee-quotes#quote-expiry)). Request the
  quote immediately before you submit the transfer, and submit before it expires.
</Tip>

### Step 2: Calculate the approval amount

To pay the fee in USDC, approve the `TokenMessengerWithFees` contract to spend
both the transfer amount and the quoted fee. The contract pulls the transfer
amount for the burn and the fee for collection.

```typescript theme={null}
import { formatUnits } from "viem";

// Total fee from the quote response
const feeAmount = BigInt(quote.feeTotalAmount);

// Approve the transfer amount (from Step 1) plus the fee
const approvalAmount = transferAmount + feeAmount;

const { decimals, symbol } = sourceUsdc;
console.log("Transfer:", formatUnits(transferAmount, decimals), symbol);
console.log("Fee:", formatUnits(feeAmount, decimals), symbol);
console.log("Approval:", formatUnits(approvalAmount, decimals), symbol);
```

Because you pay the fee upfront, the recipient receives the full
`transferAmount` on Arc Testnet. The fee is charged separately from the amount
minted.

### Step 3: Approve the USDC transfer

Grant approval for the
[`TokenMessengerWithFees`](/cctp/references/contract-addresses#tokenmessengerwithfees-testnet)
contract on Base Sepolia to spend USDC from your wallet. Approve at least the
`approvalAmount` calculated in Step 2.

Registering the `usdc` token on the wallet client gives you the
`client.token.approve` action, so you don't have to supply the ERC-20 ABI
yourself:

```typescript theme={null}
import { createPublicClient, createWalletClient, http } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { usdc } from "viem/tokens";

// Configuration
// TokenMessengerWithFees has this same address on every supported testnet
// blockchain, including Base Sepolia.
const TESTNET_TOKEN_MESSENGER_WITH_FEES =
  "0x8745D906D67C346E5eb1aEEED38Eb87F34DF0C0A";

// Set up wallet and public clients
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
  chain: baseSepolia,
  tokens: [usdc],
  transport: http(),
  account,
});
const publicClient = createPublicClient({
  chain: baseSepolia,
  transport: http(),
});

async function approveUSDC(amount: bigint) {
  console.log("Approving USDC...");
  const approveTx = await client.token.approve({
    amount,
    spender: TESTNET_TOKEN_MESSENGER_WITH_FEES,
    token: "usdc",
  });
  console.log("USDC Approval Tx:", approveTx);

  // Wait for the approval to be mined before you submit the burn
  await publicClient.waitForTransactionReceipt({ hash: approveTx });
  return approveTx;
}

// Approve the transfer amount plus the fee (from Step 2)
await approveUSDC(approvalAmount);
```

<Note>
  `sendTransaction` resolves once the node accepts the transaction, not once it's
  mined. Without the wait, a burn submitted immediately after the approval can
  revert because the allowance isn't set yet.
</Note>

### Step 4: Sign and broadcast a `depositForBurnWithFees` transaction

Call `depositForBurnWithFees` on the `TokenMessengerWithFees` contract, passing
a `QuoteClaim` that bundles the `signedQuote` with your `refundAddress`. The
contract infers Fast Transfer from the `PRE_FINALITY` fee and adds the default
`cctp-forward` hook because the quote includes a `FORWARD` fee, so you don't
pass `maxFee`, `minFinalityThreshold`, or hook data.

This example uses the `QuoteClaim` overload, which takes the opaque
`signedQuote` bytes directly. The contract also exposes a `DecodedQuoteClaim`
overload for advanced cases where you decode the quote in your own code; see the
full ABI in
[Contract interfaces](/cctp/references/contract-interfaces#tokenmessengerwithfees).

<Note>
  Use `transferAmount` (not the amount plus fee) for the `amount` parameter. The
  fee is collected separately, and the recipient receives the full
  `transferAmount`.
</Note>

```typescript theme={null}
import { pad, encodeFunctionData } from "viem";

// Configuration
const ARC_TESTNET_DOMAIN = 26;

// This example mints back to your own wallet. Set this to any address you want
// to receive the USDC on the destination blockchain.
const DESTINATION_ADDRESS = account.address;

// Convert the recipient address to bytes32
const mintRecipientBytes32 = pad(DESTINATION_ADDRESS, { size: 32 });

// Use your own address to attribute any future fee refund to
const refundAddress = account.address;

async function depositForBurnWithFees() {
  console.log("Burning USDC on Base Sepolia with upfront fees...");

  const burnTx = await client.sendTransaction({
    to: TESTNET_TOKEN_MESSENGER_WITH_FEES,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurnWithFees",
          stateMutability: "payable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            {
              name: "claim",
              type: "tuple",
              components: [
                { name: "signedQuote", type: "bytes" },
                { name: "refundAddress", type: "address" },
              ],
            },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurnWithFees",
      args: [
        transferAmount,
        ARC_TESTNET_DOMAIN,
        mintRecipientBytes32,
        sourceUsdc.address,
        pad("0x", { size: 32 }), // destinationCaller (empty = any caller)
        { signedQuote: quote.signedQuote, refundAddress },
      ],
    }),
  });
  console.log("Burn Tx:", burnTx);

  // Wait for the burn to be mined before you poll for the attestation
  await publicClient.waitForTransactionReceipt({ hash: burnTx });
  return burnTx;
}

const burnTx = await depositForBurnWithFees();
```

<Accordion title="Pay the fee in the native gas token">
  To pay the fee in the source blockchain's native gas token instead of USDC, omit
  `feeToken` from the quote request in Step 1. Then approve only the
  `transferAmount` in Step 3, and attach the quoted fee as the transaction's
  native value in Step 4:

  ```typescript theme={null}
  const burnTx = await client.sendTransaction({
    to: TESTNET_TOKEN_MESSENGER_WITH_FEES,
    value: feeAmount, // pay the quoted fee in native currency
    data: encodeFunctionData({
      // ...same abi and args as above
    }),
  });
  ```
</Accordion>

Because the quote binds to your call arguments, a mismatch reverts onchain and
costs you gas. Request the quote with the same amount, destination domain, and
burn token you submit onchain so the values match.

Once the burn transaction is confirmed on Base Sepolia, Circle attests the burn
and the Forwarding Service mints USDC on Arc Testnet. Because the fee is paid
upfront, the recipient receives the full `transferAmount`.

### Step 5: Verify the mint transaction

After the burn is confirmed, query the Circle Iris API to retrieve the
forwarding details. The API returns the `forwardTxHash`, which is the mint
transaction hash on the destination blockchain. The attestation may take time to
become available, so poll the API until the message is ready:

```typescript theme={null}
// Configuration
const BASE_SEPOLIA_DOMAIN = 6;

process.stdout.write("Waiting for attestation...");

let mintTx;
while (!mintTx) {
  const messageResponse = await fetch(
    `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`,
  );
  const data = await messageResponse.json();

  if (data.messages?.[0]?.forwardTxHash) {
    mintTx = data.messages[0].forwardTxHash;
    console.log(); // New line after dots
  } else {
    process.stdout.write(".");
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

console.log("Mint Tx:", mintTx);
```

## Full example code

The following is a complete example of how to transfer USDC from Base Sepolia to
Arc Testnet with upfront fees. Remember to set the `PRIVATE_KEY` environment
variable.

```typescript script.ts expandable theme={null}
import {
  createPublicClient,
  createWalletClient,
  http,
  encodeFunctionData,
  formatUnits,
  pad,
  parseUnits,
} from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { usdc } from "viem/tokens";

// Validate environment variables
if (!process.env.PRIVATE_KEY) {
  throw new Error("PRIVATE_KEY environment variable is required");
}

// Configuration
const sourceUsdc = usdc(baseSepolia.id);
// TokenMessengerWithFees has this same address on every supported testnet
// blockchain, including Base Sepolia. Mainnet uses a different shared address.
// See: https://developers.circle.com/cctp/references/contract-addresses#tokenmessengerwithfees-testnet
const TESTNET_TOKEN_MESSENGER_WITH_FEES =
  "0x8745D906D67C346E5eb1aEEED38Eb87F34DF0C0A";
const BASE_SEPOLIA_DOMAIN = 6;
const ARC_TESTNET_DOMAIN = 26;

// The quote binds to this amount, so the same value is used for the burn
const transferAmount = parseUnits("10", sourceUsdc.decimals);

// Set up wallet and public clients
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
  chain: baseSepolia,
  tokens: [usdc],
  transport: http(),
  account,
});
const publicClient = createPublicClient({
  chain: baseSepolia,
  transport: http(),
});

// This example mints back to your own wallet. Set this to any address you want
// to receive the USDC on the destination blockchain.
const DESTINATION_ADDRESS = account.address;

async function main() {
  console.log("Wallet address:", account.address);
  console.log("Destination address:", DESTINATION_ADDRESS);

  // Step 1: Request a signed quote
  console.log("\nStep 1: Requesting a signed quote...");
  const quoteResponse = await fetch(
    `https://iris-api-sandbox.circle.com/v2/quote/burn/usdc/${BASE_SEPOLIA_DOMAIN}/${ARC_TESTNET_DOMAIN}`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        amount: transferAmount.toString(), // minor units
        feeToken: sourceUsdc.address, // pay the fee in USDC
        requests: [
          { type: "FORWARD" },
          // Omit if the source blockchain doesn't support Fast Transfer
          { type: "PRE_FINALITY" },
        ],
      }),
    },
  );
  const quote = await quoteResponse.json();
  console.log("Quote:", JSON.stringify(quote, null, 2));

  // Step 2: Calculate amounts
  console.log("\nStep 2: Calculating amounts...");
  const feeAmount = BigInt(quote.feeTotalAmount);
  const approvalAmount = transferAmount + feeAmount;

  const { decimals, symbol } = sourceUsdc;
  console.log("Transfer:", formatUnits(transferAmount, decimals), symbol);
  console.log("Fee:", formatUnits(feeAmount, decimals), symbol);
  console.log("Approval:", formatUnits(approvalAmount, decimals), symbol);

  // Step 3: Approve USDC (transfer amount + fee)
  console.log("\nStep 3: Approving USDC...");
  const approveTx = await client.token.approve({
    amount: approvalAmount,
    spender: TESTNET_TOKEN_MESSENGER_WITH_FEES,
    token: "usdc",
  });
  console.log("Approval Tx:", approveTx);

  // Wait for the approval to be mined so the burn doesn't revert
  await publicClient.waitForTransactionReceipt({ hash: approveTx });

  // Step 4: Burn USDC with upfront fees
  console.log("\nStep 4: Burning USDC with upfront fees...");
  const burnTx = await client.sendTransaction({
    to: TESTNET_TOKEN_MESSENGER_WITH_FEES,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurnWithFees",
          stateMutability: "payable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            {
              name: "claim",
              type: "tuple",
              components: [
                { name: "signedQuote", type: "bytes" },
                { name: "refundAddress", type: "address" },
              ],
            },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurnWithFees",
      args: [
        transferAmount,
        ARC_TESTNET_DOMAIN,
        pad(DESTINATION_ADDRESS, { size: 32 }),
        sourceUsdc.address,
        pad("0x", { size: 32 }), // destinationCaller (empty = any caller)
        { signedQuote: quote.signedQuote, refundAddress: account.address },
      ],
    }),
  });
  console.log("Burn Tx:", burnTx);

  // Wait for the burn to be mined before polling for the attestation
  await publicClient.waitForTransactionReceipt({ hash: burnTx });

  console.log(
    "\nTransfer initiated. The Forwarding Service will mint the full amount on Arc Testnet.",
  );

  // Step 5: Verify the mint transaction
  console.log("\nStep 5: Verifying mint transaction...");
  process.stdout.write("Waiting for attestation...");

  let mintTx;
  while (!mintTx) {
    const messageResponse = await fetch(
      `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`,
    );
    const data = await messageResponse.json();

    if (data.messages?.[0]?.forwardTxHash) {
      mintTx = data.messages[0].forwardTxHash;
      console.log(); // New line after dots
    } else {
      process.stdout.write(".");
      await new Promise((resolve) => setTimeout(resolve, 2000));
    }
  }

  console.log("Mint Tx:", mintTx);
}

main().catch(console.error);
```

Run the script:

```bash theme={null}
npx tsx script.ts
```

## Pay fees upfront with a custom hook

The preceding steps use `depositForBurnWithFees`, which builds the default
`cctp-forward` hook for you. To attach your own hook data on the destination
blockchain, alongside forwarding, use `depositForBurnWithHookAndFees` and
provide the hook data explicitly.

<Warning>
  The quote binds to your call parameters, including the hook data. Request the
  quote with the same hook data you submit onchain. A quote requested with custom
  hook data is rejected by `depositForBurnWithFees` (which applies the default
  hook), and a quote requested without custom hook data is rejected by
  `depositForBurnWithHookAndFees`. Always pair the entry point with a matching
  quote.
</Warning>

### Build the hook data

Hook data is a sequence of hooks, each with a fixed 32-byte header followed by
its payload:

| Bytes | Type      | Data                          |
| ----- | --------- | ----------------------------- |
| 0-23  | `bytes24` | Hook name (magic)             |
| 24-27 | `uint32`  | Version                       |
| 28-31 | `uint32`  | Payload length in bytes (`n`) |
| 32+   | `bytes`   | Payload (`n` bytes)           |

The first hook must be the `cctp-forward` hook so that forwarding runs. You
append your own hook as another complete, correctly framed entry: appending raw,
unframed bytes causes the destination to reject the hook data. For the
`cctp-forward` layout, see
[Forwarding Service hook format](/cctp/concepts/forwarding-service#hook-format).

```typescript theme={null}
import { concat, stringToHex, toHex } from "viem";

// cctp-forward hook, framed with the shared 32-byte header
const forwardHook = concat([
  stringToHex("cctp-forward", { size: 24 }), // hook name
  toHex(1, { size: 4 }), // version
  toHex(0, { size: 4 }), // payload length (no payload)
]);

// A placeholder custom hook, framed the same way. The destination ignores it
// unless it recognizes the hook name; replace it with a hook your destination
// logic supports.
const customPayload = "0xdeadbeef";
const customHook = concat([
  stringToHex("my-custom-hook", { size: 24 }), // hook name
  toHex(1, { size: 4 }), // version
  toHex(4, { size: 4 }), // payload length (4 bytes)
  customPayload,
]);

const hookData = concat([forwardHook, customHook]);
console.log("Hook data:", hookData);
```

### Request a matching quote

Include the same `hookData` in the `FORWARD` request's `params` so the quote
binds to it:

```typescript theme={null}
const quoteResponse = await fetch(
  "https://iris-api-sandbox.circle.com/v2/quote/burn/usdc/6/26",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      amount: transferAmount.toString(),
      feeToken: sourceUsdc.address,
      requests: [
        { type: "FORWARD", params: { hookData } },
        { type: "PRE_FINALITY" },
      ],
    }),
  },
);
const quote = await quoteResponse.json();
console.log("Quote:", JSON.stringify(quote, null, 2));
```

### Submit the transfer with `depositForBurnWithHookAndFees`

Pass the same `hookData` as a parameter, along with the `QuoteClaim`:

```typescript theme={null}
import { pad, encodeFunctionData } from "viem";

const burnTx = await client.sendTransaction({
  to: TESTNET_TOKEN_MESSENGER_WITH_FEES,
  data: encodeFunctionData({
    abi: [
      {
        type: "function",
        name: "depositForBurnWithHookAndFees",
        stateMutability: "payable",
        inputs: [
          { name: "amount", type: "uint256" },
          { name: "destinationDomain", type: "uint32" },
          { name: "mintRecipient", type: "bytes32" },
          { name: "burnToken", type: "address" },
          { name: "destinationCaller", type: "bytes32" },
          { name: "hookData", type: "bytes" },
          {
            name: "claim",
            type: "tuple",
            components: [
              { name: "signedQuote", type: "bytes" },
              { name: "refundAddress", type: "address" },
            ],
          },
        ],
        outputs: [],
      },
    ],
    functionName: "depositForBurnWithHookAndFees",
    args: [
      transferAmount,
      ARC_TESTNET_DOMAIN,
      pad(DESTINATION_ADDRESS, { size: 32 }),
      sourceUsdc.address,
      pad("0x", { size: 32 }), // destinationCaller (empty = any caller)
      hookData,
      { signedQuote: quote.signedQuote, refundAddress: account.address },
    ],
  }),
});
console.log("Burn Tx:", burnTx);
```

Approve USDC and verify the mint the same way as
[Step 3](#step-3-approve-the-usdc-transfer) and
[Step 5](#step-5-verify-the-mint-transaction).
