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. This quickstart shows you how to use Circle Gateway to create chain abstracted USDC balances to build an application experience similar to traditional finance.
This quickstart provides all the code you need to create a unified crosschain balance and transfer it to Solana. You can find the full code for this quickstart on GitHub.

Prerequisites

Before you begin, ensure you have:
  • Node.js and npm installed on your development machine.
    • You can download Node.js directly, or use a version manager like nvm. The npm binary comes with Node.js.
  • Two wallets on Solana Devnet, with addresses and private key pairs available.
    • You need two Solana accounts to act as sender and recipient in the Solana same-chain transfer example. If you don’t plan to run the example, you can have only one Solana account.
  • One Solana wallet funded with USDC and native tokens.
    • The funded wallet acts as the sender and fee payer.
    • Use the Circle Faucet to get 1 USDC on Solana Devnet.
    • Use the Solana Devnet faucet to get testnet native tokens (SOL).
To generate Solana key pairs, run:
Shell
solana-keygen new -o account-1-keypair.json --no-bip39-passphrase
solana-keygen new -o account-2-keypair.json --no-bip39-passphrase
Copy the byte array from each JSON file to your .env file.

Step 1: Set up your project

The following sections demonstrate how to initialize your project and set up the required clients and smart contracts to interact with Circle Gateway.

1.1. Set up your development environment

Create a new directory for your project and initialize it with npm. Set the package type to module and install the necessary dependencies:
Shell
mkdir unified-balance-quickstart && cd unified-balance-quickstart
npm init -y
npm pkg set type="module"
npm install --save viem dotenv @solana/web3.js @solana/spl-token @coral-xyz/anchor bn.js bs58
Create a new .env file.
Shell
touch .env
Edit the .env file and add the following variables. Replace {YOUR_SOLANA_PRIVATE_KEYPAIR_1} and {YOUR_SOLANA_PRIVATE_KEYPAIR_2} with your Solana key pairs in JSON array format. Note that SOLANA_PRIVATE_KEYPAIR_1 should be the wallet with fund:
SOLANA_PRIVATE_KEYPAIR_1={YOUR_SOLANA_PRIVATE_KEYPAIR_1}
SOLANA_PRIVATE_KEYPAIR_2={YOUR_SOLANA_PRIVATE_KEYPAIR_2}

1.2. Define contract IDLs

Create a solana directory for Solana-specific code:
Shell
mkdir solana
Create a file called idls.js in the solana directory and add the following code to it. This helper code defines the contract functions that you use to establish a unified USDC balance and mint it on a new blockchain.
For brevity, the IDLs in this code are not complete IDLs for the wallet and minter contracts. They only define the functions necessary for this quickstart. See Contract Interfaces and Events for a full description of each contract’s methods.
JavaScript
///////////////////////////////////////////////////////////////////////////////
// IDLs used for the Gateway contracts

// The subset of the GatewayWallet IDL that is used in the quickstart guide
export const gatewayWalletIdl = {
  address: "devN7ZZFhGVTgwoKHaDDTFFgrhRzSGzuC6hgVFPrxbs",
  metadata: {
    name: "gatewayWallet",
    version: "0.1.0",
    spec: "0.1.0",
    description: "Created with Anchor",
  },
  instructions: [
    {
      name: "deposit",
      discriminator: [22, 0],
      accounts: [
        {
          name: "payer",
          writable: true,
          signer: true,
        },
        {
          name: "owner",
          signer: true,
        },
        {
          name: "gatewayWallet",
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  103, 97, 116, 101, 119, 97, 121, 95, 119, 97, 108, 108, 101,
                  116,
                ],
              },
            ],
          },
        },
        {
          name: "ownerTokenAccount",
          writable: true,
        },
        {
          name: "custodyTokenAccount",
          writable: true,
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  103, 97, 116, 101, 119, 97, 121, 95, 119, 97, 108, 108, 101,
                  116, 95, 99, 117, 115, 116, 111, 100, 121,
                ],
              },
              {
                kind: "account",
                path: "custody_token_account.mint",
                account: "tokenAccount",
              },
            ],
          },
        },
        {
          name: "deposit",
          writable: true,
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  103, 97, 116, 101, 119, 97, 121, 95, 100, 101, 112, 111, 115,
                  105, 116,
                ],
              },
              {
                kind: "account",
                path: "custody_token_account.mint",
                account: "tokenAccount",
              },
              {
                kind: "account",
                path: "owner",
              },
            ],
          },
        },
        {
          name: "depositorDenylist",
          pda: {
            seeds: [
              {
                kind: "const",
                value: [100, 101, 110, 121, 108, 105, 115, 116],
              },
              {
                kind: "account",
                path: "owner",
              },
            ],
          },
        },
        {
          name: "tokenProgram",
          address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
        },
        {
          name: "systemProgram",
          address: "11111111111111111111111111111111",
        },
        {
          name: "eventAuthority",
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  95, 95, 101, 118, 101, 110, 116, 95, 97, 117, 116, 104, 111,
                  114, 105, 116, 121,
                ],
              },
            ],
          },
        },
        {
          name: "program",
        },
      ],
      args: [
        {
          name: "amount",
          type: "u64",
        },
      ],
    },
  ],
};

