Skip to main content
This guide walks you through the process of creating Unified Crosschain USDC Balances on Solana using Circle Gateway, and performing transfers from EVM to Solana and from Solana to Solana. Select a tab below for the Circle Wallets or self-managed wallet path.

Prerequisites

Before you begin, ensure that you’ve:If you want to try the EVM to Solana transfer, ensure that you’ve:
  • Created EVM Developer-Controlled Wallets on the source chains you want to test
  • Created a Solana Devnet Developer-Controlled Wallet to receive the minted USDC
  • Completed the deposit flow from the EVM quickstart first

Add testnet funds to your wallet

To interact with Gateway, you need test USDC and native tokens in your wallet on each chain you deposit from. You also need SOL on the destination wallet to create the recipient Associated Token Account and call the Gateway Minter program.Use the Circle Faucet to get test USDC. If you have a Circle Developer Console account, you can use the Console Faucet to get testnet native tokens. In addition, the following faucets can also be used to fund your wallet with testnet native tokens:
Faucet: Arc Testnet (USDC + native tokens)
PropertyValue
Chain namearcTestnet
USDC address0x3600000000000000000000000000000000000000
Domain ID26

Step 1. Set up your project

1.1. Create the project and install dependencies

mkdir unified-gateway-balance-solana-circle-wallets
cd unified-gateway-balance-solana-circle-wallets

npm init -y
npm pkg set type=module
npm pkg set scripts.deposit="tsx --env-file=.env deposit.ts"
npm pkg set scripts.balances="tsx --env-file=.env balances.ts"
npm pkg set scripts.transfer-from-sol="tsx --env-file=.env transfer-from-sol.ts"
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts --"

npm pkg set overrides.bigint-buffer=npm:@trufflesuite/bigint-buffer@1.1.10

npm install @circle-fin/developer-controlled-wallets @coral-xyz/anchor @solana/buffer-layout @solana/spl-token @solana/web3.js bs58 bn.js tsx typescript
npm install --save-dev @types/node @types/bn.js

1.2. Configure TypeScript (optional)

This step is optional. It helps prevent missing types in your IDE or editor.
Create a tsconfig.json file:
npx tsc --init
Then, update the tsconfig.json file:
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3. Set environment variables

Create a .env file in the project directory:
.env
CIRCLE_API_KEY=YOUR_API_KEY
CIRCLE_ENTITY_SECRET=YOUR_ENTITY_SECRET
DEPOSITOR_ADDRESS=YOUR_SOURCE_WALLET_ADDRESS
RECIPIENT_ADDRESS=YOUR_DESTINATION_WALLET_ADDRESS
  • CIRCLE_API_KEY is your Circle API key.
  • CIRCLE_ENTITY_SECRET is your Circle entity secret.
  • DEPOSITOR_ADDRESS is the source depositor wallet for the script you are running.
  • RECIPIENT_ADDRESS is the destination wallet that receives the minted USDC.
For transfer-from-sol.ts, both values are Solana addresses.For transfer-from-evm.ts, DEPOSITOR_ADDRESS is an EVM address and RECIPIENT_ADDRESS is a Solana address.
Prefer editing .env files in your IDE or editor so credentials are not leaked to your shell history.

Step 2. Set up the configuration file

The shared Solana configuration and helpers are used by the deposit and transfer scripts.

2.1. Create the configuration file

touch config.ts

2.2. Configure Solana settings and Gateway helpers

Add the shared Solana RPC configuration, Gateway addresses, IDLs, attestation decoding helpers, and Circle Wallets signing helpers to config.ts.
config.ts
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
import { Connection, PublicKey, Transaction } from "@solana/web3.js";
import {
  u32be,
  nu64be,
  struct,
  seq,
  blob,
  offset,
  Layout,
} from "@solana/buffer-layout";
import bs58 from "bs58";

export const RPC_ENDPOINT = "https://api.devnet.solana.com";
export const SOLANA_DOMAIN = 5;
export const SOLANA_ZERO_ADDRESS = "11111111111111111111111111111111";

export const GATEWAY_WALLET_ADDRESS =
  "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
