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.

Prerequisites

Before you begin, ensure that you’ve:
  • Installed Node.js v22+
  • Prepared Solana Devnet wallets (sender and recipient) and have the private key pairs exported as JSON arrays
If you want to try the EVM to Solana transfer, ensure that you’ve:

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 testnet native tokens on the destination chain to call the Gateway Minter contract. Use the Circle Faucet to get testnet 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

This step shows you how to prepare your project and environment.

1.1. Create a new project

Create a new directory and install the required dependencies:
# Set up your directory and initialize a Node.js project
mkdir unified-gateway-balance-sol
cd unified-gateway-balance-sol
npm init -y

# Set up module type and run scripts
npm pkg set type=module
npm pkg set scripts.deposit="tsx --env-file=.env deposit.ts"
npm pkg set scripts.transfer-from-sol="tsx --env-file=.env transfer-from-sol.ts"
npm pkg set scripts.balance="tsx --env-file=.env balance.ts"

# Install dependencies
npm pkg set overrides.bigint-buffer=npm:@trufflesuite/bigint-buffer@1.1.10
npm install @coral-xyz/anchor @solana/buffer-layout @solana/spl-token @solana/web3.js bs58 tsx typescript
npm install --save-dev @types/node
If you want to try the EVM to Solana transfer, add the run script and install Viem :
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts"
npm install viem

1.2. Initialize and configure the project

This command creates a tsconfig.json file:
Shell
npx tsc --init
Then, edit the tsconfig.json file:
Shell
# Replace the contents of the generated file
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3 Configure environment variables

Create a .env file in the project directory and add your Solana keypair, replacing and with the your actual keypairs as JSON arrays.
echo "SOLANA_PRIVATE_KEYPAIR={YOUR_SOLANA_KEYPAIR_ARRAY}
RECIPIENT_KEYPAIR={YOUR_RECIPIENT_KEYPAIR_ARRAY}" > .env
If your wallet exports a private key hash instead, you can use bs58 to convert it:
TypeScript
const bytes = bs58.decode({ YOUR_PRIVATE_KEY_HASH });
console.log(JSON.stringify(Array.from(bytes)));
If you want to try the EVM to Solana transfer, add your wallet private key, replacing with the private key from your EVM wallet.
echo "EVM_PRIVATE_KEY={YOUR_PRIVATE_KEY}" >> .env
Important: These are sensitive credentials. Do not commit them to version control or share them publicly.

Step 2: Set up the configuration file

This section covers the shared configuration file will be used by both the deposit and transfer scripts.

2.1. Create the configuration file

touch config.ts

2.2. Configure Solana settings and Gateway addresses

Add the Solana-specific configuration, Gateway contract addresses, and account setup helper to your config.ts file. This includes the RPC endpoint, USDC address, domain ID, and the IDL definitions for interacting with Gateway Wallet and Gateway Minter programs on Solana Devnet.
config.ts
import { Keypair } from "@solana/web3.js";

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

/* Gateway Contract Addresses */
export const GATEWAY_WALLET_ADDRESS =
  "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
export const GATEWAY_MINTER_ADDRESS =
  "GATEmKK2ECL1brEngQZWCgMWPbvrEYqsV6u29dAaHavr";
export const USDC_ADDRESS = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";

/* Account Setup Helper */
export function createKeypairFromEnv(privateKey: string): Keypair {
  const secretKey = JSON.parse(privateKey);
  return Keypair.fromSecretKey(Uint8Array.from(secretKey));
}

/* Gateway Wallet IDL (for deposits) */
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" }],
    },
  ],
};

/* Gateway Minter IDL (for transfers) */
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" },
        ],
      },
    },
  ],
};

Step 3: Deposit into a unified crosschain balance

This section explains parts of the deposit script that allows you to deposit USDC into the Gateway Wallet contract on Solana Devnet. You can skip to the full deposit script if you prefer.

3.1. Create the script file

touch deposit.ts

3.2. Define constants and helpers