// The subset of the GatewayMinter IDL that is used in the quickstart guide
export const gatewayMinterIdl = {
  address: "dev7nrwT5HL2S1mdcmzgpUDfyEKZaQfZLRmNAhYZCVa",
  metadata: {
    name: "gatewayMinter",
    version: "0.1.0",
    spec: "0.1.0",
    description: "Created with Anchor",
  },
  instructions: [
    {
      name: "gatewayMint",
      discriminator: [12, 0],
      accounts: [
        {
          name: "payer",
          writable: true,
          signer: true,
        },
        {
          name: "destinationCaller",
          signer: true,
        },
        {
          name: "gatewayMinter",
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  103, 97, 116, 101, 119, 97, 121, 95, 109, 105, 110, 116, 101,
                  114,
                ],
              },
            ],
          },
        },
        {
          name: "systemProgram",
          address: "11111111111111111111111111111111",
        },
        {
          name: "tokenProgram",
          address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
        },
        {
          name: "eventAuthority",
          pda: {
            seeds: [
              {
                kind: "const",
                value: [
                  95, 95, 101, 118, 101, 110, 116, 95, 97, 117, 116, 104, 111,
                  114, 105, 116, 121,
                ],
              },
            ],
          },
        },
        {
          name: "program",
        },
      ],
      args: [
        {
          name: "params",
          type: {
            defined: {
              name: "gatewayMintParams",
            },
          },
        },
      ],
    },
  ],
  types: [
    {
      name: "gatewayMintParams",
      docs: ["Mode 1: Full attestation bytes with signature"],
      type: {
        kind: "struct",
        fields: [
          {
            name: "attestation",
            type: "bytes",
          },
          {
            name: "signature",
            type: "bytes",
          },
        ],
      },
    },
  ],
};

1.3. Define the Gateway API client

Create a file called gateway-client.js in the project root directory and add the following code to it. This helper code provides data and methods for interacting with the Gateway API.
JavaScript
/////////////////////////////////////////////////////////////////
// A lightweight API client for interacting with the Gateway API.

export class GatewayClient {
  static GATEWAY_API_BASE_URL = "https://gateway-api-testnet.circle.com/v1";

  // Identifiers used for supported blockchains
  // See https://developers.circle.com/cctp/cctp-supported-blockchains#cctp-supported-domains
  static DOMAINS = {
    ethereum: 0,
    mainnet: 0,
    sepolia: 0,
    avalanche: 1,
    avalancheFuji: 1,
    solana: 5,
    solanaDevnet: 5,
    base: 6,
    baseSepolia: 6,
  };

  // Human-readable names for the supported blockchains, by domain
  static CHAINS = {
    0: "Ethereum",
    1: "Avalanche",
    5: "Solana",
    6: "Base",
  };

  // Gets info about supported chains and contracts
  async info() {
    return this.#get("/info");
  }