export const GATEWAY_MINTER_ADDRESS =
  "GATEmKK2ECL1brEngQZWCgMWPbvrEYqsV6u29dAaHavr";
export const USDC_ADDRESS = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";

const API_KEY = process.env.CIRCLE_API_KEY!;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET!;

if (!API_KEY || !ENTITY_SECRET) {
  console.error(
    "Missing required env vars: CIRCLE_API_KEY, CIRCLE_ENTITY_SECRET",
  );
  process.exit(1);
}

export const client = initiateDeveloperControlledWalletsClient({
  apiKey: API_KEY,
  entitySecret: ENTITY_SECRET,
});

export class PublicKeyLayout extends Layout<PublicKey> {
  constructor(property: string) {
    super(32, property);
  }
  decode(b: Buffer, offset = 0): PublicKey {
    return new PublicKey(b.subarray(offset, offset + 32));
  }
  encode(src: PublicKey, b: Buffer, offset = 0): number {
    const pubkeyBuffer = src.toBuffer();
    pubkeyBuffer.copy(b, offset);
    return 32;
  }
}

export const publicKey = (property: string) => new PublicKeyLayout(property);

const MintAttestationElementLayout = struct([
  publicKey("destinationToken"),
  publicKey("destinationRecipient"),
  nu64be("value"),
  blob(32, "transferSpecHash"),
  u32be("hookDataLength"),
  blob(offset(u32be(), -4), "hookData"),
] as any);

const MintAttestationSetLayout = struct([
  u32be("magic"),
  u32be("version"),
  u32be("destinationDomain"),
  publicKey("destinationContract"),
  publicKey("destinationCaller"),
  nu64be("maxBlockHeight"),
  u32be("numAttestations"),
  seq(MintAttestationElementLayout, offset(u32be(), -4), "attestations"),
] as any);

export const gatewayWalletIdl = {
  address: GATEWAY_WALLET_ADDRESS,
  metadata: {
    name: "gatewayWallet",
    version: "0.1.0",
    spec: "0.1.0",
  },
  instructions: [
    {
      name: "deposit",
      discriminator: [22, 0],
      accounts: [
        { name: "payer", writable: true, signer: true },
        { name: "owner", signer: true },
        { name: "gatewayWallet" },
        { name: "ownerTokenAccount", writable: true },
        { name: "custodyTokenAccount", writable: true },
        { name: "deposit", writable: true },
        { name: "depositorDenylist" },
        { name: "tokenProgram" },
        { name: "systemProgram" },
        { name: "eventAuthority" },
        { name: "program" },
      ],
      args: [{ name: "amount", type: "u64" }],
    },
  ],
};

export const gatewayMinterIdl = {
  address: GATEWAY_MINTER_ADDRESS,
  metadata: { name: "gatewayMinter", version: "0.1.0", spec: "0.1.0" },
  instructions: [
    {
      name: "gatewayMint",
      discriminator: [12, 0],
      accounts: [
        { name: "payer", writable: true, signer: true },
        { name: "destinationCaller", signer: true },
        { name: "gatewayMinter" },
        { name: "systemProgram" },
        { name: "tokenProgram" },
        { name: "eventAuthority" },
        { name: "program" },
      ],
      args: [
        {
          name: "params",
          type: { defined: { name: "gatewayMintParams" } },
        },
      ],
    },
  ],
  types: [
    {
      name: "gatewayMintParams",
      type: {
        kind: "struct",
        fields: [
          { name: "attestation", type: "bytes" },
          { name: "signature", type: "bytes" },
        ],
      },
    },
  ],
};

export function findDepositPDAs(
  programId: PublicKey,
  usdcMint: PublicKey,
  owner: PublicKey,
) {
  return {
    wallet: PublicKey.findProgramAddressSync(
      [Buffer.from("gateway_wallet")],
      programId,
    )[0],
    custody: PublicKey.findProgramAddressSync(
      [Buffer.from("gateway_wallet_custody"), usdcMint.toBuffer()],
      programId,
    )[0],
    deposit: PublicKey.findProgramAddressSync(
      [Buffer.from("gateway_deposit"), usdcMint.toBuffer(), owner.toBuffer()],
      programId,
    )[0],
    denylist: PublicKey.findProgramAddressSync(
      [Buffer.from("denylist"), owner.toBuffer()],
      programId,
    )[0],
  };
}

