Prerequisites
Before you begin, ensure that you’ve:- Installed Node.js v22+
- Prepared an EVM testnet wallet with the private key available
- Added the supported Testnets of your choice to your wallet
- Prepared a Solana Devnet wallet and exported its keypair as a JSON array
- Completed Step 3: Deposit into a unified crosschain balance from the Solana quickstart
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:- Arc
- Avalanche
- Base
- Ethereum
- Hyperliquid
- Sei
- Solana
- Sonic
- Worldchain
Faucet: Arc Testnet (USDC + native tokens)
| Property | Value |
|---|---|
| Chain name | arcTestnet |
| USDC address | 0x3600000000000000000000000000000000000000 |
| Domain ID | 26 |
Faucet: Avalanche Fuji
| Property | Value |
|---|---|
| Chain name | avalancheFuji |
| USDC address | 0x5425890298aed601595a70ab815c96711a31bc65 |
| Domain ID | 1 |
Faucet: Base Sepolia
| Property | Value |
|---|---|
| Chain name | baseSepolia |
| USDC address | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Domain ID | 6 |
Faucet: Ethereum Sepolia
| Property | Value |
|---|---|
| Chain name | sepolia |
| USDC address | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 |
| Domain ID | 0 |
Faucet: Hyperliquid EVM Testnet
| Property | Value |
|---|---|
| Chain name | hyperliquidEvmTestnet |
| USDC address | 0x2B3370eE501B4a559b57D449569354196457D8Ab |
| Domain ID | 19 |
Faucet: Sei Testnet
| Property | Value |
|---|---|
| Chain name | seiTestnet |
| USDC address | 0x4fCF1784B31630811181f670Aea7A7bEF803eaED |
| Domain ID | 16 |
Faucet: Solana Devnet
| Property | Value |
|---|---|
| Chain name | solanaDevnet (note that Solana is not EVM-compatible) |
| USDC address | 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU |
| Domain ID | 5 |
Faucet: Sonic Testnet
| Property | Value |
|---|---|
| Chain name | sonicTestnet |
| USDC address | 0x0BA304580ee7c9a980CF72e55f5Ed2E9fd30Bc51 |
| Domain ID | 13 |
Faucet: Worldchain Sepolia
| Property | Value |
|---|---|
| Chain name | worldchainSepolia |
| USDC address | 0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88 |
| Domain ID | 14 |
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:Report incorrect code
Copy
# Set up your directory and initialize a Node.js project
mkdir unified-gateway-balance-evm
cd unified-gateway-balance-evm
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-evm="tsx --env-file=.env transfer-from-evm.ts"
npm pkg set scripts.balances="tsx --env-file=.env balances.ts"
# Install dependencies
npm install viem tsx typescript
npm install --save-dev @types/node
Report incorrect code
Copy
npm pkg set scripts.transfer-from-sol="tsx --env-file=.env transfer-from-sol.ts"
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
1.2. Initialize and configure the project
This command creates atsconfig.json file:
Shell
Report incorrect code
Copy
npx tsc --init
tsconfig.json file:
Shell
Report incorrect code
Copy
# 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 wallet private key,
replacing with the private key from your EVM wallet.
Report incorrect code
Copy
echo "EVM_PRIVATE_KEY={YOUR_PRIVATE_KEY}" > .env
Report incorrect code
Copy
echo "SOLANA_PRIVATE_KEYPAIR={YOUR_SOLANA_KEYPAIR_ARRAY}" >> .env
If your wallet exports a private key hash instead, you can use
bs58 to convert it:TypeScript
Report incorrect code
Copy
const bytes = bs58.decode({ YOUR_PRIVATE_KEY_HASH });
console.log(JSON.stringify(Array.from(bytes)));
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
Report incorrect code
Copy
touch config.ts
2.2. Configure wallet account and chain settings
Add the account setup, Gateway contract addresses, and chain-specific configuration to yourconfig.ts file. This includes RPC endpoints, USDC
addresses, and domain IDs for all supported testnet chains.
config.ts
Report incorrect code
Copy
import { type Address } from "viem";
import {
sepolia,
baseSepolia,
avalancheFuji,
arcTestnet,
hyperliquidEvmTestnet,
seiTestnet,
sonicTestnet,
worldchainSepolia,
} from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
/* Account Setup */
if (!process.env.EVM_PRIVATE_KEY) {
throw new Error("EVM_PRIVATE_KEY not set in environment");
}
export const account = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
/* Gateway Contract Addresses */
export const GATEWAY_WALLET_ADDRESS: Address =
"0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
export const GATEWAY_MINTER_ADDRESS: Address =
"0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
/* Chain Configuration */
export const chainConfigs = {
sepolia: {
chain: sepolia,
usdcAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" as Address,
domainId: 0,
},
baseSepolia: {
chain: baseSepolia,
usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address,
domainId: 6,
},
avalancheFuji: {
chain: avalancheFuji,
usdcAddress: "0x5425890298aed601595a70ab815c96711a31bc65" as Address,
domainId: 1,
},
arcTestnet: {
chain: arcTestnet,
usdcAddress: "0x3600000000000000000000000000000000000000" as Address,
domainId: 26,
},
hyperliquidEvmTestnet: {
chain: hyperliquidEvmTestnet,
usdcAddress: "0x2B3370eE501B4a559b57D449569354196457D8Ab" as Address,
domainId: 19,
},
seiTestnet: {
chain: seiTestnet,
usdcAddress: "0x4fCF1784B31630811181f670Aea7A7bEF803eaED" as Address,
domainId: 16,
},
sonicTestnet: {
chain: sonicTestnet,
usdcAddress: "0x0BA304580ee7c9a980CF72e55f5Ed2E9fd30Bc51" as Address,
domainId: 13,
},
worldchainSepolia: {
chain: worldchainSepolia,
usdcAddress: "0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88" as Address,
domainId: 14,
},
} as const;
export type ChainKey = keyof typeof chainConfigs;
/* CLI Argument Parsing Helper */
export function parseSelectedChains(): ChainKey[] {
const args = process.argv.slice(2);
const validChains = Object.keys(chainConfigs) as ChainKey[];
if (args.length === 0) {
throw new Error(
"No chains specified. Usage: npm run <script> <chain1> [chain2...] or 'all'",
);
}
if (args.length === 1 && args[0] === "all") {
return validChains;
}
const invalid = args.filter((c) => !validChains.includes(c as ChainKey));
if (invalid.length > 0) {
console.error(`Unsupported chain: ${invalid.join(", ")}`);
console.error(`Valid chains: ${validChains.join(", ")}, all`);
process.exit(1);
}
return [...new Set(args)] as ChainKey[];
}
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 contracts. The script accepts chain names as CLI arguments. Specify one or more chains (for example,arcTestnet baseSepolia)
or use all for all supported testnets. You can skip to the
full deposit script if you prefer.
3.1. Create the script file
Report incorrect code
Copy
touch deposit.ts
3.2. Define constants and ABI
deposit.ts
Report incorrect code
Copy
const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)
// Gateway Wallet ABI (minimal - only deposit function)
const gatewayWalletAbi = [
{
type: "function",
name: "deposit",
inputs: [
{ name: "token", type: "address" },
{ name: "value", type: "uint256" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
3.3. Setup clients and check balances
Set up the client and contracts for the chain, then verify sufficient USDC balance before depositing.deposit.ts
Report incorrect code
Copy
const config = chainConfigs[chainName];
// Create client for current chain
const client = createPublicClient({
chain: config.chain,
transport: http(),
});
// Get contract instances
const usdcContract = getContract({
address: config.usdcAddress,
abi: erc20Abi,
client,
});
const gatewayWallet = getContract({
address: GATEWAY_WALLET_ADDRESS,
abi: gatewayWalletAbi,
client,
});
// Check USDC balance
const balance = await usdcContract.read.balanceOf([account.address]);
console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);
if (balance < DEPOSIT_AMOUNT) {
throw new Error(
"Insufficient USDC balance. Please top up at https://faucet.circle.com",
);
}
3.4. Approve and deposit USDC
The main logic performs two key actions:- Approve USDC transfers: It calls the
approvemethod on the USDC contract to allow the Gateway Wallet contract to transfer USDC from your wallet. - Deposit USDC into Gateway: After receiving the approval transaction hash, it
calls the
depositmethod on the Gateway Wallet contract.
deposit.ts
Report incorrect code
Copy
// [1] Approve Gateway Wallet to spend USDC
console.log(
`Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${chainName}...`,
);
const approvalTx = await usdcContract.write.approve(
[GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: approvalTx });
console.log(`Approved on ${chainName}: ${approvalTx}`);
// [2] Deposit USDC into Gateway Wallet
console.log(
`Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
);
const depositTx = await gatewayWallet.write.deposit(
[config.usdcAddress, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: depositTx });
console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);
3.5. Full deposit script
The complete deposit script loops through selected chains, validates USDC balances, and deposits funds into the Gateway Wallet contract on each chain. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.deposit.ts
Report incorrect code
Copy
import {
createPublicClient,
getContract,
http,
erc20Abi,
formatUnits,
} from "viem";
import {
account,
chainConfigs,
parseSelectedChains,
GATEWAY_WALLET_ADDRESS,
} from "./config.js";
const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)
// Gateway Wallet ABI (minimal - only deposit function)
const gatewayWalletAbi = [
{
type: "function",
name: "deposit",
inputs: [
{ name: "token", type: "address" },
{ name: "value", type: "uint256" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
async function main() {
console.log(`Using account: ${account.address}\n`);
const selectedChains = parseSelectedChains();
console.log(`Depositing on: ${selectedChains.join(", ")}\n`);
for (const chainName of selectedChains) {
const config = chainConfigs[chainName];
// Create client for current chain
const client = createPublicClient({
chain: config.chain,
transport: http(),
});
// Get contract instances
const usdcContract = getContract({
address: config.usdcAddress,
abi: erc20Abi,
client,
});
const gatewayWallet = getContract({
address: GATEWAY_WALLET_ADDRESS,
abi: gatewayWalletAbi,
client,
});
console.log(`\n=== Processing ${chainName} ===`);
// Check USDC balance
const balance = await usdcContract.read.balanceOf([account.address]);
console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);
if (balance < DEPOSIT_AMOUNT) {
throw new Error(
"Insufficient USDC balance. Please top up at https://faucet.circle.com",
);
}
try {
// [1] Approve Gateway Wallet to spend USDC
console.log(
`Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${chainName}...`,
);
const approvalTx = await usdcContract.write.approve(
[GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: approvalTx });
console.log(`Approved on ${chainName}: ${approvalTx}`);
// [2] Deposit USDC into Gateway Wallet
console.log(
`Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
);
const depositTx = await gatewayWallet.write.deposit(
[config.usdcAddress, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: depositTx });
console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);
} catch (err) {
console.error(`Error on ${chainName}:`, err);
}
}
}
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 make the deposits. You must specify at least one chain using command-line arguments.Report incorrect code
Copy
# Deposit to all supported chains
npm run deposit -- all
# Deposit to a single chain
npm run deposit -- sepolia
# Deposit to multiple chains
npm run deposit -- baseSepolia avalancheFuji
3.7. Check the balances on the Gateway Wallet
Create a new file calledbalances.ts, and add the following code. This script
retrieves the USDC balances available from your Gateway Wallet on each supported
chain.
balances.ts
Report incorrect code
Copy
import { privateKeyToAccount } from "viem/accounts";
if (!process.env.EVM_PRIVATE_KEY) {
throw new Error("Missing EVM_PRIVATE_KEY in environment");
}
const DOMAINS = {
sepolia: 0,
avalancheFuji: 1,
baseSepolia: 6,
arcTestnet: 26,
hyperliquidEvmTestnet: 19,
seiTestnet: 16,
sonicTestnet: 13,
worldchainSepolia: 14,
};
async function main() {
const account = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const depositor = account.address;
console.log(`Depositor address: ${depositor}\n`);
const body = {
token: "USDC",
sources: Object.entries(DOMAINS).map(([_, domain]) => ({
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();
let total = 0;
for (const balance of result.balances) {
const chain =
Object.keys(DOMAINS).find(
(key) => DOMAINS[key 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);
});
Report incorrect code
Copy
npm run balances
- Transfer from EVM
- Transfer from Solana
Step 4: Transfer USDC from the crosschain balance
This section explains parts of the transfer script that burns USDC from source chains and mints on a destination chain via Gateway. The script accepts chain names as CLI arguments. Specify one or more source chains (for example,seiTestnet or arcTestnet) or use all for all supported testnets. You can
skip to the full transfer script if you prefer.4.1. Create the script file
Report incorrect code
Copy
touch transfer-from-evm.ts
4.2. Define constants and types
You can set which chain to deposit to by modifying theDESTINATION_CHAIN
value. This example sets it to seiTestnet. You can also set the amount to be
transferred from each source chain by changing the TRANSFER_VALUE.transfer-from-evm.ts
Report incorrect code
Copy
const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
// EIP-712 Domain and Types for Gateway burn intents
const domain = { name: "GatewayWallet", version: "1" };
const EIP712Domain = [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
] as const;
const TransferSpec = [
{ name: "version", type: "uint32" },
{ name: "sourceDomain", type: "uint32" },
{ name: "destinationDomain", type: "uint32" },
{ name: "sourceContract", type: "bytes32" },
{ name: "destinationContract", type: "bytes32" },
{ name: "sourceToken", type: "bytes32" },
{ name: "destinationToken", type: "bytes32" },
{ name: "sourceDepositor", type: "bytes32" },
{ name: "destinationRecipient", type: "bytes32" },
{ name: "sourceSigner", type: "bytes32" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "value", type: "uint256" },
{ name: "salt", type: "bytes32" },
{ name: "hookData", type: "bytes" },
] as const;
const BurnIntent = [
{ name: "maxBlockHeight", type: "uint256" },
{ name: "maxFee", type: "uint256" },
{ name: "spec", type: "TransferSpec" },
] as const;
const gatewayMinterAbi = [
{
type: "function",
name: "gatewayMint",
inputs: [
{ name: "attestationPayload", type: "bytes" },
{ name: "signature", type: "bytes" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
4.3. Add helper functions
transfer-from-evm.ts
Report incorrect code
Copy
// Create a burn intent for cross-chain transfer
function createBurnIntent(params: {
sourceChain: ChainKey;
depositorAddress: string;
recipientAddress?: string;
}) {
const {
sourceChain,
depositorAddress,
recipientAddress = depositorAddress,
} = params;
const sourceConfig = chainConfigs[sourceChain];
const destConfig = chainConfigs[DESTINATION_CHAIN];
return {
maxBlockHeight: maxUint256,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: sourceConfig.domainId,
destinationDomain: destConfig.domainId,
sourceContract: GATEWAY_WALLET_ADDRESS,
destinationContract: GATEWAY_MINTER_ADDRESS,
sourceToken: sourceConfig.usdcAddress,
destinationToken: destConfig.usdcAddress,
sourceDepositor: depositorAddress,
destinationRecipient: recipientAddress,
sourceSigner: depositorAddress,
destinationCaller: zeroAddress,
value: TRANSFER_VALUE,
salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
hookData: "0x" as Hex,
},
};
}
// Create EIP-712 typed data for signing
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
return {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: {
...burnIntent,
spec: {
...burnIntent.spec,
sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
destinationContract: addressToBytes32(
burnIntent.spec.destinationContract,
),
sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
destinationRecipient: addressToBytes32(
burnIntent.spec.destinationRecipient,
),
sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
},
},
};
}
// Convert address to bytes32
function addressToBytes32(address: string): Hex {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
4.4. Create and sign burn intents
transfer-from-evm.ts
Report incorrect code
Copy
const requests = [];
for (const chainName of selectedChains) {
console.log(
`Creating burn intent from ${chainName} → ${DESTINATION_CHAIN}...`,
);
const intent = createBurnIntent({
sourceChain: chainName,
depositorAddress: account.address,
});
const typedData = burnIntentTypedData(intent);
const signature = await account.signTypedData(typedData);
requests.push({ burnIntent: typedData.message, signature });
}
console.log("Signed burn intents.");
4.5. Request attestation from Gateway API
transfer-from-evm.ts
Report incorrect code
Copy
const response = await fetch(
"https://gateway-api-testnet.circle.com/v1/transfer",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requests, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = await response.json();
console.log("Gateway API response:", JSON.stringify(json, null, 2));
const attestation = json?.attestation;
const operatorSig = json?.signature;
if (!attestation || !operatorSig) {
throw new Error("Missing attestation or signature in response");
}
4.6. Mint on destination chain
transfer-from-evm.ts
Report incorrect code
Copy
const destConfig = chainConfigs[DESTINATION_CHAIN];
const destClient = createPublicClient({
chain: destConfig.chain,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: destConfig.chain,
transport: http(),
});
const destinationGatewayMinterContract = getContract({
address: GATEWAY_MINTER_ADDRESS,
abi: gatewayMinterAbi,
client: { public: destClient, wallet: walletClient },
});
console.log(`Minting funds on ${destConfig.chain.name}...`);
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
[attestation, operatorSig],
{ account },
);
await destClient.waitForTransactionReceipt({ hash: mintTx });
const totalMinted = BigInt(requests.length) * TRANSFER_VALUE;
console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);
4.7. Full EVM transfer script
The complete transfer script loops through selected source chains, creates and signs burn intents for each chain, submits them to the Gateway API for attestation, and mints USDC on the destination chain. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.transfer-from-evm.ts
Report incorrect code
Copy
import {
createPublicClient,
createWalletClient,
getContract,
http,
pad,
zeroAddress,
maxUint256,
formatUnits,
type Hex,
} from "viem";
import { randomBytes } from "node:crypto";
import {
account,
chainConfigs,
parseSelectedChains,
GATEWAY_WALLET_ADDRESS,
GATEWAY_MINTER_ADDRESS,
type ChainKey,
} from "./config.js";
const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
// EIP-712 Domain and Types for Gateway burn intents
const domain = { name: "GatewayWallet", version: "1" };
const EIP712Domain = [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
] as const;
const TransferSpec = [
{ name: "version", type: "uint32" },
{ name: "sourceDomain", type: "uint32" },
{ name: "destinationDomain", type: "uint32" },
{ name: "sourceContract", type: "bytes32" },
{ name: "destinationContract", type: "bytes32" },
{ name: "sourceToken", type: "bytes32" },
{ name: "destinationToken", type: "bytes32" },
{ name: "sourceDepositor", type: "bytes32" },
{ name: "destinationRecipient", type: "bytes32" },
{ name: "sourceSigner", type: "bytes32" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "value", type: "uint256" },
{ name: "salt", type: "bytes32" },
{ name: "hookData", type: "bytes" },
] as const;
const BurnIntent = [
{ name: "maxBlockHeight", type: "uint256" },
{ name: "maxFee", type: "uint256" },
{ name: "spec", type: "TransferSpec" },
] as const;
const gatewayMinterAbi = [
{
type: "function",
name: "gatewayMint",
inputs: [
{ name: "attestationPayload", type: "bytes" },
{ name: "signature", type: "bytes" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
// Create a burn intent for cross-chain transfer
function createBurnIntent(params: {
sourceChain: ChainKey;
depositorAddress: string;
recipientAddress?: string;
}) {
const {
sourceChain,
depositorAddress,
recipientAddress = depositorAddress,
} = params;
const sourceConfig = chainConfigs[sourceChain];
const destConfig = chainConfigs[DESTINATION_CHAIN];
return {
maxBlockHeight: maxUint256,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: sourceConfig.domainId,
destinationDomain: destConfig.domainId,
sourceContract: GATEWAY_WALLET_ADDRESS,
destinationContract: GATEWAY_MINTER_ADDRESS,
sourceToken: sourceConfig.usdcAddress,
destinationToken: destConfig.usdcAddress,
sourceDepositor: depositorAddress,
destinationRecipient: recipientAddress,
sourceSigner: depositorAddress,
destinationCaller: zeroAddress,
value: TRANSFER_VALUE,
salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
hookData: "0x" as Hex,
},
};
}
// Create EIP-712 typed data for signing
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
return {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: {
...burnIntent,
spec: {
...burnIntent.spec,
sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
destinationContract: addressToBytes32(
burnIntent.spec.destinationContract,
),
sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
destinationRecipient: addressToBytes32(
burnIntent.spec.destinationRecipient,
),
sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
},
},
};
}
// Convert address to bytes32
function addressToBytes32(address: string): Hex {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
async function main() {
console.log(`Using account: ${account.address}`);
const selectedChains = parseSelectedChains();
console.log(`Transfering balances from: ${selectedChains.join(", ")}`);
// [1] Create and sign burn intents for each source chain
const requests = [];
for (const chainName of selectedChains) {
console.log(
`Creating burn intent from ${chainName} → ${DESTINATION_CHAIN}...`,
);
const intent = createBurnIntent({
sourceChain: chainName,
depositorAddress: account.address,
});
const typedData = burnIntentTypedData(intent);
const signature = await account.signTypedData(typedData);
requests.push({ burnIntent: typedData.message, signature });
}
console.log("Signed burn intents.");
// [2] 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(requests, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = await response.json();
console.log("Gateway API response:", JSON.stringify(json, null, 2));
const attestation = json?.attestation;
const operatorSig = json?.signature;
if (!attestation || !operatorSig) {
throw new Error("Missing attestation or signature in response");
}
// [3] Mint on destination chain
const destConfig = chainConfigs[DESTINATION_CHAIN];
const destClient = createPublicClient({
chain: destConfig.chain,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: destConfig.chain,
transport: http(),
});
const destinationGatewayMinterContract = getContract({
address: GATEWAY_MINTER_ADDRESS,
abi: gatewayMinterAbi,
client: { public: destClient, wallet: walletClient },
});
console.log(`Minting funds on ${destConfig.chain.name}...`);
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
[attestation, operatorSig],
{ account },
);
await destClient.waitForTransactionReceipt({ hash: mintTx });
const totalMinted = BigInt(requests.length) * TRANSFER_VALUE;
console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the script to transfer USDC to destination chain
Run the transfer script to transfer 1 USDC from each selected Gateway balance to the destination chain.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.
Report incorrect code
Copy
# Transfer from all chains
npm run transfer-from-evm -- all
# Transfer from a single chain
npm run transfer-from-evm -- arcTestnet
# Transfer from multiple chains
npm run transfer-from-evm -- baseSepolia avalancheFuji
Step 4: Transfer USDC from the crosschain balance
This section explains parts of the transfer script that burns USDC from Solana Devnet and mints on an EVM chain via Gateway. You can skip to the full transfer script if you prefer.4.1. Create the script file
Report incorrect code
Copy
touch transfer-from-sol.ts
4.2. Define constants and types
You can set which chain to deposit to by modifying theDESTINATION_CHAIN
value. This example sets it to seiTestnet. You can also set the amount to be
transferred from each source chain by changing the TRANSFER_VALUE.transfer-from-sol.ts
Report incorrect code
Copy
/* Constants */
const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;
const GATEWAY_WALLET_ADDRESS = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const USDC_ADDRESS = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
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 gatewayMinterAbi = [
{
type: "function",
name: "gatewayMint",
inputs: [
{ name: "attestationPayload", type: "bytes" },
{ name: "signature", type: "bytes" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
4.3. Add helper functions
transfer-from-sol.ts
Report incorrect code
Copy
// Construct burn intent for Solana to EVM transfer
function createBurnIntent(params: {
sourceDepositor: string;
destinationRecipient: string;
}) {
const { sourceDepositor, destinationRecipient } = params;
const destConfig = chainConfigs[DESTINATION_CHAIN];
return {
maxBlockHeight: MAX_UINT64,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domainId,
sourceContract: solanaAddressToBytes32(GATEWAY_WALLET_ADDRESS),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(USDC_ADDRESS),
destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
sourceDepositor: solanaAddressToBytes32(sourceDepositor),
destinationRecipient: evmAddressToBytes32(destinationRecipient),
sourceSigner: solanaAddressToBytes32(sourceDepositor),
destinationCaller: evmAddressToBytes32(
"0x0000000000000000000000000000000000000000",
),
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")}`;
}
// Get Solana keypair from environment variable
function getSolanaKeypair(): Keypair {
if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
}
const secretKey = JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR);
return Keypair.fromSecretKey(Uint8Array.from(secretKey));
}
// Convert Solana address to 32-byte hex string
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
// Pad EVM address to 32 bytes
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
// Convert hex string to Solana PublicKey
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
const buffer = Buffer.from(cleanHex, "hex");
return new PublicKey(buffer);
}
// Serialize typed data (convert bigints to strings)
function stringifyTypedData(obj: unknown) {
return JSON.stringify(obj, (_key: string, value: unknown) =>
typeof value === "bigint" ? value.toString() : value,
);
}
4.4. Create and sign burn intent
transfer-from-sol.ts
Report incorrect code
Copy
const intent = createBurnIntent({
sourceDepositor: solanaKeypair.publicKey.toBase58(),
destinationRecipient: account.address,
});
const signature = signBurnIntent(solanaKeypair, intent);
const request = [{ burnIntent: intent, signature }];
console.log("Signed burn intents.");
4.5. Request attestation from Gateway API
transfer-from-sol.ts
Report incorrect code
Copy
const response = await fetch(
"https://gateway-api-testnet.circle.com/v1/transfer",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData(request),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = await response.json();
console.log("Gateway API response:", JSON.stringify(json, null, 2));
const attestation = json?.attestation;
const operatorSig = json?.signature;
if (!attestation || !operatorSig) {
throw new Error("Missing attestation or signature in response");
}
4.6. Mint on destination chain
transfer-from-sol.ts
Report incorrect code
Copy
const destConfig = chainConfigs[DESTINATION_CHAIN];
console.log(`Minting funds on ${destConfig.chain.name}...`);
const destClient = createPublicClient({
chain: destConfig.chain,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: destConfig.chain,
transport: http(),
});
const destinationGatewayMinterContract = getContract({
address: GATEWAY_MINTER_ADDRESS,
abi: gatewayMinterAbi,
client: { public: destClient, wallet: walletClient },
});
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
[attestation, operatorSig],
{ account },
);
await destClient.waitForTransactionReceipt({ hash: mintTx });
console.log(`Minted ${formatUnits(TRANSFER_VALUE, 6)} USDC`);
console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);
4.7. 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 the destination EVM chain. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.transfer-from-sol.ts
Report incorrect code
Copy
import { randomBytes } from "node:crypto";
import * as crypto from "crypto";
import {
createPublicClient,
createWalletClient,
getContract,
http,
pad,
formatUnits,
type Hex,
} from "viem";
import { Keypair, PublicKey } from "@solana/web3.js";
import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
import bs58 from "bs58";
import {
account,
chainConfigs,
GATEWAY_MINTER_ADDRESS,
type ChainKey,
} from "./config.js";
/* Constants */
const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;
const GATEWAY_WALLET_ADDRESS = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const USDC_ADDRESS = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
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 gatewayMinterAbi = [
{
type: "function",
name: "gatewayMint",
inputs: [
{ name: "attestationPayload", type: "bytes" },
{ name: "signature", type: "bytes" },
],
outputs: [],
stateMutability: "nonpayable",
},
] as const;
/* Helpers */
// Construct burn intent for Solana to EVM transfer
function createBurnIntent(params: {
sourceDepositor: string;
destinationRecipient: string;
}) {
const { sourceDepositor, destinationRecipient } = params;
const destConfig = chainConfigs[DESTINATION_CHAIN];
return {
maxBlockHeight: MAX_UINT64,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domainId,
sourceContract: solanaAddressToBytes32(GATEWAY_WALLET_ADDRESS),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(USDC_ADDRESS),
destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
sourceDepositor: solanaAddressToBytes32(sourceDepositor),
destinationRecipient: evmAddressToBytes32(destinationRecipient),
sourceSigner: solanaAddressToBytes32(sourceDepositor),
destinationCaller: evmAddressToBytes32(
"0x0000000000000000000000000000000000000000",
),
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")}`;
}
// Get Solana keypair from environment variable
function getSolanaKeypair(): Keypair {
if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
}
const secretKey = JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR);
return Keypair.fromSecretKey(Uint8Array.from(secretKey));
}
// Convert Solana address to 32-byte hex string
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
// Pad EVM address to 32 bytes
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
// Convert hex string to Solana PublicKey
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
const buffer = Buffer.from(cleanHex, "hex");
return new PublicKey(buffer);
}
// Serialize typed data (convert bigints to strings)
function stringifyTypedData(obj: unknown) {
return JSON.stringify(obj, (_key: string, value: unknown) =>
typeof value === "bigint" ? value.toString() : value,
);
}
/* Main logic */
async function main() {
const solanaKeypair = getSolanaKeypair();
console.log(`Sender (Solana): ${solanaKeypair.publicKey.toBase58()}`);
console.log(`Recipient (EVM): ${account.address}`);
console.log(`Transfering balances from: solanaDevnet`);
// [1] Create and sign burn intent
console.log(
`Creating burn intent from solanaDevnet → ${DESTINATION_CHAIN}...`,
);
const intent = createBurnIntent({
sourceDepositor: solanaKeypair.publicKey.toBase58(),
destinationRecipient: account.address,
});
const signature = signBurnIntent(solanaKeypair, intent);
const request = [{ burnIntent: intent, signature }];
console.log("Signed burn intents.");
// [2] 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: stringifyTypedData(request),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = await response.json();
console.log("Gateway API response:", JSON.stringify(json, null, 2));
const attestation = json?.attestation;
const operatorSig = json?.signature;
if (!attestation || !operatorSig) {
throw new Error("Missing attestation or signature in response");
}
// [3] Mint on destination chain
const destConfig = chainConfigs[DESTINATION_CHAIN];
console.log(`Minting funds on ${destConfig.chain.name}...`);
const destClient = createPublicClient({
chain: destConfig.chain,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: destConfig.chain,
transport: http(),
});
const destinationGatewayMinterContract = getContract({
address: GATEWAY_MINTER_ADDRESS,
abi: gatewayMinterAbi,
client: { public: destClient, wallet: walletClient },
});
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
[attestation, operatorSig],
{ account },
);
await destClient.waitForTransactionReceipt({ hash: mintTx });
console.log(`Minted ${formatUnits(TRANSFER_VALUE, 6)} USDC`);
console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the script to transfer USDC to destination chain
Run the transfer script to transfer 1 USDC from your Solana Devnet Gateway balance to the destination EVM chain.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.
Report incorrect code
Copy
npm run transfer-from-sol