  // Checks balances for a given depositor for the given domains. If no domains
  // are specified, it defaults to all supported domains.
  async balances(token, depositor, domains) {
    return this.#post("/balances", {
      token,
      sources: domains.map((domain) => ({ depositor, domain })),
    });
  }

  // Sends burn intents to the API to retrieve an attestation
  async transfer(body) {
    return this.#post("/transfer", body);
  }

  // Private method to do a GET request to the Gateway API
  async #get(path) {
    const url = GatewayClient.GATEWAY_API_BASE_URL + path;
    const response = await fetch(url);
    return response.json();
  }

  // Private method to do a POST request to the Gateway API
  async #post(path, body) {
    const url = GatewayClient.GATEWAY_API_BASE_URL + path;
    const headers = { "Content-Type": "application/json" };
    const response = await fetch(url, {
      method: "POST",
      headers,
      // Serialize bigints as strings
      body: JSON.stringify(body, (_key, value) =>
        typeof value === "bigint" ? value.toString() : value,
      ),
    });
    return response.json();
  }
}

1.4. Prepare ready-to-sign burn intent

This step shows you how to transform a raw burn intent into one that is ready to sign.

1.4.1 Transform format of burn intent fields based on the chain

Create a file called burnIntentTransformers.js in the project root directory that can be shared by EVM and Solana, and add the following code to it:
JavaScript
import { randomBytes } from "node:crypto";
import { pad, zeroAddress as evmZeroAddress } from "viem";
import bs58 from "bs58";

const solanaZeroAddress = "11111111111111111111111111111111";

// Maximum value for u64 (used for Solana block heights)
const MAX_U64 = 2n ** 64n - 1n;

export function addressToBytes32(address, isSolana) {
  if (isSolana) {
    const decoded = Buffer.from(bs58.decode(address));
    return `0x${decoded.toString("hex")}`;
  } else {
    return pad(address.toLowerCase(), { size: 32 });
  }
}

export function burnIntent({ account, from, to, amount, recipient }) {
  return {
    // Needs to be at least 7 days in the future
    maxBlockHeight: MAX_U64,
    // 2.01 USDC will cover the fee for any chain.
    maxFee: 2_010000n,
    // The details of the transfer
    spec: {
      version: 1,
      sourceDomain: from.domain,
      destinationDomain: to.domain,
      sourceContract: from.gatewayWallet.address,
      destinationContract: to.gatewayMinter.address,
      sourceToken: from.usdc.address,
      destinationToken: to.usdc.address,
      sourceDepositor: account.address,
      destinationRecipient: recipient || account.address,
      sourceSigner: account.address,
      destinationCaller: solanaZeroAddress, // Use the zero address to specify that anyone can execute the attestation
      value: BigInt(Math.floor(amount * 1e6)), // Convert the amount string to USDC atomic units
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x", // No hook data for now
    },
  };
}

export function transformBurnIntent(
  burnIntent,
  isSourceSolana,
  isDestinationSolana,
) {
  const destinationCallerValue = isDestinationSolana
    ? (burnIntent.spec.destinationCaller ?? solanaZeroAddress)
    : (burnIntent.spec.destinationCaller ?? evmZeroAddress);

  return {
    maxBlockHeight: burnIntent.maxBlockHeight,
    maxFee: burnIntent.maxFee,
    spec: {
      ...burnIntent.spec,
      sourceContract: addressToBytes32(
        burnIntent.spec.sourceContract,
        isSourceSolana,
      ),
      destinationContract: addressToBytes32(
        burnIntent.spec.destinationContract,
        isDestinationSolana,
      ),
      sourceToken: addressToBytes32(
        burnIntent.spec.sourceToken,
        isSourceSolana,
      ),
      destinationToken: addressToBytes32(
        burnIntent.spec.destinationToken,
        isDestinationSolana,
      ),
      sourceDepositor: addressToBytes32(
        burnIntent.spec.sourceDepositor,
        isSourceSolana,
      ),
      destinationRecipient: addressToBytes32(
        burnIntent.spec.destinationRecipient,
        isDestinationSolana,
      ),
      sourceSigner: addressToBytes32(
        burnIntent.spec.sourceSigner,
        isSourceSolana,
      ),
      destinationCaller: addressToBytes32(
        destinationCallerValue,
        isDestinationSolana,
      ),
    },
  };
}

1.4.2 Encode burn intent(set)