export function findCustodyPda(
  mint: PublicKey,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("gateway_minter_custody"), mint.toBuffer()],
    minterProgramId,
  )[0];
}

export function findTransferSpecHashPda(
  transferSpecHash: Uint8Array | Buffer,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("used_transfer_spec_hash"), Buffer.from(transferSpecHash)],
    minterProgramId,
  )[0];
}

export function decodeAttestationSet(attestation: string) {
  const buffer = Buffer.from(attestation.slice(2), "hex");
  return MintAttestationSetLayout.decode(buffer) as {
    attestations: Array<{
      destinationToken: PublicKey;
      destinationRecipient: PublicKey;
      transferSpecHash: Uint8Array;
    }>;
  };
}

export function solanaAddressToBytes32(address: string): string {
  const decoded = Buffer.from(bs58.decode(address));
  return `0x${decoded.toString("hex")}`;
}

export function hexToPublicKey(hex: string): PublicKey {
  return new PublicKey(Buffer.from(hex.slice(2), "hex"));
}

export async function signAndBroadcast(
  circleClient: ReturnType<typeof initiateDeveloperControlledWalletsClient>,
  connection: Connection,
  transaction: Transaction,
  walletAddress: string,
  label: string,
): Promise<string> {
  const serialized = transaction.serialize({
    requireAllSignatures: false,
    verifySignatures: false,
  });

  console.log(`Signing ${label} via Circle Wallets...`);
  const signResult = await circleClient.signTransaction({
    walletAddress,
    blockchain: "SOL-DEVNET",
    rawTransaction: serialized.toString("base64"),
  });

  const signedTxBase64 = signResult.data?.signedTransaction;
  if (!signedTxBase64) throw new Error(`Failed to sign ${label}`);

  console.log(`Broadcasting ${label}...`);
  const signedTxBytes = Buffer.from(signedTxBase64, "base64");
  return connection.sendRawTransaction(signedTxBytes);
}

export function stringifyTypedData<T>(obj: T) {
  return JSON.stringify(obj, (_key, value) =>
    typeof value === "bigint" ? value.toString() : value,
  );
}

Step 3. Deposit into a unified crosschain balance (Circle Wallets)

The deposit script submits a Gateway deposit on Solana Devnet. You can skip to the full deposit script if you prefer.
Do not send USDC directly to the Gateway Wallet address or custody account. You must use a Gateway deposit instruction for the funds to be credited to your unified balance.

3.1. Create the deposit script

touch deposit.ts

3.2. Define constants and helpers

Set the deposit amount near the top of the file, then derive the owner ATA and load the account so the script can validate balance before it builds the Gateway instruction.
const DEPOSIT_AMOUNT = new BN(1_000_000);

3.3. Initialize connection, Anchor client, and validate balance

The Solana version follows the same teaching order as the standard quickstart: initialize the connection, check the source wallet balance, then set up the Anchor client and derive the Gateway PDAs.
const connection = new Connection(RPC_ENDPOINT, "confirmed");
const usdcMint = new PublicKey(USDC_ADDRESS);
const programId = new PublicKey(GATEWAY_WALLET_ADDRESS);
const owner = new PublicKey(DEPOSITOR_ADDRESS);

const userTokenAccount = getAssociatedTokenAddressSync(usdcMint, owner);
const tokenAccountInfo = await getAccount(connection, userTokenAccount);

const dummyWallet = new Wallet(Keypair.generate());
const provider = new AnchorProvider(
  connection,
  dummyWallet,
  AnchorProvider.defaultOptions(),
);

3.4. Execute the deposit

After the balance check and PDA derivation, build the Gateway deposit instruction, sign it with Circle Wallets, broadcast it, and wait for Solana confirmation.
const depositIx = await program.methods
  .deposit(DEPOSIT_AMOUNT)
  .accountsPartial({
    payer: owner,
    owner: owner,
    gatewayWallet: pdas.wallet,
    ownerTokenAccount: userTokenAccount,
    custodyTokenAccount: pdas.custody,
    deposit: pdas.deposit,
    depositorDenylist: pdas.denylist,
    tokenProgram: TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  })
  .instruction();