You can adjust the DEPOSIT_AMOUNT to a different value. For now, it is set to 10 USDC.
deposit.ts
const DEPOSIT_AMOUNT = new BN(10000000); // 10 USDC (6 decimals)

/* Helpers */
function findPDAs(programId: PublicKey, usdcMint: PublicKey, owner: PublicKey) {
  return {
    wallet: PublicKey.findProgramAddressSync(
      [Buffer.from(utils.bytes.utf8.encode("gateway_wallet"))],
      programId,
    )[0],
    custody: PublicKey.findProgramAddressSync(
      [
        Buffer.from(utils.bytes.utf8.encode("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],
  };
}

3.3. Initialize connection, Anchor client, and validate balance

Initialize the Solana connection and keypair, set up the Anchor client with Program Derived Addresses (PDAs) for interacting with the Gateway Wallet contract, then verify sufficient USDC balance before depositing.
deposit.ts
const keypair = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR);
const connection = new Connection(RPC_ENDPOINT, "confirmed");
const programId = new PublicKey(GATEWAY_WALLET_ADDRESS);
const usdcMint = new PublicKey(USDC_ADDRESS);

console.log(`Using account: ${keypair.publicKey.toBase58()}`);

// Check USDC balance
const userAta = await getAssociatedTokenAddress(usdcMint, keypair.publicKey);
const ataInfo = await getAccount(connection, userAta);
const currentBalance = ataInfo.amount;

console.log(
  `Current balance: ${Number(currentBalance.toString()) / 1_000_000} USDC`,
);

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

const pdas = findPDAs(programId, usdcMint, keypair.publicKey);

const anchorWallet = new Wallet(keypair);
const provider = new AnchorProvider(
  connection,
  anchorWallet,
  AnchorProvider.defaultOptions(),
);
setProvider(provider);
const program = new Program(gatewayWalletIdl, provider);

3.4. Execute the deposit

deposit.ts
const txHash = await program.methods
  .deposit(DEPOSIT_AMOUNT)
  .accountsPartial({
    payer: keypair.publicKey,
    owner: keypair.publicKey,
    gatewayWallet: pdas.wallet,
    ownerTokenAccount: userAta,
    custodyTokenAccount: pdas.custody,
    deposit: pdas.deposit,
    depositorDenylist: pdas.denylist,
    tokenProgram: TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
  })
  .signers([keypair])
  .rpc();

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

3.5. Full deposit script

The complete deposit script initializes the Solana connection and Anchor client, validates the USDC balance, and deposits funds into the Gateway Wallet contract on Solana Devnet. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.
deposit.ts
import {
  Wallet,
  AnchorProvider,
  setProvider,
  Program,
  utils,
} from "@coral-xyz/anchor";
import { Connection, PublicKey, SystemProgram } from "@solana/web3.js";
import {
  getAssociatedTokenAddress,
  getAccount,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import BN from "bn.js";
import {
  RPC_ENDPOINT,
  GATEWAY_WALLET_ADDRESS,
  USDC_ADDRESS,
  createKeypairFromEnv,
  gatewayWalletIdl,
} from "./config.js";

const DEPOSIT_AMOUNT = new BN(10000000); // 10 USDC (6 decimals)

/* Helpers */
function findPDAs(programId: PublicKey, usdcMint: PublicKey, owner: PublicKey) {
  return {
    wallet: PublicKey.findProgramAddressSync(
      [Buffer.from(utils.bytes.utf8.encode("gateway_wallet"))],
      programId,
    )[0],
    custody: PublicKey.findProgramAddressSync(
      [
        Buffer.from(utils.bytes.utf8.encode("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],
  };
}

/* Main logic */
async function main() {
  if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
    throw new Error("SOLANA_PRIVATE_KEYPAIR not set in environment");
  }

  const keypair = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR);
  const connection = new Connection(RPC_ENDPOINT, "confirmed");
  const programId = new PublicKey(GATEWAY_WALLET_ADDRESS);
  const usdcMint = new PublicKey(USDC_ADDRESS);

  console.log(`Using account: ${keypair.publicKey.toBase58()}`);

  console.log(`\n=== Processing Solana Devnet ===`);

  // Check USDC balance
  const userAta = await getAssociatedTokenAddress(usdcMint, keypair.publicKey);
  const ataInfo = await getAccount(connection, userAta);
  const currentBalance = ataInfo.amount;

  console.log(
    `Current balance: ${Number(currentBalance.toString()) / 1_000_000} USDC`,
  );

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

  console.log(
    `Depositing ${Number(DEPOSIT_AMOUNT.toString()) / 1_000_000} USDC to Gateway Wallet`,
  );

  // Set up Anchor client
  const pdas = findPDAs(programId, usdcMint, keypair.publicKey);

  const anchorWallet = new Wallet(keypair);
  const provider = new AnchorProvider(
    connection,
    anchorWallet,
    AnchorProvider.defaultOptions(),
  );
  setProvider(provider);
  const program = new Program(gatewayWalletIdl, provider);

  // Execute deposit
  const txHash = await program.methods
    .deposit(DEPOSIT_AMOUNT)
    .accountsPartial({
      payer: keypair.publicKey,
      owner: keypair.publicKey,
      gatewayWallet: pdas.wallet,
      ownerTokenAccount: userAta,
      custodyTokenAccount: pdas.custody,
      deposit: pdas.deposit,
      depositorDenylist: pdas.denylist,
      tokenProgram: TOKEN_PROGRAM_ID,
      systemProgram: SystemProgram.programId,
    })
    .signers([keypair])
    .rpc();

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

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

3.6. Run the script to create a crosschain balance

Run the deposit script to deposit USDC into your Gateway balance on Solana Devnet.
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 on Solana Devnet.
balances.ts
import { Keypair } from "@solana/web3.js";

/* Constants */
const SOLANA_DOMAIN = 5;

/* Helpers */
function createKeypairFromEnv(privateKey: string): Keypair {
  const secretKey = JSON.parse(privateKey);
  return Keypair.fromSecretKey(Uint8Array.from(secretKey));
}

async function main() {
  if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
    throw new Error("SOLANA_PRIVATE_KEYPAIR not set in environment");
  }

  const keypair = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR);
  const depositor = keypair.publicKey.toBase58();

  console.log(`Depositor address: ${depositor}\n`);

  const body = {
    token: "USDC",
    sources: [{ domain: SOLANA_DOMAIN, depositor }],
  };

  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();

  for (const balance of result.balances) {
    const amount = parseFloat(balance.balance);
    console.log(`solanaDevnet: ${amount.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 balance

Step 4: Transfer USDC from the crosschain balance

This section explains parts of the transfer script that burns USDC from your Solana Devnet Gateway balance to a recipient on Solana Devnet via Gateway. You can skip to the full transfer script if you prefer.

4.1. Create the script file

touch transfer-from-sol.ts

4.2. Define constants and types

You can set the amount to be transferred from your Gateway balance by changing the TRANSFER_AMOUNT. For now, it is set to 1 USDC.
transfer-from-sol.ts
const TRANSFER_AMOUNT = 1; // 1 USDC
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;

/* Type definitions */
// Custom layout for Solana PublicKey (32 bytes)
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;
  }
}

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

// 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);

// Type 'as any' used due to @solana/buffer-layout's incomplete TypeScript definitions (archived Jan 2025)
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);

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);

4.3. Add helper functions

transfer-from-sol.ts
// Construct burn intent for a given source
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: addressToBytes32(GATEWAY_WALLET_ADDRESS),
      destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
      sourceToken: addressToBytes32(USDC_ADDRESS),
      destinationToken: addressToBytes32(USDC_ADDRESS),
      sourceDepositor: addressToBytes32(sourceDepositor),
      destinationRecipient: addressToBytes32(destinationRecipient),
      sourceSigner: addressToBytes32(sourceSigner),
      destinationCaller: addressToBytes32(SOLANA_ZERO_ADDRESS),
      value: TRANSFER_VALUE,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

// Encode burn intent as binary layout for signing
function encodeBurnIntent(bi: any): 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);
}

// Sign burn intent with Ed25519 keypair
function signBurnIntent(keypair: Keypair, payload: any): string {
  const encoded = encodeBurnIntent(payload);
  const prefixed = Buffer.concat([
    Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
    encoded,
  ]);
  const privateKey = crypto.createPrivateKey({
    key: Buffer.concat([
      Buffer.from("302e020100300506032b657004220420", "hex"),
      Buffer.from(keypair.secretKey.slice(0, 32)),
    ]),
    format: "der",
    type: "pkcs8",
  });
  return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
}

// Convert Solana address to 32-byte hex string
function addressToBytes32(address: string): string {
  const decoded = Buffer.from(bs58.decode(address));
  return `0x${decoded.toString("hex")}`;
}

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

// Decode attestation set from Gateway API response
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;
    }>;
  };
}

// Find PDA for token custody account
function findCustodyPda(
  mint: PublicKey,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("gateway_minter_custody"), mint.toBuffer()],
    minterProgramId,
  )[0];
}

// Find PDA for transfer spec hash tracking
function findTransferSpecHashPda(
  transferSpecHash: Uint8Array | Buffer,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("used_transfer_spec_hash"), Buffer.from(transferSpecHash)],
    minterProgramId,
  )[0];
}

4.4. Initialize connection and create recipient ATA

Initialize the Solana connection and keypairs, then create the recipient’s Associated Token Account (ATA) for receiving USDC on the destination chain.
transfer-from-sol.ts
const senderKeypair = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR);
const recipientKeypair = createKeypairFromEnv(process.env.RECIPIENT_KEYPAIR);
const connection = new Connection(RPC_ENDPOINT, "confirmed");
const usdcMint = new PublicKey(USDC_ADDRESS);

console.log(`Using account: ${senderKeypair.publicKey.toBase58()}`);
console.log(`Transfering balances from: Solana Devnet`);

// Create recipient's Associated Token Account
const recipientAta = getAssociatedTokenAddressSync(
  usdcMint,
  recipientKeypair.publicKey,
);

const createAtaIx = createAssociatedTokenAccountIdempotentInstruction(
  senderKeypair.publicKey,
  recipientAta,
  recipientKeypair.publicKey,
  usdcMint,
);
const tx = new Transaction().add(createAtaIx);
await sendAndConfirmTransaction(connection, tx, [senderKeypair]);

4.5. Create and sign burn intent

transfer-from-sol.ts
const burnIntent = createBurnIntent({
  sourceDepositor: senderKeypair.publicKey.toBase58(),
  destinationRecipient: recipientAta.toBase58(),
  sourceSigner: senderKeypair.publicKey.toBase58(),
});

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

4.6. Request attestation from Gateway API

transfer-from-sol.ts
const response = await fetch(
  "https://gateway-api-testnet.circle.com/v1/transfer",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request, (_key, value) =>
      typeof value === "bigint" ? value.toString() : value,
    ),
  },
);

const json = await response.json();
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.7. Set up minter client

transfer-from-sol.ts
const minterProgramId = new PublicKey(GATEWAY_MINTER_ADDRESS);
const anchorWallet = new Wallet(senderKeypair);
const provider = new AnchorProvider(
  connection,
  anchorWallet,
  AnchorProvider.defaultOptions(),
);
setProvider(provider);
const minterProgram = new Program(gatewayMinterIdl, provider);

const [minterPda] = PublicKey.findProgramAddressSync(
  [Buffer.from(utils.bytes.utf8.encode("gateway_minter"))],
  minterProgramId,
);

4.8. Mint on Solana

transfer-from-sol.ts
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");

console.log("Minting funds on Solana Devnet...");
const mintTx = await minterProgram.methods
  .gatewayMint({ attestation: attestationBytes, signature: signatureBytes })
  .accountsPartial({
    gatewayMinter: minterPda,
    destinationCaller: senderKeypair.publicKey,
    payer: senderKeypair.publicKey,
    systemProgram: SystemProgram.programId,
    tokenProgram: TOKEN_PROGRAM_ID,
  })
  .remainingAccounts(remainingAccounts)
  .signers([senderKeypair])
  .rpc();

// [6] Wait for confirmation
const latest = await connection.getLatestBlockhash();
await connection.confirmTransaction(
  {
    signature: mintTx,
    blockhash: latest.blockhash,
    lastValidBlockHeight: latest.lastValidBlockHeight,
  },
  "confirmed",
);

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

4.9. Full Solana transfer script

The complete transfer script creates and signs a burn intent on Solana Devnet, submits it to the Gateway API for attestation, and mints USDC on Solana Devnet for the recipient. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.
transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import * as crypto from "crypto";
import {
  Wallet,
  AnchorProvider,
  setProvider,
  Program,
  utils,
} from "@coral-xyz/anchor";
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
} from "@solana/web3.js";
import {
  TOKEN_PROGRAM_ID,
  getAssociatedTokenAddressSync,
  createAssociatedTokenAccountIdempotentInstruction,
} from "@solana/spl-token";
import {
  u32be,
  nu64be,
  struct,
  seq,
  blob,
  offset,
  Layout,
} from "@solana/buffer-layout";
import bs58 from "bs58";
import {
  RPC_ENDPOINT,
  GATEWAY_WALLET_ADDRESS,
  GATEWAY_MINTER_ADDRESS,
  USDC_ADDRESS,
  SOLANA_DOMAIN,
  SOLANA_ZERO_ADDRESS,
  createKeypairFromEnv,
  gatewayMinterIdl,
} from "./config.js";

const TRANSFER_AMOUNT = 1; // 1 USDC
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;

/* Type definitions */
// Custom layout for Solana PublicKey (32 bytes)
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;
  }
}

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

// 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);

// Type 'as any' used due to @solana/buffer-layout's incomplete TypeScript definitions (archived Jan 2025)
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);

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);

/* Helpers */
// Construct burn intent for a given source
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: addressToBytes32(GATEWAY_WALLET_ADDRESS),
      destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
      sourceToken: addressToBytes32(USDC_ADDRESS),
      destinationToken: addressToBytes32(USDC_ADDRESS),
      sourceDepositor: addressToBytes32(sourceDepositor),
      destinationRecipient: addressToBytes32(destinationRecipient),
      sourceSigner: addressToBytes32(sourceSigner),
      destinationCaller: addressToBytes32(SOLANA_ZERO_ADDRESS),
      value: TRANSFER_VALUE,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

// Encode burn intent as binary layout for signing
function encodeBurnIntent(bi: any): 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);
}

// Sign burn intent with Ed25519 keypair
function signBurnIntent(keypair: Keypair, payload: any): string {
  const encoded = encodeBurnIntent(payload);
  const prefixed = Buffer.concat([
    Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
    encoded,
  ]);
  const privateKey = crypto.createPrivateKey({
    key: Buffer.concat([
      Buffer.from("302e020100300506032b657004220420", "hex"),
      Buffer.from(keypair.secretKey.slice(0, 32)),
    ]),
    format: "der",
    type: "pkcs8",
  });
  return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
}

// Convert Solana address to 32-byte hex string
function addressToBytes32(address: string): string {
  const decoded = Buffer.from(bs58.decode(address));
  return `0x${decoded.toString("hex")}`;
}

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

// Decode attestation set from Gateway API response
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;
    }>;
  };
}

// Find PDA for token custody account
function findCustodyPda(
  mint: PublicKey,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("gateway_minter_custody"), mint.toBuffer()],
    minterProgramId,
  )[0];
}

// Find PDA for transfer spec hash tracking
function findTransferSpecHashPda(
  transferSpecHash: Uint8Array | Buffer,
  minterProgramId: PublicKey,
): PublicKey {
  return PublicKey.findProgramAddressSync(
    [Buffer.from("used_transfer_spec_hash"), Buffer.from(transferSpecHash)],
    minterProgramId,
  )[0];
}

/* Main logic */
async function main() {
  if (!process.env.SOLANA_PRIVATE_KEYPAIR || !process.env.RECIPIENT_KEYPAIR) {
    throw new Error("SOLANA_PRIVATE_KEYPAIR and RECIPIENT_KEYPAIR must be set");
  }

  const senderKeypair = createKeypairFromEnv(
    process.env.SOLANA_PRIVATE_KEYPAIR,
  );
  const recipientKeypair = createKeypairFromEnv(process.env.RECIPIENT_KEYPAIR);
  const connection = new Connection(RPC_ENDPOINT, "confirmed");
  const usdcMint = new PublicKey(USDC_ADDRESS);

  console.log(`Using account: ${senderKeypair.publicKey.toBase58()}`);
  console.log(`Transfering balances from: Solana Devnet`);

  // [1] Create recipient's Associated Token Account
  const recipientAta = getAssociatedTokenAddressSync(
    usdcMint,
    recipientKeypair.publicKey,
  );

  const createAtaIx = createAssociatedTokenAccountIdempotentInstruction(
    senderKeypair.publicKey,
    recipientAta,
    recipientKeypair.publicKey,
    usdcMint,
  );
  const tx = new Transaction().add(createAtaIx);
  await sendAndConfirmTransaction(connection, tx, [senderKeypair]);

  // [2] Create and sign burn intent
  console.log(`Creating burn intent from Solana Devnet → Solana Devnet...`);
  const burnIntent = createBurnIntent({
    sourceDepositor: senderKeypair.publicKey.toBase58(),
    destinationRecipient: recipientAta.toBase58(),
    sourceSigner: senderKeypair.publicKey.toBase58(),
  });

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

  // [3] Request attestation from Gateway API
  const response = await fetch(
    "https://gateway-api-testnet.circle.com/v1/transfer",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(request, (_key, value) =>
        typeof value === "bigint" ? value.toString() : value,
      ),
    },
  );

  const json = await response.json();
  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 minter client
  const minterProgramId = new PublicKey(GATEWAY_MINTER_ADDRESS);
  const anchorWallet = new Wallet(senderKeypair);
  const provider = new AnchorProvider(
    connection,
    anchorWallet,
    AnchorProvider.defaultOptions(),
  );
  setProvider(provider);
  const minterProgram = new Program(gatewayMinterIdl, provider);

  const [minterPda] = PublicKey.findProgramAddressSync(
    [Buffer.from(utils.bytes.utf8.encode("gateway_minter"))],
    minterProgramId,
  );

  // [5] Mint on Solana
  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");

  console.log("Minting funds on Solana Devnet...");
  const mintTx = await minterProgram.methods
    .gatewayMint({ attestation: attestationBytes, signature: signatureBytes })
    .accountsPartial({
      gatewayMinter: minterPda,
      destinationCaller: senderKeypair.publicKey,
      payer: senderKeypair.publicKey,
      systemProgram: SystemProgram.programId,
      tokenProgram: TOKEN_PROGRAM_ID,
    })
    .remainingAccounts(remainingAccounts)
    .signers([senderKeypair])
    .rpc();

  // [6] Wait for confirmation
  const latest = await connection.getLatestBlockhash();
  await connection.confirmTransaction(
    {
      signature: mintTx,
      blockhash: latest.blockhash,
      lastValidBlockHeight: latest.lastValidBlockHeight,
    },
    "confirmed",
  );

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

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

4.10. Run the script to transfer USDC to Solana Devnet

Run the transfer script to transfer 1 USDC from your Solana Devnet Gateway balance to the recipient address on Solana Devnet.
Gateway gas fees are charged per burn intent. To reduce overall gas costs, consider keeping most Gateway funds on low-cost chains, where Circle’s base fee for burns is cheaper.
npm run transfer-from-sol