This section corresponds to Step 1.4 in the EVM quickstart.
Solana doesn’t support typed data, so you need to encode the burn intent(set) data manually. Create a file called burnIntent.js in the solana directory and add the following code to it:
JavaScript
import { publicKey } from "@solana/buffer-layout-utils";
import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
import { PublicKey } from "@solana/web3.js";

// Magic numbers
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;

// Custom layout for 256-bit unsigned integers (stored as 32 bytes big-endian)
// Currently only reads the last 8 bytes as a regular number
class UInt256BE extends Layout {
  constructor(property) {
    super(32, property);
  }

  decode(b, offset = 0) {
    const buffer = b.slice(offset, offset + 32);
    // Read only the last 8 bytes as a BigInt
    const value = buffer.readBigUInt64BE(24);
    return value;
  }

  encode(src, b, offset = 0) {
    const buffer = Buffer.alloc(32);
    buffer.writeBigUInt64BE(BigInt(src), 24);
    buffer.copy(b, offset);
    return 32;
  }
}

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

const hexToPublicKey = (hex) => new PublicKey(Buffer.from(hex.slice(2), "hex"));

// BurnIntent layout with nested TransferSpec
// Fixed size: 72 bytes for BurnIntent header (4 + 32 + 32 + 4)
// + 340 bytes for TransferSpec header (4 + 4 + 4 + 4 + 32*8 + 32 + 32 + 4)
// Variable: hookData within TransferSpec
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"),
    ],
    "spec",
  ),
]);

export function encodeBurnIntent(bi) {
  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);
}

1.5. Decode attestation

Create a file called attestation.js in the solana directory and add the following code to it:
JavaScript
import { publicKey } from "@solana/buffer-layout-utils";
import {
  u32be,
  nu64be,
  struct,
  seq,
  blob,
  offset,
} from "@solana/buffer-layout";

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

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

// Decode an attestation set using the buffer layout
export function decodeAttestationSet(attestation) {
  const buffer = Buffer.from(attestation.slice(2), "hex");
  return MintAttestationSetLayout.decode(buffer);
}

1.6. Initialize clients and contracts

1.6.1 Define wallet and minter clients

You only need to define clients for Solana. For EVM, the getContract function provided by the viem library handles client setup. [1] Solana wallet client Create a file called solanaWalletClient.js in the solana directory and add the following code to it.
JavaScript
import * as anchor from "@coral-xyz/anchor";
import {
  getAssociatedTokenAddressSync,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { PublicKey, SystemProgram } from "@solana/web3.js";
import { gatewayWalletIdl } from "./idls.js";
import { encodeBurnIntent } from "./burnIntent.js";
import crypto from "crypto";

export class SolanaWalletClient {
  account;
  connection;
  anchorProvider;
  program;
  walletPda;
  address;

  constructor(account, connection, programId) {
    this.account = account;
    this.connection = connection;
    this.address = programId.toBase58();
    const anchorWallet = new anchor.Wallet(account);
    this.anchorProvider = new anchor.AnchorProvider(
      this.connection,
      anchorWallet,
      anchor.AnchorProvider.defaultOptions(),
    );
    anchor.setProvider(this.anchorProvider);

    this.program = new anchor.Program(
      { ...gatewayWalletIdl, address: programId.toBase58() },
      this.anchorProvider,
    );
    this.walletPda = this.findGatewayWalletPda();
  }

  findCustodyPda(tokenMint) {
    return PublicKey.findProgramAddressSync(
      [
        Buffer.from(anchor.utils.bytes.utf8.encode("gateway_wallet_custody")),
        tokenMint.toBuffer(),
      ],
      this.program.programId,
    )[0];
  }

  findDepositPda(tokenMint, depositor) {
    return PublicKey.findProgramAddressSync(
      [
        Buffer.from("gateway_deposit"),
        tokenMint.toBuffer(),
        depositor.toBuffer(),
      ],
      this.program.programId,
    )[0];
  }

  findDenylistPda(address) {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("denylist"), address.toBuffer()],
      this.program.programId,
    )[0];
  }

  findGatewayWalletPda() {
    return PublicKey.findProgramAddressSync(
      [Buffer.from(anchor.utils.bytes.utf8.encode("gateway_wallet"))],
      this.program.programId,
    )[0];
  }

  findATA(tokenMint, owner) {
    return getAssociatedTokenAddressSync(tokenMint, owner);
  }

  async deposit(tokenMint, amount) {
    const owner = this.account.publicKey;
    const beneficiary = owner;

    const custodyPda = this.findCustodyPda(tokenMint);
    const depositPda = this.findDepositPda(tokenMint, beneficiary);
    const beneficiaryDenylistPda = this.findDenylistPda(beneficiary);

    return this.program.methods
      .deposit(amount)
      .accountsPartial({
        payer: owner,
        owner: owner,
        gatewayWallet: this.walletPda,
        ownerTokenAccount: this.findATA(tokenMint, owner),
        custodyTokenAccount: custodyPda,
        deposit: depositPda,
        depositorDenylist: beneficiaryDenylistPda,
        tokenProgram: TOKEN_PROGRAM_ID,
        systemProgram: SystemProgram.programId,
      })
      .signers([this.account])
      .rpc();
  }

  async waitForConfirmation(signature) {
    const latest = await this.connection.getLatestBlockhash();

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

  signBurnIntent(payload) {
    const encoded = encodeBurnIntent(payload);
    // Per the GatewayWallet program's requirements, the BurnIntent message must be
    // prefixed with a specific 16-byte sequence for signing.
    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"), // PKCS#8 header
        Buffer.from(this.account.secretKey.slice(0, 32)), // The first 32 bytes of a Solana secretKey are the private key
      ]),
      format: "der",
      type: "pkcs8",
    });
    return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
  }
}
[2] Solana minter client Create a file called solanaMinterClient.js in the solana directory and add the following code to it.
JavaScript
import * as anchor from "@coral-xyz/anchor";
import { PublicKey, SystemProgram } from "@solana/web3.js";
import { gatewayMinterIdl } from "./idls.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { decodeAttestationSet } from "./attestation.js";