const txSignature = await signAndBroadcast(
  client,
  connection,
  transaction,
  DEPOSITOR_ADDRESS,
  "deposit",
);

3.5. Full deposit script (Circle Wallets)

The script validates the source balance, builds the Gateway deposit instruction, and confirms the deposit on Solana Devnet. Inline comments explain each stage.
deposit.ts
import {
  Wallet,
  AnchorProvider,
  setProvider,
  Program,
} from "@coral-xyz/anchor";
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";
import {
  getAssociatedTokenAddressSync,
  getAccount,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import BN from "bn.js";
import {
  RPC_ENDPOINT,
  GATEWAY_WALLET_ADDRESS,
  USDC_ADDRESS,
  client,
  gatewayWalletIdl,
  findDepositPDAs,
  signAndBroadcast,
} from "./config.js";

const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

if (!DEPOSITOR_ADDRESS) {
  console.error("Missing required env var: DEPOSITOR_ADDRESS");
  process.exit(1);
}

const DEPOSIT_AMOUNT = new BN(1_000_000);

async function main() {
  // Set up the Solana connection and core account addresses.
  const connection = new Connection(RPC_ENDPOINT, "confirmed");
  const usdcMint = new PublicKey(USDC_ADDRESS);
  const programId = new PublicKey(GATEWAY_WALLET_ADDRESS);
  const owner = new PublicKey(DEPOSITOR_ADDRESS);

  console.log(`Using account: ${owner.toBase58()}`);
  console.log(`\n=== Processing Solana Devnet ===`);

  // [1] Check the depositor's current USDC balance.
  const userTokenAccount = getAssociatedTokenAddressSync(usdcMint, owner);
  const tokenAccountInfo = await getAccount(connection, userTokenAccount);
  const currentBalance = Number(tokenAccountInfo.amount) / 1_000_000;
  console.log(`Current balance: ${currentBalance} USDC`);

  if (tokenAccountInfo.amount < BigInt(DEPOSIT_AMOUNT.toString())) {
    throw new Error(
      "Insufficient USDC balance. Please top up at https://faucet.circle.com",
    );
  }

  // [2] Set up the Anchor client and derive the Gateway deposit PDAs.
  const dummyWallet = new Wallet(Keypair.generate());
  const provider = new AnchorProvider(
    connection,
    dummyWallet,
    AnchorProvider.defaultOptions(),
  );
  setProvider(provider);
  const program = new Program(gatewayWalletIdl, provider);
  const pdas = findDepositPDAs(programId, usdcMint, owner);

  // [3] Build, sign, and confirm the Gateway deposit transaction.
  const depositIx = await program.methods
    .deposit(DEPOSIT_AMOUNT)
    .accountsPartial({
      payer: owner,
      owner: owner,
      gatewayWallet: pdas.wallet,
      ownerTokenAccount: userTokenAccount,
      custodyTokenAccount: pdas.custody,
      deposit: pdas.deposit,
      depositorDenylist: pdas.denylist,
      tokenProgram: TOKEN_PROGRAM_ID,
      systemProgram: SystemProgram.programId,
    })
    .instruction();

  const { blockhash, lastValidBlockHeight } =
    await connection.getLatestBlockhash();
  const transaction = new Transaction();
  transaction.add(depositIx);
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = owner;

  const txSignature = await signAndBroadcast(
    client,
    connection,
    transaction,
    DEPOSITOR_ADDRESS,
    "deposit",
  );

  await connection.confirmTransaction(
    { signature: txSignature, blockhash, lastValidBlockHeight },
    "confirmed",
  );

  console.log(`Done on Solana Devnet. Deposit tx: ${txSignature}`);
}

/* Main invocation */
main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});

3.6. Run the deposit script

npm run deposit
Wait for the required number of block confirmations. Once the deposit transaction is final, your Gateway balance on Solana Devnet will be updated. Solana Devnet transactions typically reach finality in seconds.

3.7. Check the balances on the Gateway Wallet

Create a new file called balances.ts, and add the following code. This script retrieves the USDC balances available from your Gateway Wallet for the DEPOSITOR_ADDRESS currently set in .env.
balances.ts
interface GatewayBalancesResponse {
  balances: Array<{
    domain: number;
    balance: string;
  }>;
}

const EVM_DOMAINS = {
  ethereum: 0,
  avalanche: 1,
  optimism: 2,
  arbitrum: 3,
  base: 6,
  polygon: 7,
  unichain: 10,
  arc: 26,
};

const SOLANA_DOMAINS = {
  solana: 5,
};

const DOMAINS = { ...EVM_DOMAINS, ...SOLANA_DOMAINS };

const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

if (!DEPOSITOR_ADDRESS) {
  console.error("Missing required env var: DEPOSITOR_ADDRESS");
  process.exit(1);
}

const isEvmAddress = DEPOSITOR_ADDRESS.startsWith("0x");

async function main() {
  console.log(`Depositor address: ${DEPOSITOR_ADDRESS}`);
  console.log(`Address type: ${isEvmAddress ? "EVM" : "Solana"}\n`);

  const activeDomains = isEvmAddress ? EVM_DOMAINS : SOLANA_DOMAINS;
  const domainIds = Object.values(activeDomains);
  const body = {
    token: "USDC",
    sources: domainIds.map((domain) => ({
      domain,
      depositor: DEPOSITOR_ADDRESS,
    })),
  };

  const res = await fetch(
    "https://gateway-api-testnet.circle.com/v1/balances",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );

  const result = (await res.json()) as GatewayBalancesResponse;

  let total = 0;
  for (const balance of result.balances) {
    const chain =
      Object.keys(DOMAINS).find(
        (k) => DOMAINS[k as keyof typeof DOMAINS] === balance.domain,
      ) || `Domain ${balance.domain}`;
    const amount = parseFloat(balance.balance);
    console.log(`${chain}: ${amount.toFixed(6)} USDC`);
    total += amount;
  }

  console.log(`\nTotal: ${total.toFixed(6)} USDC`);
}

main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});
You can run it to verify your balance on Gateway.
npm run balances

Step 4. Transfer USDC from Solana to Solana

The transfer script burns USDC on your Solana Devnet Gateway balance and mints to a recipient on Solana Devnet via Gateway. You can skip to the full transfer script if you prefer.

4.1. Create the Solana transfer script

touch transfer-from-sol.ts

4.2. Define constants and types

This flow uses the same Solana burn intent layout as the standard Gateway quickstart, but swaps in Circle Wallet signing for both the burn intent and the mint transaction.
const TRANSFER_AMOUNT = 0.1;
const TRANSFER_VALUE = BigInt(Math.floor(TRANSFER_AMOUNT * 1e6));
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;

4.3. Add helper functions

The helper layer encodes the Solana burn intent, creates a lightweight Anchor provider, and exposes the address conversion utilities required for Gateway minting.
function createProvider(connection: Connection) {
  const dummyWallet = new Wallet(Keypair.generate());
  const provider = new AnchorProvider(
    connection,
    dummyWallet,
    AnchorProvider.defaultOptions(),
  );
  setProvider(provider);
  return provider;
}

4.4. Initialize connection and create recipient ATA

Before minting to the destination wallet, derive the recipient Associated Token Account and create it idempotently with the destination Developer-Controlled Wallet.
const provider = createProvider(connection);

const recipientAta = getAssociatedTokenAddressSync(usdcMint, recipientPubkey);
const ataTx = new Transaction();
ataTx.add(
  createAssociatedTokenAccountIdempotentInstruction(
    owner,
    recipientAta,
    recipientPubkey,
    usdcMint,
  ),
);

4.5. Create and sign burn intent

Encode the Solana burn intent, prefix the payload, and sign it with the source Developer-Controlled Wallet.
const burnIntent = createBurnIntent({
  sourceDepositor: owner.toBase58(),
  destinationRecipient: recipientAta.toBase58(),
  sourceSigner: owner.toBase58(),
});

const sigResult = await client.signMessage({
  walletAddress: DEPOSITOR_ADDRESS,
  blockchain: "SOL-DEVNET",
  encodedByHex: true,
  message: "0x" + prefixed.toString("hex"),
});