export class SolanaMinterClient {
  account;
  connection;
  anchorProvider;
  program;
  address;
  minterPda;

  constructor(account, connection, programId) {
    this.account = account;
    this.connection = connection;
    this.address = programId.toBase58();
    const anchorWallet = new anchor.Wallet(account);
    this.anchorProvider = new anchor.AnchorProvider(
      this.connection,
      anchorWallet,
      anchor.AnchorProvider.defaultOptions(),
    );
    anchor.setProvider(this.anchorProvider);

    this.program = new anchor.Program(
      { ...gatewayMinterIdl, address: programId.toBase58() },
      this.anchorProvider,
    );

    const [pda] = PublicKey.findProgramAddressSync(
      [Buffer.from(anchor.utils.bytes.utf8.encode("gateway_minter"))],
      this.program.programId,
    );
    this.minterPda = pda;
  }

  findCustodyPda(mint) {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("gateway_minter_custody"), mint.toBuffer()],
      this.program.programId,
    )[0];
  }

  findTransferSpecHashPda(transferSpecHash) {
    return PublicKey.findProgramAddressSync(
      [Buffer.from("used_transfer_spec_hash"), transferSpecHash],
      this.program.programId,
    )[0];
  }

  // Assume a single destination recipient and token
  gatewayMint = async (attestation, signature) => {
    const decoded = decodeAttestationSet(attestation);

    console.log("=== Decoded Attestation Details ===\n");
    decoded.attestations.forEach((att, index) => {
      console.log(`Attestation ${index + 1}:`);
      console.log(`  Destination Token: ${att.destinationToken.toBase58()}`);
      console.log(
        `  Destination Recipient: ${att.destinationRecipient.toBase58()}`,
      );
      console.log(`  Value: ${att.value.toString()} USDC base units`);
      console.log(
        `  Transfer Spec Hash: ${att.transferSpecHash.toString("hex")}`,
      );
      console.log(`  Hook Data Length: ${att.hookDataLength}`);
      console.log(
        `  Hook Data: 0x${Buffer.from(att.hookData).toString("hex")}`,
      );
    });

    // Create remaining accounts list
    const remainingAccountsList = decoded.attestations.flatMap((e) => {
      return [
        {
          pubkey: this.findCustodyPda(e.destinationToken),
          isWritable: true,
          isSigner: false,
        },
        {
          pubkey: e.destinationRecipient,
          isWritable: true,
          isSigner: false,
        },
        {
          pubkey: this.findTransferSpecHashPda(e.transferSpecHash),
          isWritable: true,
          isSigner: false,
        },
      ];
    });

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

    return this.program.methods
      .gatewayMint({ attestation: attestationBytes, signature: signatureBytes })
      .accountsPartial({
        gatewayMinter: this.minterPda,
        destinationCaller: this.account.publicKey,
        payer: this.account.publicKey,
        systemProgram: SystemProgram.programId,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .remainingAccounts(remainingAccountsList)
      .signers([this.account])
      .rpc();
  };

  async waitForConfirmation(signature) {
    const latest = await this.connection.getLatestBlockhash();

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

1.6.2 Account setup

Create a file called setup.js in the solana directory and add the following code to it. This setup code initializes Solana wallet and minter clients that you’ll use to interact with the Gateway program on Solana.
Remember to add Solana key pairs to your .env file as shown in the preceding environment setup step.
JavaScript
import "dotenv/config";
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import { SolanaWalletClient } from "./solanaWalletClient.js";
import { SolanaMinterClient } from "./solanaMinterClient.js";
import { GatewayClient } from "../gateway-client.js";

// Addresses that are needed for Solana Devnet
const gatewayWalletAddress = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const gatewayMinterAddress = "GATEmKK2ECL1brEngQZWCgMWPbvrEYqsV6u29dAaHavr";
const usdcAddresses = {
  solanaDevnet: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
};

// RPC endpoints
const rpcEndpoints = {
  solanaDevnet: "https://api.devnet.solana.com",
};

const keypair1 = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR_1);
const keypair2 = createKeypairFromEnv(process.env.SOLANA_PRIVATE_KEYPAIR_2);

function createKeypairFromEnv(privateKey) {
  if (!privateKey) {
    throw new Error("Private key is required");
  }

  // Try to parse as JSON array first (byte array format)
  try {
    const secretKey = JSON.parse(privateKey);
    return Keypair.fromSecretKey(Uint8Array.from(secretKey));
  } catch {
    throw new Error(
      "SOLANA_PRIVATE_KEYPAIR must be a JSON array of bytes, e.g., [1,2,3,...]",
    );
  }
}

function setup(networkName, keypair) {
  const connection = new Connection(rpcEndpoints[networkName], "confirmed");

  return {
    connection,
    name: networkName,
    domain: GatewayClient.DOMAINS[networkName],
    currency: "SOL",
    usdc: {
      address: usdcAddresses["solanaDevnet"],
      publicKey: new PublicKey(usdcAddresses["solanaDevnet"]),
    },
    gatewayWallet: new SolanaWalletClient(
      keypair,
      connection,
      new PublicKey(gatewayWalletAddress),
    ),
    gatewayMinter: new SolanaMinterClient(
      keypair,
      connection,
      new PublicKey(gatewayMinterAddress),
    ),
    address: keypair.publicKey.toBase58(),
    publicKey: keypair.publicKey,
    keypair: keypair,
  };
}

// Set up clients and contracts for Solana Devnet
export const solanaAccount1 = setup("solanaDevnet", keypair1);
console.log(`Using Solana account1: ${solanaAccount1.address}`);

// If you don't need to use another Solana account as a recipient,
// you can comment out the following lines
export const solanaAccount2 = setup("solanaDevnet", keypair2);
console.log(`Using Solana account2: ${solanaAccount2.address}`);

Step 2: Create a unified crosschain balance

In this step, you deposit USDC into the Gateway Wallet contract on the source chain, creating a unified crosschain balance for your address in Gateway.

2.1. Deposit USDC into the Gateway Wallet contracts

Create a file called deposit.js in the solana directory and add the following code to it. This script deposits USDC into the Gateway Wallet contracts on Solana. Note that for the transfer, the code is not calling the ERC-20 transfer function, as doing so would result in a loss of funds. Instead, the code calls gateway_deposit instruction provided by the Gateway Wallet contract to transfer USDC on behalf of your account.
JavaScript
import { solanaAccount1 as solanaSenderAccount } from "./setup.js";
import { getAssociatedTokenAddress, getAccount } from "@solana/spl-token";
import BN from "bn.js";

const DEPOSIT_AMOUNT = new BN(500000); // 0.5 USDC (6 decimals)

// Get USDC balance for an account on a Solana chain
async function getUsdcBalance(chain, accountPublicKey) {
  // Get the associated token account for the user's USDC
  const userTokenAccount = await getAssociatedTokenAddress(
    chain.usdc.publicKey,
    accountPublicKey,
  );

  // Get the token account info to check balance
  const tokenAccountInfo = await getAccount(chain.connection, userTokenAccount);

  return {
    balance: tokenAccountInfo.amount,
    tokenAccount: userTokenAccount,
  };
}

// Deposit into the GatewayWallet program on Solana
async function depositToSolana() {
  const chain = solanaSenderAccount;

  try {
    // Check USDC balance
    console.log(`Checking USDC balance on ${chain.name}...`);
    const { balance, tokenAccount: userTokenAccount } = await getUsdcBalance(
      chain,
      solanaSenderAccount.publicKey,
    );
    console.log("User token account:", userTokenAccount.toBase58());
    console.log(`Current balance: ${balance} USDC (atomic units)`);

    // Ensure the balance is sufficient for the deposit
    if (balance < DEPOSIT_AMOUNT) {
      console.error(`Insufficient USDC balance on ${chain.name}!`);
      console.error("Please top up at https://faucet.circle.com.");
      process.exit(1);
    }

    // Deposit USDC into the GatewayWallet contract
    console.log("Depositing USDC into the GatewayWallet contract...");

    const tx = await chain.gatewayWallet.deposit(
      chain.usdc.publicKey,
      DEPOSIT_AMOUNT,
    );
    console.log("Transaction hash:", tx);

    await chain.gatewayWallet.waitForConfirmation(tx);
    console.log("Deposit successful!");
  } catch (error) {
    if (error.error?.InstructionError?.[1] === "InsufficientFunds") {
      // If there wasn't enough for gas, log an error message and exit
      console.error(
        `The wallet does not have enough ${chain.currency} to pay for transaction fees on ${chain.name}!`,
      );
      console.error(`Please top up using a faucet.`);
    } else {
      // Log any other errors for debugging
      console.error("Error during deposit:", error);
    }
    process.exit(1);
  }
}

// Run the deposit
depositToSolana();

2.2. Run the script to create a crosschain balance

Run the deposit.js script to make the deposit. If you don’t have enough USDC for the deposit or enough testnet native tokens to pay for gas fees, the code returns an error message.
Shell
node solana/deposit.js
You must wait for finality on the blockchain for the unified balance to be updated. On Solana Devnet, finality is typically reached within a few seconds.

Step 3: Transfer USDC from the crosschain balance to Solana

In this step, you create a burn intent, submit it to the transfer API, and receive an attestation, which you can use to mint USDC on Solana. This quickstart demonstrates two transfer scenarios involving Solana:
  • EVM to Solana: Cross-ecosystem transfer from Avalanche Fuji to Solana Devnet
  • Solana to Solana: Transfer USDC between Solana Devnet accounts
Select the tab for your desired transfer scenario.

3.1. Sign burn intents

Create a folder called transfers in the project root directory and add the following code to it. The code checks that the Gateway API supports the blockchains you want to use and confirms that your balance on the source blockchains is sufficient to complete the transfer. This section provides two transfer scenarios for reference.
To run this example, you must first complete Steps 1-2 of the EVM quickstart to set up your EVM environment and deposit USDC on Avalanche Fuji.
In the transfers folder, create a file called avaxToSolana.js and add the following code to it.
JavaScript
import { account as evmAccount, avalanche } from "../evm/setup.js";
import {
  solanaAccount1 as solanaFeePayer,
  solanaAccount2 as solanaRecipient,
} from "../solana/setup.js";
import { GatewayClient } from "../gateway-client.js";
import { burnIntent } from "../burnIntentTransformers.js";
import { burnIntentTypedData } from "../evm/typed-data.js";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
import { createAssociatedTokenAccountIdempotentInstruction } from "@solana/spl-token";
import { Transaction, sendAndConfirmTransaction } from "@solana/web3.js";

// Initialize a lightweight API client for interacting with Gateway
const gatewayClient = new GatewayClient();

// Check the info endpoint to confirm which chains are supported
// Not necessary for the transfer, but useful information
console.log("Fetching Gateway API info...");
const info = await gatewayClient.info();
for (const domain of info.domains) {
  console.log(
    `  - ${domain.chain} ${domain.network}`,
    `(wallet: ${"walletContract" in domain}, minter: ${
      "minterContract" in domain
    })`,
  );
}

// Check the account's balances with the Gateway API
console.log(`Checking balances...`);
const { balances: evmBalances } = await gatewayClient.balances(
  "USDC",
  evmAccount.address,
  [GatewayClient.DOMAINS.avalancheFuji],
);

// Check if Gateway has picked up the Avalanche deposit yet
// Since Avalanche has instant finality, this should be quick
const avalancheBalance = evmBalances.find(
  (b) => b.domain === GatewayClient.DOMAINS.avalancheFuji,
).balance;

// This is the amount we intend to transfer
const fromAvalancheAmount = 0.2;

if (
  !avalancheBalance ||
  parseFloat(avalancheBalance.balance) < fromAvalancheAmount
) {
  console.error(
    "Gateway deposit not yet picked up on Avalanche, wait until finalization",
  );
  process.exit(1);
} else {
  console.log(
    "Gateway deposit picked up on Avalanche! Current balance: ",
    avalancheBalance,
  );
}

// Construct the burn intents
console.log("Constructing burn intent ...");

const defaultRecipientAta = getAssociatedTokenAddressSync(
  solanaRecipient.usdc.publicKey,
  solanaRecipient.publicKey,
);

console.log(
  "Creating the recipient's Associated Token Account (ATA) if it does not exist. ATA:",
  defaultRecipientAta.toBase58(),
);
const createAtaIx = createAssociatedTokenAccountIdempotentInstruction(
  solanaFeePayer.publicKey,
  defaultRecipientAta,
  solanaRecipient.publicKey,
  solanaRecipient.usdc.publicKey,
);
const tx = new Transaction().add(createAtaIx);
await sendAndConfirmTransaction(solanaFeePayer.connection, tx, [
  solanaFeePayer.keypair,
]);

const burnIntents = [
  burnIntent({
    account: evmAccount,
    from: avalanche,
    to: solanaRecipient,
    amount: fromAvalancheAmount,
    recipient: defaultRecipientAta.toBase58(),
  }),
];

const isSourceSolana = false;
const isDestinationSolana = true;

// Sign the burn intents
console.log("Signing burn intents...");
const request = await Promise.all(
  burnIntents.map(async (intent) => {
    console.log("Signing burn intent:", intent);
    const typedData = burnIntentTypedData(
      intent,
      isSourceSolana,
      isDestinationSolana,
    );
    const signature = await evmAccount.signTypedData(typedData);
    return { burnIntent: typedData.message, signature };
  }),
);

3.2. Submit burn intents to the API

Add the following code to avaxToSolana.js to submit the burn intents to the Gateway in exchange for an attestation:
JavaScript
// Request the attestation
console.log("Requesting attestation from Gateway API...");
const start = performance.now();
const response = await gatewayClient.transfer(request);
const end = performance.now();
if (response.success === false) {
  console.error("Error from Gateway API:", response.message);
  process.exit(1);
}
console.log(
  "Received attestation from Gateway API in",
  (end - start).toFixed(2),
  "ms",
);

3.3. Mint USDC on Solana

Add the following code to avaxToSolana.js to mint USDC on Solana using the attestation:
JavaScript
// Mint the funds on Solana
console.log("Minting funds on Solana...");

const { attestation, signature } = response;
const mintTx = await solanaFeePayer.gatewayMinter.gatewayMint(
  attestation,
  signature,
);
console.log("Transaction hash:", mintTx);
await solanaFeePayer.gatewayMinter.waitForConfirmation(mintTx);
console.log("Mint successful!");
process.exit(0);

3.4. Run the transfer script

Run the avaxToSolana.js script:
Shell
node transfers/avaxToSolana.js