4.6. Request attestation from Gateway API

Submit the signed burn intent to the Gateway API and decode the attestation set that comes back from the response.
const response = await fetch(
  "https://gateway-api-testnet.circle.com/v1/transfer",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: stringifyTypedData(request),
  },
);

4.7. Set up minter client

Once the API returns the attestation, initialize the Gateway Minter program and derive the PDA accounts needed for the Solana mint.
const minterProgram = new Program(gatewayMinterIdl, provider);
const [minterPda] = PublicKey.findProgramAddressSync(
  [Buffer.from(utils.bytes.utf8.encode("gateway_minter"))],
  minterProgramId,
);

4.8. Mint on Solana

Create the mint instruction, sign the transaction with the destination Developer-Controlled Wallet, and confirm it on Solana Devnet.
const mintIx = await minterProgram.methods
  .gatewayMint({
    attestation: attestationBytes,
    signature: signatureBytes,
  })
  .accountsPartial({
    gatewayMinter: minterPda,
    destinationCaller: owner,
    payer: owner,
    systemProgram: SystemProgram.programId,
    tokenProgram: TOKEN_PROGRAM_ID,
  })
  .remainingAccounts(remainingAccounts)
  .instruction();

4.9. Full Solana transfer script (Circle Wallets)

The script creates the recipient ATA, signs a Solana burn intent, requests a Gateway attestation, and mints on Solana Devnet. Inline comments explain each stage.
transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import {
  Wallet,
  AnchorProvider,
  setProvider,
  Program,
  utils,
} from "@coral-xyz/anchor";
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";
import {
  TOKEN_PROGRAM_ID,
  getAssociatedTokenAddressSync,
  createAssociatedTokenAccountIdempotentInstruction,
} from "@solana/spl-token";
import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
import {
  RPC_ENDPOINT,
  GATEWAY_WALLET_ADDRESS,
  GATEWAY_MINTER_ADDRESS,
  USDC_ADDRESS,
  SOLANA_DOMAIN,
  SOLANA_ZERO_ADDRESS,
  client,
  gatewayMinterIdl,
  publicKey,
  hexToPublicKey,
  solanaAddressToBytes32,
  decodeAttestationSet,
  findCustodyPda,
  findTransferSpecHashPda,
  signAndBroadcast,
  stringifyTypedData,
} from "./config.js";

const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

if (!DEPOSITOR_ADDRESS || !RECIPIENT_ADDRESS) {
  console.error(
    "Missing required env vars: DEPOSITOR_ADDRESS, RECIPIENT_ADDRESS",
  );
  process.exit(1);
}

const TRANSFER_AMOUNT = 0.1;
const TRANSFER_VALUE = BigInt(Math.floor(TRANSFER_AMOUNT * 1e6));
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;

const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;

// Custom layout for 256-bit unsigned integers.
class UInt256BE extends Layout<bigint> {
  constructor(property: string) {
    super(32, property);
  }
  decode(b: Buffer, offset = 0) {
    const buffer = b.subarray(offset, offset + 32);
    return buffer.readBigUInt64BE(24);
  }
  encode(src: bigint, b: Buffer, offset = 0) {
    const buffer = Buffer.alloc(32);
    buffer.writeBigUInt64BE(BigInt(src), 24);
    buffer.copy(b, offset);
    return 32;
  }
}

const uint256be = (property: string) => new UInt256BE(property);

const BurnIntentLayout = struct([
  u32be("magic"),
  uint256be("maxBlockHeight"),
  uint256be("maxFee"),
  u32be("transferSpecLength"),
  struct(
    [
      u32be("magic"),
      u32be("version"),
      u32be("sourceDomain"),
      u32be("destinationDomain"),
      publicKey("sourceContract"),
      publicKey("destinationContract"),
      publicKey("sourceToken"),
      publicKey("destinationToken"),
      publicKey("sourceDepositor"),
      publicKey("destinationRecipient"),
      publicKey("sourceSigner"),
      publicKey("destinationCaller"),
      uint256be("value"),
      blob(32, "salt"),
      u32be("hookDataLength"),
      blob(offset(u32be(), -4), "hookData"),
    ] as any,
    "spec",
  ),
] as any);

function createBurnIntent(params: {
  sourceDepositor: string;
  destinationRecipient: string;
  sourceSigner: string;
}) {
  const { sourceDepositor, destinationRecipient, sourceSigner } = params;

  return {
    maxBlockHeight: MAX_UINT64,
    maxFee: MAX_FEE,
    spec: {
      version: 1,
      sourceDomain: SOLANA_DOMAIN,
      destinationDomain: SOLANA_DOMAIN,
      sourceContract: solanaAddressToBytes32(GATEWAY_WALLET_ADDRESS),
      destinationContract: solanaAddressToBytes32(GATEWAY_MINTER_ADDRESS),
      sourceToken: solanaAddressToBytes32(USDC_ADDRESS),
      destinationToken: solanaAddressToBytes32(USDC_ADDRESS),
      sourceDepositor: solanaAddressToBytes32(sourceDepositor),
      destinationRecipient: solanaAddressToBytes32(destinationRecipient),
      sourceSigner: solanaAddressToBytes32(sourceSigner),
      destinationCaller: solanaAddressToBytes32(SOLANA_ZERO_ADDRESS),
      value: TRANSFER_VALUE,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

// Encode the burn intent into the binary layout expected by Gateway.
function encodeBurnIntent(bi: ReturnType<typeof createBurnIntent>): Buffer {
  const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
  const prepared = {
    magic: BURN_INTENT_MAGIC,
    maxBlockHeight: bi.maxBlockHeight,
    maxFee: bi.maxFee,
    transferSpecLength: 340 + hookData.length,
    spec: {
      magic: TRANSFER_SPEC_MAGIC,
      version: bi.spec.version,
      sourceDomain: bi.spec.sourceDomain,
      destinationDomain: bi.spec.destinationDomain,
      sourceContract: hexToPublicKey(bi.spec.sourceContract),
      destinationContract: hexToPublicKey(bi.spec.destinationContract),
      sourceToken: hexToPublicKey(bi.spec.sourceToken),
      destinationToken: hexToPublicKey(bi.spec.destinationToken),
      sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
      destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
      sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
      destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
      value: bi.spec.value,
      salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
      hookDataLength: hookData.length,
      hookData,
    },
  };
  const buffer = Buffer.alloc(72 + 340 + hookData.length);
  const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
  return buffer.subarray(0, bytesWritten);
}

// Create a lightweight Anchor provider for PDA derivation and instruction building.
function createProvider(connection: Connection) {
  const dummyWallet = new Wallet(Keypair.generate());
  const provider = new AnchorProvider(
    connection,
    dummyWallet,
    AnchorProvider.defaultOptions(),
  );
  setProvider(provider);
  return provider;
}

async function main() {
  // Set up the Solana connection and destination accounts.
  const connection = new Connection(RPC_ENDPOINT, "confirmed");
  const usdcMint = new PublicKey(USDC_ADDRESS);
  const minterProgramId = new PublicKey(GATEWAY_MINTER_ADDRESS);
  const owner = new PublicKey(DEPOSITOR_ADDRESS);
  const recipientPubkey = new PublicKey(RECIPIENT_ADDRESS);

  console.log(`Using account: ${owner.toBase58()}`);
  console.log(`Transferring from: Solana Devnet -> Solana Devnet`);

  const provider = createProvider(connection);

  // [1] Create the recipient ATA if it does not already exist.
  const recipientAta = getAssociatedTokenAddressSync(usdcMint, recipientPubkey);
  const { blockhash: ataBlockhash, lastValidBlockHeight: ataBlockHeight } =
    await connection.getLatestBlockhash();
  const ataTx = new Transaction();
  ataTx.add(
    createAssociatedTokenAccountIdempotentInstruction(
      owner,
      recipientAta,
      recipientPubkey,
      usdcMint,
    ),
  );
  ataTx.recentBlockhash = ataBlockhash;
  ataTx.feePayer = owner;

  const ataSig = await signAndBroadcast(
    client,
    connection,
    ataTx,
    DEPOSITOR_ADDRESS,
    "ATA creation",
  );
  await connection.confirmTransaction(
    {
      signature: ataSig,
      blockhash: ataBlockhash,
      lastValidBlockHeight: ataBlockHeight,
    },
    "confirmed",
  );

  // [2] Create and sign the Solana burn intent.
  const burnIntent = createBurnIntent({
    sourceDepositor: owner.toBase58(),
    destinationRecipient: recipientAta.toBase58(),
    sourceSigner: owner.toBase58(),
  });

  const encoded = encodeBurnIntent(burnIntent);
  const prefixed = Buffer.concat([
    Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
    encoded,
  ]);

  const sigResult = await client.signMessage({
    walletAddress: DEPOSITOR_ADDRESS,
    blockchain: "SOL-DEVNET",
    encodedByHex: true,
    message: "0x" + prefixed.toString("hex"),
  });

  const burnIntentSignature = sigResult.data?.signature;
  if (!burnIntentSignature) throw new Error("Failed to sign burn intent");

  const formattedSignature = burnIntentSignature.startsWith("0x")
    ? burnIntentSignature
    : `0x${burnIntentSignature}`;

  const request = [{ burnIntent, signature: formattedSignature }];
  console.log("Signed burn intent.");

  // [3] Request the attestation set from Gateway API.
  const response = await fetch(
    "https://gateway-api-testnet.circle.com/v1/transfer",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: stringifyTypedData(request),
    },
  );

  const json = (await response.json()) as {
    attestation: string;
    signature: string;
    success?: boolean;
    message?: string;
  };
  if (json.success === false) {
    throw new Error(`Gateway API error: ${json.message}`);
  }
  console.log("Gateway API response:", JSON.stringify(json, null, 2));

  const { attestation, signature: mintSignature } = json;
  const decoded = decodeAttestationSet(attestation);

  // [4] Set up the Gateway Minter client and remaining accounts.
  const minterProgram = new Program(gatewayMinterIdl, provider);
  const [minterPda] = PublicKey.findProgramAddressSync(
    [Buffer.from(utils.bytes.utf8.encode("gateway_minter"))],
    minterProgramId,
  );

  const remainingAccounts = decoded.attestations.flatMap((e) => [
    {
      pubkey: findCustodyPda(e.destinationToken, minterProgramId),
      isWritable: true,
      isSigner: false,
    },
    { pubkey: e.destinationRecipient, isWritable: true, isSigner: false },
    {
      pubkey: findTransferSpecHashPda(e.transferSpecHash, minterProgramId),
      isWritable: true,
      isSigner: false,
    },
  ]);

  const attestationBytes = Buffer.from(attestation.slice(2), "hex");
  const signatureBytes = Buffer.from(mintSignature.slice(2), "hex");

  // [5] Mint on Solana with the returned attestation.
  console.log("Minting funds on Solana Devnet...");
  const mintIx = await minterProgram.methods
    .gatewayMint({
      attestation: attestationBytes,
      signature: signatureBytes,
    })
    .accountsPartial({
      gatewayMinter: minterPda,
      destinationCaller: owner,
      payer: owner,
      systemProgram: SystemProgram.programId,
      tokenProgram: TOKEN_PROGRAM_ID,
    })
    .remainingAccounts(remainingAccounts)
    .instruction();

  const { blockhash, lastValidBlockHeight } =
    await connection.getLatestBlockhash();
  const mintTx = new Transaction();
  mintTx.add(mintIx);
  mintTx.recentBlockhash = blockhash;
  mintTx.feePayer = owner;

  const mintSig = await signAndBroadcast(
    client,
    connection,
    mintTx,
    DEPOSITOR_ADDRESS,
    "mint",
  );

  await connection.confirmTransaction(
    { signature: mintSig, blockhash, lastValidBlockHeight },
    "confirmed",
  );

  console.log(`Minted ${Number(TRANSFER_VALUE) / 1_000_000} USDC`);
  console.log(`Mint transaction hash (Solana Devnet):`, mintSig);
}

/* Main invocation */
main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});

4.10. Run the Solana to Solana transfer script

npm run transfer-from-sol