Use
Unified Balance Kit
to simplify this integration.This quickstart uses a manual Gateway integration. It is for learning or for
developers who need direct control.To simplify, use Unified Balance Kit to deposit and spend USDC in just a few
lines of code.
- Circle Wallets
- Self-managed
Prerequisites
This quickstart uses Arc Testnet for deposits and theArc Testnet -> Base Sepolia route for the EVM transfer example. You can adapt
the same code to other supported EVM testnets later in the guide.Before you begin, ensure that you’ve:- Installed Node.js v22+
- Obtained a Circle API Key and Entity Secret from the Circle Console.
- Created Developer-Controlled Wallets for the chains you want to test.
- Funded your wallets with testnet tokens:
- Get testnet USDC from the Circle Faucet.
- Get test native tokens from the Console Faucet.
- Created a Solana Devnet Developer-Controlled Wallet to act as the source depositor
- Completed the deposit flow from the Solana quickstart first
Add testnet funds to your wallet
To interact with Gateway, you need test USDC and native tokens in your wallet on each chain you deposit from. In the default Arc Testnet deposit flow, the same USDC also covers transaction fees because Arc uses USDC as the native gas token.Use the Circle Faucet to get test USDC. If you have a Circle Developer Console account, you can use the Console Faucet to get testnet native tokens. In addition, the following faucets can also be used to fund your wallet with testnet native tokens:- 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
1.1. Create the project and install dependencies
# Set up your directory and initialize a Node.js project
mkdir unified-gateway-balance-evm-circle-wallets
cd unified-gateway-balance-evm-circle-wallets
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.balances="tsx --env-file=.env balances.ts"
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts"
# Install runtime dependencies
npm install @circle-fin/developer-controlled-wallets tsx typescript
# Install dev dependencies
npm install --save-dev @types/node
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 @solana/buffer-layout @solana/web3.js bs58
1.2. Configure TypeScript (optional)
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Set environment variables
Create a.env file in the project directory:.env
CIRCLE_API_KEY=YOUR_API_KEY
CIRCLE_ENTITY_SECRET=YOUR_ENTITY_SECRET
DEPOSITOR_ADDRESS=YOUR_SOURCE_WALLET_ADDRESS
RECIPIENT_ADDRESS=YOUR_DESTINATION_WALLET_ADDRESS
CIRCLE_API_KEYis your Circle API key.CIRCLE_ENTITY_SECRETis your Circle entity secret.DEPOSITOR_ADDRESSis the source depositor wallet for the script you are running.RECIPIENT_ADDRESSis the destination wallet. It is only required for transfer scripts.
transfer-from-evm.ts, both DEPOSITOR_ADDRESS and RECIPIENT_ADDRESS are
EVM addresses.For transfer-from-sol.ts, DEPOSITOR_ADDRESS is a Solana address and
RECIPIENT_ADDRESS is an EVM address.For the Circle Wallets Direct Mint path,
RECIPIENT_ADDRESS must be a
destination-chain Developer-Controlled Wallet address in your Circle account.
The script uses createContractExecutionTransaction(...) to submit
gatewayMint(...) from that wallet, so a regular external EVM address will
not work.Open
.env in your editor rather than writing values with shell commands, and
add .env to your .gitignore. This prevents credentials from leaking into
your shell history or version control.Step 2. Set up the configuration file
The deposit and transfer scripts share this configuration file.2.1. Create the configuration file
touch config.ts
2.2. Configure shared Gateway constants
Add the shared Gateway constants used by the deposit and transfer scripts. To swap chains later, keepchainConfig as the shared map and update the
SOURCE_CHAIN and DEST_CHAIN constants inside each script.config.ts
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
type WalletChain = "ARC-TESTNET" | "BASE-SEPOLIA";
type EvmChainConfig = {
chainName: string;
usdc: string;
domain: number;
walletChain: WalletChain;
};
export const chainConfig = {
arc: {
chainName: "Arc Testnet",
usdc: "0x3600000000000000000000000000000000000000",
domain: 26,
walletChain: "ARC-TESTNET",
},
base: {
chainName: "Base Sepolia",
usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
domain: 6,
walletChain: "BASE-SEPOLIA",
},
} as const satisfies Record<string, EvmChainConfig>;
export type ChainKey = keyof typeof chainConfig;
export const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";
export const GATEWAY_WALLET_ADDRESS =
"0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
export const GATEWAY_MINTER_ADDRESS =
"0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
export const API_KEY = process.env.CIRCLE_API_KEY!;
export const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET!;
export const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
if (!API_KEY || !ENTITY_SECRET || !DEPOSITOR_ADDRESS) {
console.error(
"Missing required env vars: CIRCLE_API_KEY, CIRCLE_ENTITY_SECRET, DEPOSITOR_ADDRESS",
);
process.exit(1);
}
/* Circle Wallets Client */
export const client = initiateDeveloperControlledWalletsClient({
apiKey: API_KEY,
entitySecret: ENTITY_SECRET,
});
export async function waitForTxCompletion(
client: ReturnType<typeof initiateDeveloperControlledWalletsClient>,
txId: string,
label: string,
) {
const terminalStates = new Set([
"COMPLETE",
"CONFIRMED",
"FAILED",
"DENIED",
"CANCELLED",
]);
process.stdout.write(`Waiting for ${label} (txId=${txId})\n`);
while (true) {
const { data } = await client.getTransaction({ id: txId });
const state = data?.transaction?.state;
process.stdout.write(".");
if (state && terminalStates.has(state)) {
process.stdout.write("\n");
console.log(`${label} final state: ${state}`);
if (state !== "COMPLETE" && state !== "CONFIRMED") {
throw new Error(
`${label} did not complete successfully (state=${state})`,
);
}
return data.transaction;
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
export function parseBalance(
value: string | number | null | undefined,
): bigint {
const str = String(value ?? "0");
const [whole, decimal = ""] = str.split(".");
const decimal6 = (decimal + "000000").slice(0, 6);
return BigInt((whole || "0") + decimal6);
}
export function addressToBytes32(address: string) {
return ("0x" +
address
.toLowerCase()
.replace(/^0x/, "")
.padStart(64, "0")) as `0x${string}`;
}
export function stringifyTypedData<T>(obj: T) {
return JSON.stringify(obj, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
);
}
Step 3. Deposit into a unified crosschain balance (Circle Wallets)
The deposit script deposits USDC into the Gateway Wallet on Arc Testnet. You can skip to the full deposit script if you prefer.Do not transfer USDC directly to the Gateway Wallet contract with a standard
ERC-20 transfer. You must call a Gateway deposit method or the USDC will not
be credited to your unified balance.
3.1. Create the deposit script
touch deposit.ts
3.2. Define constants and deposit amount
Set the deposit amount near the top of the script. This example uses Arc Testnet as the source chain, and you can swap chains later by changingSOURCE_CHAIN.const DEPOSIT_AMOUNT_USDC = "2";
3.3. Approve USDC spending and submit the deposit
The script first approves the Gateway Wallet contract to spend USDC, then calls the Gatewaydeposit(address,uint256) method on the configured source chain.const SOURCE_CHAIN = "arc" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const approveTx = await client.createContractExecutionTransaction({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
contractAddress: sourceConfig.usdc,
abiFunctionSignature: "approve(address,uint256)",
abiParameters: [
GATEWAY_WALLET_ADDRESS,
parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
const depositTx = await client.createContractExecutionTransaction({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
contractAddress: GATEWAY_WALLET_ADDRESS,
abiFunctionSignature: "deposit(address,uint256)",
abiParameters: [
sourceConfig.usdc,
parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
3.4. Wait for Circle Wallet transaction to complete
Circle Wallet contract execution is asynchronous. After each submit step, wait for transaction completion before proceeding to the next phase.await waitForTxCompletion(client, approveTxId, "USDC approve");
await waitForTxCompletion(client, depositTxId, "Gateway deposit");
3.5. Full deposit script (Circle Wallets)
The script approves USDC spending and deposits into the Gateway Wallet on the configured source chain. Inline comments explain each stage.deposit.ts
import {
chainConfig,
GATEWAY_WALLET_ADDRESS,
DEPOSITOR_ADDRESS,
client,
waitForTxCompletion,
parseBalance,
} from "./config.js";
const DEPOSIT_AMOUNT_USDC = "2";
const SOURCE_CHAIN = "arc" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
async function main() {
console.log(`Using account: ${DEPOSITOR_ADDRESS}`);
console.log(`Depositing on: ${sourceConfig.chainName}`);
// [1] Approve the Gateway Wallet to spend USDC on the source chain.
console.log(
`Approving ${DEPOSIT_AMOUNT_USDC} USDC on ${sourceConfig.chainName}...`,
);
const approveTx = await client.createContractExecutionTransaction({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
contractAddress: sourceConfig.usdc,
abiFunctionSignature: "approve(address,uint256)",
abiParameters: [
GATEWAY_WALLET_ADDRESS,
parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
const approveTxId = approveTx.data?.id;
if (!approveTxId) throw new Error("Failed to create approve transaction");
await waitForTxCompletion(client, approveTxId, "USDC approve");
// [2] Call the Gateway deposit function on the source chain.
console.log(`Depositing ${DEPOSIT_AMOUNT_USDC} USDC to Gateway Wallet`);
const depositTx = await client.createContractExecutionTransaction({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
contractAddress: GATEWAY_WALLET_ADDRESS,
abiFunctionSignature: "deposit(address,uint256)",
abiParameters: [
sourceConfig.usdc,
parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
const depositTxId = depositTx.data?.id;
if (!depositTxId) throw new Error("Failed to create deposit transaction");
await waitForTxCompletion(client, depositTxId, "Gateway deposit");
console.log(
"\n==| Block confirmation may take up to 19 minutes for some chains |==",
);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
3.6. Run the deposit script
Run the script:npm run deposit
SOURCE_CHAIN to another supported entry in chainConfig.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 for the
DEPOSITOR_ADDRESS currently set in .env.balances.ts
interface GatewayBalancesResponse {
balances: Array<{
domain: number;
balance: string;
}>;
}
const EVM_DOMAINS = {
base: 6,
arc: 26,
};
const SOLANA_DOMAINS = {
solana: 5,
};
const DOMAINS = { ...EVM_DOMAINS, ...SOLANA_DOMAINS };
const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
if (!DEPOSITOR_ADDRESS) {
console.error("Missing required env var: DEPOSITOR_ADDRESS");
process.exit(1);
}
const isEvmAddress = DEPOSITOR_ADDRESS.startsWith("0x");
async function main() {
console.log(`Depositor address: ${DEPOSITOR_ADDRESS}`);
console.log(`Address type: ${isEvmAddress ? "EVM" : "Solana"}\n`);
const activeDomains = isEvmAddress ? EVM_DOMAINS : SOLANA_DOMAINS;
const domainIds = Object.values(activeDomains);
const body = {
token: "USDC",
sources: domainIds.map((domain) => ({
domain,
depositor: DEPOSITOR_ADDRESS,
})),
};
const res = await fetch(
"https://gateway-api-testnet.circle.com/v1/balances",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const result = (await res.json()) as GatewayBalancesResponse;
let total = 0;
for (const balance of result.balances) {
const chain =
Object.keys(DOMAINS).find(
(k) => DOMAINS[k as keyof typeof DOMAINS] === balance.domain,
) || `Domain ${balance.domain}`;
const amount = parseFloat(balance.balance);
console.log(`${chain}: ${amount.toFixed(6)} USDC`);
total += amount;
}
console.log(`\nTotal: ${total.toFixed(6)} USDC`);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
npm run balances
- Transfer from EVM
- Transfer from Solana
Step 4. Transfer USDC from EVM to EVM
This step transfers USDC from your Arc Testnet Gateway balance to Base Sepolia. Both paths create and submit the burn intent from Arc Testnet first. Direct Mint then retrieves the Gateway attestation and callsgatewayMint(...) on Base
Sepolia from your wallet, while the Forwarding Service lets Circle complete the
destination mint for you.- Direct Mint
- Forwarding Service
4.1. Create the EVM transfer script (Circle Wallets)
You can skip to the full transfer script if you prefer.touch transfer-from-evm.ts
4.2. Define constants and types
This script uses Arc Testnet as the source chain and Base Sepolia as the destination chain.This direct-mint flow signs the burn intent on Arc Testnet, requests the Gateway attestation, and then callsgatewayMint(...) on Base Sepolia from your wallet.type GatewayTransferResponse = {
attestation: string;
signature: string;
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();
4.3. Add shared chain references
The script uses the shared chain map fromconfig.ts.const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destinationConfig = chainConfig[DEST_CHAIN];
4.4. Create and sign the burn intent
Create an EIP-712 burn intent for the Arc Testnet Gateway balance and sign it with the source Developer-Controlled Wallet.const burnIntent = createBurnIntent({
depositorAddress: DEPOSITOR_ADDRESS,
recipientAddress: RECIPIENT_ADDRESS,
});
const typedData = burnIntentTypedData(burnIntent);
const sigResp = await client.signTypedData({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
data: stringifyTypedData(typedData),
});
4.5. Request the Gateway attestation
Send the signed burn intent to the Gateway API and validate that the response includes both the attestation and the operator signature needed for minting.const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData(requests),
});
4.6. Mint on Base Sepolia
Once the Gateway API returns the attestation set, callgatewayMint(bytes,bytes) on Base Sepolia and wait for Circle Wallet
transaction completion.const tx = await client.createContractExecutionTransaction({
walletAddress: RECIPIENT_ADDRESS,
blockchain: destinationConfig.walletChain,
contractAddress: GATEWAY_MINTER_ADDRESS,
abiFunctionSignature: "gatewayMint(bytes,bytes)",
abiParameters: [attestation, operatorSig],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
await waitForTxCompletion(client, txId, "USDC mint");
4.7. Full EVM direct-mint script (Circle Wallets)
The script builds and signs a burn intent for Arc Testnet, requests the Gateway attestation, and mints on Base Sepolia. Inline comments explain each stage.transfer-from-evm.ts
import { randomBytes } from "node:crypto";
import {
chainConfig,
GATEWAY_API_BASE,
GATEWAY_WALLET_ADDRESS,
GATEWAY_MINTER_ADDRESS,
DEPOSITOR_ADDRESS,
client,
waitForTxCompletion,
addressToBytes32,
stringifyTypedData,
} from "./config.js";
type GatewayTransferResponse = {
attestation: string;
signature: string;
};
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;
if (!RECIPIENT_ADDRESS) {
console.error("Missing required env var: RECIPIENT_ADDRESS");
process.exit(1);
}
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destinationConfig = chainConfig[DEST_CHAIN];
const domain = { name: "GatewayWallet", version: "1" };
const EIP712Domain = [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
];
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" },
];
const BurnIntent = [
{ name: "maxBlockHeight", type: "uint256" },
{ name: "maxFee", type: "uint256" },
{ name: "spec", type: "TransferSpec" },
];
function createBurnIntent(params: {
depositorAddress: string;
recipientAddress?: string;
}) {
const { depositorAddress, recipientAddress = depositorAddress } = params;
return {
maxBlockHeight: MAX_UINT256_DEC,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: sourceConfig.domain,
destinationDomain: destinationConfig.domain,
sourceContract: GATEWAY_WALLET_ADDRESS,
destinationContract: GATEWAY_MINTER_ADDRESS,
sourceToken: sourceConfig.usdc,
destinationToken: destinationConfig.usdc,
sourceDepositor: depositorAddress,
destinationRecipient: recipientAddress,
sourceSigner: depositorAddress,
destinationCaller: "0x0000000000000000000000000000000000000000",
value: TRANSFER_VALUE,
salt: `0x${randomBytes(32).toString("hex")}`,
hookData: "0x",
},
};
}
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
return {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent",
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),
},
},
};
}
async function main() {
console.log("Transferring 1 USDC from Arc Testnet to Base Sepolia");
console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
console.log(`Recipient: ${RECIPIENT_ADDRESS}`);
// [1] Create and sign the Arc Testnet burn intent.
console.log("\n[1/3] Signing burn intent...");
const burnIntent = createBurnIntent({
depositorAddress: DEPOSITOR_ADDRESS,
recipientAddress: RECIPIENT_ADDRESS,
});
const typedData = burnIntentTypedData(burnIntent);
const sigResp = await client.signTypedData({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
data: stringifyTypedData(typedData),
});
const signature = sigResp.data?.signature;
if (!signature) throw new Error("Failed to sign burn intent");
const requests = [{ burnIntent: typedData.message, signature }];
// [2] Submit the burn intent to the Gateway API.
console.log("[2/3] Submitting burn intent to Gateway API...");
const transferResponse = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData(requests),
});
if (!transferResponse.ok) {
throw new Error(await transferResponse.text());
}
const { attestation, signature: operatorSig } =
(await transferResponse.json()) as GatewayTransferResponse;
if (!attestation || !operatorSig) {
throw new Error("Invalid Gateway API response");
}
// [3] Mint on Base Sepolia with the returned attestation set.
console.log(`[3/3] Minting on ${destinationConfig.chainName}...`);
const tx = await client.createContractExecutionTransaction({
walletAddress: RECIPIENT_ADDRESS,
blockchain: destinationConfig.walletChain,
contractAddress: GATEWAY_MINTER_ADDRESS,
abiFunctionSignature: "gatewayMint(bytes,bytes)",
abiParameters: [attestation, operatorSig],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
const txId = tx.data?.id;
if (!txId) throw new Error("Failed to submit mint transaction");
await waitForTxCompletion(client, txId, "USDC mint");
console.log(
`Transfer complete. 1 USDC minted on ${destinationConfig.chainName}.`,
);
console.log(`Mint transaction ID: ${txId}`);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the EVM direct-mint script
Run the script to burn from your Arc Testnet Gateway balance and callgatewayMint(...) on Base Sepolia.Confirm these values before running:DEPOSITOR_ADDRESSis the source Arc Testnet walletRECIPIENT_ADDRESSis the destination Base Sepolia wallet- the source depositor has a Gateway balance on Arc Testnet
- the destination wallet can submit the mint transaction on Base Sepolia
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-evm
4.1. Create the EVM transfer script
You can skip to the full transfer script if you prefer.touch transfer-from-evm.ts
4.2. Define constants and types
This script uses Arc Testnet as the source chain and Base Sepolia as the destination chain.This forwarding flow signs the burn intent on Arc Testnet, submits it with forwarding enabled, and then lets Circle complete the destination mint on Base Sepolia.type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
4.3. Define the burn-intent shape
The forwarding flow first estimates the burn-intent limits for the Arc Testnet to Base Sepolia route, then uses the same burn-intent shape for signing and submission.type TransferSpecBytes32 = {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: `0x${string}`;
destinationContract: `0x${string}`;
sourceToken: `0x${string}`;
destinationToken: `0x${string}`;
sourceDepositor: `0x${string}`;
destinationRecipient: `0x${string}`;
sourceSigner: `0x${string}`;
destinationCaller: `0x${string}`;
value: bigint;
salt: `0x${string}`;
hookData: `0x${string}`;
};
4.4. Estimate forwarding fees
Before you submit the forwarding transfer, call the Gateway estimate endpoint withenableForwarder=true. The response returns the maxFee and
maxBlockHeight values you must include in the signed burn intent.const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ spec }]),
},
);
4.5. Sign and submit the forwarding burn intent
Sign the burn intent with the Arc Testnet Developer-Controlled Wallet, then submit it to the Gateway transfer endpoint withenableForwarder=true.const sigResp = await client.signTypedData({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
data: stringifyTypedData(typedData),
});
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ burnIntent: typedData.message, signature }]),
},
);
4.6. Poll for forwarding completion
The Forwarding Service completes the Base Sepolia mint for you. Poll the transfer status endpoint until the transfer is finalized or fails.const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
4.7. Full EVM forwarding script (Circle Wallets)
The script estimates forwarding fees, signs the burn intent on Arc Testnet, submits it to the Forwarding Service, and polls until Circle completes the Base Sepolia mint. Inline comments explain each stage.transfer-from-evm.ts
import { randomBytes } from "node:crypto";
import {
chainConfig,
GATEWAY_API_BASE,
GATEWAY_WALLET_ADDRESS,
GATEWAY_MINTER_ADDRESS,
DEPOSITOR_ADDRESS,
client,
addressToBytes32,
stringifyTypedData,
} from "./config.js";
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
type TransferSpecBytes32 = {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: `0x${string}`;
destinationContract: `0x${string}`;
sourceToken: `0x${string}`;
destinationToken: `0x${string}`;
sourceDepositor: `0x${string}`;
destinationRecipient: `0x${string}`;
sourceSigner: `0x${string}`;
destinationCaller: `0x${string}`;
value: bigint;
salt: `0x${string}`;
hookData: `0x${string}`;
};
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;
if (!RECIPIENT_ADDRESS) {
console.error("Missing required env var: RECIPIENT_ADDRESS");
process.exit(1);
}
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destinationConfig = chainConfig[DEST_CHAIN];
const domain = { name: "GatewayWallet", version: "1" };
const EIP712Domain = [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
];
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" },
];
const BurnIntent = [
{ name: "maxBlockHeight", type: "uint256" },
{ name: "maxFee", type: "uint256" },
{ name: "spec", type: "TransferSpec" },
];
function createTransferSpec(): TransferSpecBytes32 {
return {
version: 1,
sourceDomain: sourceConfig.domain,
destinationDomain: destinationConfig.domain,
sourceContract: addressToBytes32(GATEWAY_WALLET_ADDRESS),
destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: addressToBytes32(sourceConfig.usdc),
destinationToken: addressToBytes32(destinationConfig.usdc),
sourceDepositor: addressToBytes32(DEPOSITOR_ADDRESS),
destinationRecipient: addressToBytes32(RECIPIENT_ADDRESS),
sourceSigner: addressToBytes32(DEPOSITOR_ADDRESS),
destinationCaller: addressToBytes32(
"0x0000000000000000000000000000000000000000",
),
value: TRANSFER_VALUE,
salt: `0x${randomBytes(32).toString("hex")}`,
hookData: "0x",
};
}
async function main() {
console.log("Forwarding 1 USDC from Arc Testnet to Base Sepolia");
console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
console.log(`Recipient: ${RECIPIENT_ADDRESS}`);
const spec = createTransferSpec();
// [1] Estimate forwarding fee inputs for the burn intent.
console.log("\n[1/3] Estimating forwarding fees...");
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ spec }]),
},
);
if (!estimateResponse.ok) {
const text = await estimateResponse.text();
throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
}
const estimateResult = (await estimateResponse.json()) as EstimateResponse;
const estimated = estimateResult.body[0]?.burnIntent;
if (!estimated) throw new Error("Missing burnIntent estimate");
const maxFee = BigInt(estimated.maxFee);
const maxBlockHeight = BigInt(estimated.maxBlockHeight);
const { fees } = estimateResult;
if (fees.forwardingFee) {
console.log(
`Forwarding fee: ${Number(fees.forwardingFee) / 1_000_000} ${fees.token}`,
);
}
console.log(`Estimated maxFee: ${Number(maxFee) / 1_000_000} ${fees.token}`);
// [2] Sign and submit the forwarding burn intent.
console.log("\n[2/3] Signing and submitting burn intent...");
const typedData = {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: { maxBlockHeight, maxFee, spec },
};
const sigResp = await client.signTypedData({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: sourceConfig.walletChain,
data: stringifyTypedData(typedData),
});
const signature = sigResp.data?.signature;
if (!signature) throw new Error("Failed to sign burn intent");
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ burnIntent: typedData.message, signature }]),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = (await response.json()) as TransferResponse;
if (!json.transferId) throw new Error("Missing transferId in response");
const transferId = json.transferId;
console.log(`Transfer ID: ${transferId}`);
// [3] Poll until the Forwarding Service completes the destination mint.
console.log("\n[3/3] Polling for transfer completion...");
const pollStart = Date.now();
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
const pollRes = await fetch(
`${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
);
if (!pollRes.ok) {
console.error(`Poll error: ${pollRes.status}`);
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const details = (await pollRes.json()) as TransferStatusResponse;
console.log(`Status: ${details.status}`);
if (details.status === "finalized" || details.status === "confirmed") {
if (details.transactionHash) {
console.log(`Mint transaction hash: ${details.transactionHash}`);
}
break;
}
if (details.status === "failed") {
const reason = details.forwardingDetails?.failureReason ?? "unknown";
throw new Error(`Transfer failed: ${reason}`);
}
if (details.status === "expired") {
throw new Error("Transfer attestation expired before forwarding");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
throw new Error("Polling timed out waiting for transfer completion");
}
console.log(
`Transfer complete. 1 USDC forwarded to ${destinationConfig.chainName}.`,
);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the EVM forwarding script
Run the script to burn from your Arc Testnet Gateway balance and let the Forwarding Service mint on Base Sepolia.Confirm these values before running:DEPOSITOR_ADDRESSis the source Arc Testnet walletRECIPIENT_ADDRESSis the destination Base Sepolia wallet- the source depositor has a Gateway balance on Arc Testnet
- no destination gas is required for this forwarding path
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-evm
Step 4. Transfer USDC from Solana to EVM
This step transfers USDC from your Solana Devnet Gateway balance to Arc Testnet. Both paths create and sign the Solana burn intent first. Direct Mint then requests the Gateway attestation and callsgatewayMint(...) on Arc Testnet
from your wallet, while the Forwarding Service lets Circle complete the
destination mint for you.- Direct Mint
- Forwarding Service
4.1. Create the Solana to EVM transfer script
You can skip to the full transfer script if you prefer.touch transfer-from-sol.ts
4.2. Define constants and types
This script uses Arc Testnet as the destination chain.This direct-mint flow signs the burn intent on Solana Devnet, requests the Gateway attestation, and then callsgatewayMint(...) on Arc Testnet from your
wallet.const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
4.3. Add helper functions
The helper layer encodes the Solana burn intent into the binary layout expected by Gateway, then normalizes addresses and payloads for the API request.function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
}
4.4. Create and sign burn intent
Build the Solana burn intent, prefix the encoded payload, and sign it with the source Solana Developer-Controlled Wallet.For Solana-origin transfers, the burn intent uses Solana-specific binary encoding and must be signed with the Solana signing-domain prefix. See the Solana Technical Guide for the signing rules and message format details.const burnIntent = createBurnIntent({
sourceDepositor: DEPOSITOR_ADDRESS,
destinationRecipient: RECIPIENT_ADDRESS,
});
const encoded = encodeBurnIntent(burnIntent);
const prefixed = Buffer.concat([
Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
encoded,
]);
const sigResult = await client.signMessage({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: "SOL-DEVNET",
encodedByHex: true,
message: "0x" + prefixed.toString("hex"),
});
4.5. Request attestation from Gateway API
Submit the signed burn intent to the Gateway API and verify that it returns the attestation and operator signature required by the destination minter.const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData(request),
});
4.6. Mint on Arc Testnet
Use the destination EVM Developer-Controlled Wallet to callgatewayMint(bytes,bytes) on the Gateway Minter contract, then wait for
completion.const tx = await client.createContractExecutionTransaction({
walletAddress: RECIPIENT_ADDRESS,
blockchain: destConfig.walletChain,
contractAddress: GATEWAY_MINTER_ADDRESS,
abiFunctionSignature: "gatewayMint(bytes,bytes)",
abiParameters: [attestation, operatorSig],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
await waitForTxCompletion(client, txId, "USDC mint");
4.7. Full Solana direct-mint script (Circle Wallets)
The script signs a Solana burn intent, requests a Gateway attestation, and mints on Arc Testnet. Inline comments explain each stage.transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import { PublicKey } from "@solana/web3.js";
import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
import bs58 from "bs58";
import {
chainConfig,
GATEWAY_API_BASE,
GATEWAY_MINTER_ADDRESS,
client,
waitForTxCompletion,
} from "./config.js";
const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;
if (!DEPOSITOR_ADDRESS || !RECIPIENT_ADDRESS) {
console.error(
"Missing required env vars: DEPOSITOR_ADDRESS, RECIPIENT_ADDRESS",
);
process.exit(1);
}
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const DEST_CHAIN = "arc" as const;
const TRANSFER_AMOUNT = 0.1;
const TRANSFER_VALUE = BigInt(Math.floor(TRANSFER_AMOUNT * 1e6));
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
// Custom layout for Solana PublicKey values in the burn intent payload.
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);
const BurnIntentLayout = struct([
u32be("magic"),
uint256be("maxBlockHeight"),
uint256be("maxFee"),
u32be("transferSpecLength"),
struct(
[
u32be("magic"),
u32be("version"),
u32be("sourceDomain"),
u32be("destinationDomain"),
publicKey("sourceContract"),
publicKey("destinationContract"),
publicKey("sourceToken"),
publicKey("destinationToken"),
publicKey("sourceDepositor"),
publicKey("destinationRecipient"),
publicKey("sourceSigner"),
publicKey("destinationCaller"),
uint256be("value"),
blob(32, "salt"),
u32be("hookDataLength"),
blob(offset(u32be(), -4), "hookData"),
] as any,
"spec",
),
] as any);
function createBurnIntent(params: {
sourceDepositor: string;
destinationRecipient: string;
}) {
const { sourceDepositor, destinationRecipient } = params;
const destConfig = chainConfig[DEST_CHAIN];
return {
maxBlockHeight: MAX_UINT64,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domain,
sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(SOLANA_USDC),
destinationToken: evmAddressToBytes32(destConfig.usdc),
sourceDepositor: solanaAddressToBytes32(sourceDepositor),
destinationRecipient: evmAddressToBytes32(destinationRecipient),
sourceSigner: solanaAddressToBytes32(sourceDepositor),
destinationCaller: evmAddressToBytes32(
"0x0000000000000000000000000000000000000000",
),
value: TRANSFER_VALUE,
salt: "0x" + randomBytes(32).toString("hex"),
hookData: "0x",
},
};
}
// Encode the burn intent into the binary layout expected by Gateway.
function encodeBurnIntent(bi: ReturnType<typeof createBurnIntent>): Buffer {
const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
const prepared = {
magic: BURN_INTENT_MAGIC,
maxBlockHeight: bi.maxBlockHeight,
maxFee: bi.maxFee,
transferSpecLength: 340 + hookData.length,
spec: {
magic: TRANSFER_SPEC_MAGIC,
version: bi.spec.version,
sourceDomain: bi.spec.sourceDomain,
destinationDomain: bi.spec.destinationDomain,
sourceContract: hexToPublicKey(bi.spec.sourceContract),
destinationContract: hexToPublicKey(bi.spec.destinationContract),
sourceToken: hexToPublicKey(bi.spec.sourceToken),
destinationToken: hexToPublicKey(bi.spec.destinationToken),
sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
value: bi.spec.value,
salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
hookDataLength: hookData.length,
hookData,
},
};
const buffer = Buffer.alloc(72 + 340 + hookData.length);
const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
return buffer.subarray(0, bytesWritten);
}
// Convert a Solana address to a 32-byte hex string.
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
// Convert an EVM address to a 32-byte hex string.
function evmAddressToBytes32(address: string): string {
return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
}
// Convert a 32-byte hex string into a Solana PublicKey.
function hexToPublicKey(hex: string): PublicKey {
return new PublicKey(Buffer.from(hex.slice(2), "hex"));
}
// Serialize typed data while converting bigint values to strings.
function stringifyTypedData<T>(obj: T) {
return JSON.stringify(obj, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
);
}
async function main() {
const destConfig = chainConfig[DEST_CHAIN];
console.log(`Sender (Solana): ${DEPOSITOR_ADDRESS}`);
console.log(`Recipient (EVM): ${RECIPIENT_ADDRESS}`);
console.log(`Transferring from: Solana Devnet -> ${destConfig.chainName}`);
console.log(
`Creating burn intent from Solana Devnet -> ${destConfig.chainName}...`,
);
// [1] Create and sign the Solana burn intent.
const burnIntent = createBurnIntent({
sourceDepositor: DEPOSITOR_ADDRESS,
destinationRecipient: RECIPIENT_ADDRESS,
});
const encoded = encodeBurnIntent(burnIntent);
const prefixed = Buffer.concat([
Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
encoded,
]);
const sigResult = await client.signMessage({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: "SOL-DEVNET",
encodedByHex: true,
message: "0x" + prefixed.toString("hex"),
});
const burnIntentSignature = sigResult.data?.signature;
if (!burnIntentSignature) throw new Error("Failed to sign burn intent");
const formattedSignature = burnIntentSignature.startsWith("0x")
? burnIntentSignature
: `0x${burnIntentSignature}`;
const request = [{ burnIntent, signature: formattedSignature }];
console.log("Signed burn intent.");
// [2] Request the attestation set from Gateway API.
const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData(request),
});
if (!response.ok) {
console.error("Gateway API error status:", response.status);
console.error(await response.text());
throw new Error("Gateway API request failed");
}
const json = (await response.json()) as {
attestation: string;
signature: string;
};
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 Gateway API response");
}
// [3] Mint on Arc Testnet with the returned attestation.
console.log(`Minting funds on ${destConfig.chainName}...`);
const tx = await client.createContractExecutionTransaction({
walletAddress: RECIPIENT_ADDRESS,
blockchain: destConfig.walletChain,
contractAddress: GATEWAY_MINTER_ADDRESS,
abiFunctionSignature: "gatewayMint(bytes,bytes)",
abiParameters: [attestation, operatorSig],
fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});
const txId = tx.data?.id;
if (!txId) throw new Error("Failed to submit mint transaction");
console.log("Mint tx submitted:", txId);
await waitForTxCompletion(client, txId, "USDC mint");
console.log(`Minted ${Number(TRANSFER_VALUE) / 1_000_000} USDC`);
console.log(`Mint transaction ID (${destConfig.walletChain}):`, txId);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the Solana to EVM direct-mint script
Run the script to burn from your Solana Devnet Gateway balance and callgatewayMint(...) on Arc Testnet.Confirm these values before running:DEPOSITOR_ADDRESSis the source Solana Devnet walletRECIPIENT_ADDRESSis the destination Arc Testnet wallet- the source depositor has a Gateway balance on Solana Devnet
- the destination Arc Testnet wallet can submit the mint transaction
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-sol
4.1. Create the Solana to EVM transfer script
You can skip to the full transfer script if you prefer.touch transfer-from-sol.ts
4.2. Define constants and types
This script uses Arc Testnet as the destination chain.This forwarding flow signs the burn intent on Solana Devnet, submits it with forwarding enabled, and then lets Circle complete the destination mint on Arc Testnet.type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 100_000n; // 0.1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
4.3. Estimate forwarding fees
Before you submit the forwarding transfer, call the Gateway estimate endpoint withenableForwarder=true. The response returns the maxFee and
maxBlockHeight values you must include in the signed burn intent.const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ spec }]),
},
);
4.4. Sign and submit the forwarding burn intent
Create the Solana burn intent with the estimated values, prefix the encoded payload, and sign it with the source Solana Developer-Controlled Wallet. Then submit it to the Gateway transfer endpoint withenableForwarder=true.For Solana-origin transfers, the burn intent uses Solana-specific binary
encoding and must be signed with the Solana signing-domain prefix. See the
Solana Technical Guide for the signing
rules and message format details.const sigResult = await client.signMessage({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: "SOL-DEVNET",
encodedByHex: true,
message: "0x" + prefixed.toString("hex"),
});
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ burnIntent, signature }]),
},
);
4.5. Poll for forwarding completion
The Forwarding Service completes the Arc Testnet mint for you. Poll the transfer status endpoint until the transfer is finalized or fails.const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
4.6. Full Solana forwarding script (Circle Wallets)
The script estimates forwarding fees, signs the burn intent on Solana Devnet, submits it to the Forwarding Service, and polls until Circle completes the Arc Testnet mint. Inline comments explain each stage.transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import { PublicKey } from "@solana/web3.js";
import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
import bs58 from "bs58";
import {
chainConfig,
GATEWAY_API_BASE,
GATEWAY_MINTER_ADDRESS,
client,
stringifyTypedData,
} from "./config.js";
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;
if (!DEPOSITOR_ADDRESS || !RECIPIENT_ADDRESS) {
console.error(
"Missing required env vars: DEPOSITOR_ADDRESS, RECIPIENT_ADDRESS",
);
process.exit(1);
}
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const DEST_CHAIN = "arc" as const;
const TRANSFER_VALUE = 100_000n; // 0.1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
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);
class UInt256BE extends Layout<bigint> {
constructor(property: string) {
super(32, property);
}
decode(b: Buffer, offset = 0) {
const buffer = b.subarray(offset, offset + 32);
return buffer.readBigUInt64BE(24);
}
encode(src: bigint, b: Buffer, offset = 0) {
const buffer = Buffer.alloc(32);
buffer.writeBigUInt64BE(BigInt(src), 24);
buffer.copy(b, offset);
return 32;
}
}
const uint256be = (property: string) => new UInt256BE(property);
const BurnIntentLayout = struct([
u32be("magic"),
uint256be("maxBlockHeight"),
uint256be("maxFee"),
u32be("transferSpecLength"),
struct(
[
u32be("magic"),
u32be("version"),
u32be("sourceDomain"),
u32be("destinationDomain"),
publicKey("sourceContract"),
publicKey("destinationContract"),
publicKey("sourceToken"),
publicKey("destinationToken"),
publicKey("sourceDepositor"),
publicKey("destinationRecipient"),
publicKey("sourceSigner"),
publicKey("destinationCaller"),
uint256be("value"),
blob(32, "salt"),
u32be("hookDataLength"),
blob(offset(u32be(), -4), "hookData"),
] as any,
"spec",
),
] as any);
function createTransferSpec(params: {
sourceDepositor: string;
destinationRecipient: string;
}) {
const { sourceDepositor, destinationRecipient } = params;
const destConfig = chainConfig[DEST_CHAIN];
return {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domain,
sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(SOLANA_USDC),
destinationToken: evmAddressToBytes32(destConfig.usdc),
sourceDepositor: solanaAddressToBytes32(sourceDepositor),
destinationRecipient: evmAddressToBytes32(destinationRecipient),
sourceSigner: solanaAddressToBytes32(sourceDepositor),
destinationCaller: evmAddressToBytes32(
"0x0000000000000000000000000000000000000000",
),
value: TRANSFER_VALUE,
salt: `0x${randomBytes(32).toString("hex")}`,
hookData: "0x",
};
}
function encodeBurnIntent(bi: {
maxBlockHeight: bigint;
maxFee: bigint;
spec: ReturnType<typeof createTransferSpec>;
}): 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);
}
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
}
function hexToPublicKey(hex: string): PublicKey {
return new PublicKey(Buffer.from(hex.slice(2), "hex"));
}
async function main() {
const destConfig = chainConfig[DEST_CHAIN];
console.log(
`Forwarding 0.1 USDC from Solana Devnet to ${destConfig.chainName}`,
);
console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
console.log(`Recipient: ${RECIPIENT_ADDRESS}`);
const spec = createTransferSpec({
sourceDepositor: DEPOSITOR_ADDRESS,
destinationRecipient: RECIPIENT_ADDRESS,
});
// [1] Estimate forwarding fee inputs for the burn intent.
console.log("\n[1/3] Estimating forwarding fees...");
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ spec }]),
},
);
if (!estimateResponse.ok) {
const text = await estimateResponse.text();
throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
}
const estimateResult = (await estimateResponse.json()) as EstimateResponse;
const estimated = estimateResult.body[0]?.burnIntent;
if (!estimated) throw new Error("Missing burnIntent estimate");
const maxFee = BigInt(estimated.maxFee);
const maxBlockHeight = BigInt(estimated.maxBlockHeight);
const { fees } = estimateResult;
if (fees.forwardingFee) {
console.log(`Forwarding fee: ${fees.forwardingFee} ${fees.token}`);
}
console.log(`Estimated maxFee: ${Number(maxFee) / 1_000_000} ${fees.token}`);
// [2] Create, sign, and submit the forwarding burn intent.
console.log("\n[2/3] Signing and submitting burn intent...");
const burnIntent = { maxBlockHeight, maxFee, spec };
const encoded = encodeBurnIntent(burnIntent);
const prefixed = Buffer.concat([
Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
encoded,
]);
const sigResult = await client.signMessage({
walletAddress: DEPOSITOR_ADDRESS,
blockchain: "SOL-DEVNET",
encodedByHex: true,
message: "0x" + prefixed.toString("hex"),
});
const signature = sigResult.data?.signature;
if (!signature) throw new Error("Failed to sign burn intent");
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyTypedData([{ burnIntent, signature }]),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = (await response.json()) as TransferResponse;
if (!json.transferId) throw new Error("Missing transferId in response");
const transferId = json.transferId;
console.log(`Transfer ID: ${transferId}`);
// [3] Poll until the Forwarding Service completes the destination mint.
console.log("\n[3/3] Polling for transfer completion...");
const pollStart = Date.now();
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
const pollRes = await fetch(
`${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
);
if (!pollRes.ok) {
console.error(`Poll error: ${pollRes.status}`);
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const details = (await pollRes.json()) as TransferStatusResponse;
console.log(`Status: ${details.status}`);
if (details.status === "finalized" || details.status === "confirmed") {
if (details.transactionHash) {
console.log(`Mint transaction hash: ${details.transactionHash}`);
}
break;
}
if (details.status === "failed") {
const reason = details.forwardingDetails?.failureReason ?? "unknown";
throw new Error(`Transfer failed: ${reason}`);
}
if (details.status === "expired") {
throw new Error("Transfer attestation expired before forwarding");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
throw new Error("Polling timed out waiting for transfer completion");
}
console.log(
`Transfer complete. 0.1 USDC forwarded to ${destConfig.chainName}.`,
);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.7. Run the Solana forwarding script
Run the script to burn from your Solana Devnet Gateway balance and let the Forwarding Service mint on Arc Testnet.Confirm these values before running:DEPOSITOR_ADDRESSis the source Solana Devnet walletRECIPIENT_ADDRESSis the destination Arc Testnet wallet- the source depositor has a Gateway balance on Solana Devnet
- no destination gas is required for this forwarding path
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-sol
Prerequisites
This quickstart uses Arc Testnet for deposits and theArc Testnet -> Base Sepolia route for the EVM transfer example. You can adapt
the same code to other supported EVM testnets later in the guide.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. In the default Arc Testnet deposit flow, the same USDC also covers transaction fees because Arc uses USDC as the native gas token. For direct-mint transfers, you also need the destination chain’s gas token 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
1.1. Create the project and install dependencies
# 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.balances="tsx --env-file=.env balances.ts"
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts"
# Install runtime dependencies
npm install viem tsx typescript
# Install dev dependencies
npm install --save-dev @types/node
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 @solana/buffer-layout @solana/web3.js bs58
1.2. Configure TypeScript (optional)
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Set environment variables
Open.env in your editor and add:EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
SOLANA_PRIVATE_KEYPAIR=YOUR_SOLANA_KEYPAIR_ARRAY
EVM_PRIVATE_KEYis the private key for the EVM wallet you use for the EVM side of the transfer.SOLANA_PRIVATE_KEYPAIRis the Solana wallet keypair as a JSON array for the Solana side of the transfer.
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)));
Open
.env in your editor rather than writing values with shell commands, and
add .env to your .gitignore. This prevents credentials from leaking into
your shell history or version control.This example uses one or more private keys for local testing. In production,
use a secure key management solution and never expose or share private keys.
Step 2. Set up the configuration file
This section covers the shared configuration file used by both the deposit and transfer scripts.2.1. Create the configuration file
touch config.ts
2.2. Configure shared Gateway constants
Add only the shared values used across the deposit and transfer scripts. This keepsconfig.ts focused on shared chain data without turning it into a generic
chain-selector layer. To swap chains later, leave chainConfig as the shared
map and change the SOURCE_CHAIN and DEST_CHAIN constants inside each script.config.ts
import { type Address } from "viem";
import { baseSepolia, arcTestnet } 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";
export const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";
export const chainConfig = {
arc: {
chain: arcTestnet,
usdcAddress: "0x3600000000000000000000000000000000000000" as Address,
domainId: 26,
},
base: {
chain: baseSepolia,
usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address,
domainId: 6,
},
} as const;
export type ChainKey = keyof typeof chainConfig;
export function stringifyBigInts<T>(obj: T) {
return JSON.stringify(obj, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
);
}
Step 3. Deposit into a unified crosschain balance (Self-managed)
This section explains the deposit script that deposits USDC into the Gateway Wallet contract on Arc Testnet. 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 ABI
deposit.ts
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
const SOURCE_CHAIN = "arc" as const;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const chainName = sourceConfig.chain.name;
const client = createPublicClient({
chain: sourceConfig.chain,
transport: http(),
});
// Get contract instances
const usdcContract = getContract({
address: sourceConfig.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
// [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(
[sourceConfig.usdcAddress, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: depositTx });
console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);
3.5. Full deposit script (Self-managed)
The complete deposit script checks the source-chain USDC balance, then deposits funds into the Gateway Wallet contract. The script includes inline comments to explain each stage.deposit.ts
import {
createPublicClient,
getContract,
http,
erc20Abi,
formatUnits,
} from "viem";
import { account, chainConfig, GATEWAY_WALLET_ADDRESS } from "./config.js";
const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)
const SOURCE_CHAIN = "arc" as const;
// 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() {
const sourceConfig = chainConfig[SOURCE_CHAIN];
console.log(`Using account: ${account.address}\n`);
console.log(`Depositing on: ${sourceConfig.chain.name}\n`);
// Create the client for the source chain.
const client = createPublicClient({
chain: sourceConfig.chain,
transport: http(),
});
// Get the USDC and Gateway Wallet contracts.
const usdcContract = getContract({
address: sourceConfig.usdcAddress,
abi: erc20Abi,
client,
});
const gatewayWallet = getContract({
address: GATEWAY_WALLET_ADDRESS,
abi: gatewayWalletAbi,
client,
});
// [1] Check the current source-chain 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",
);
}
// [2] Approve the Gateway Wallet to spend USDC.
console.log(
`Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${sourceConfig.chain.name}...`,
);
const approvalTx = await usdcContract.write.approve(
[GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: approvalTx });
console.log(`Approved on ${sourceConfig.chain.name}: ${approvalTx}`);
// [3] Deposit USDC into the source-chain Gateway Wallet.
console.log(
`Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
);
const depositTx = await gatewayWallet.write.deposit(
[sourceConfig.usdcAddress, DEPOSIT_AMOUNT],
{ account },
);
await client.waitForTransactionReceipt({ hash: depositTx });
console.log(`Done on ${sourceConfig.chain.name}. Deposit tx: ${depositTx}`);
}
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 source-chain deposit.npm run deposit
SOURCE_CHAIN to another supported
entry in chainConfig.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 across the
supported testnet routes in chainConfig.balances.ts
import { privateKeyToAccount } from "viem/accounts";
if (!process.env.EVM_PRIVATE_KEY) {
throw new Error("Missing EVM_PRIVATE_KEY in environment");
}
const DOMAINS = {
baseSepolia: 6,
arcTestnet: 26,
};
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);
});
npm run balances
- Transfer from EVM
- Transfer from Solana
Step 4. Transfer USDC from EVM to EVM
This step transfers USDC from your Arc Testnet Gateway balance to Base Sepolia. Both paths create and submit the burn intent from Arc Testnet first. Direct Mint then retrieves the Gateway attestation and callsgatewayMint(...) on Base
Sepolia from your wallet, while the Forwarding Service lets Circle complete the
destination mint for you.- Direct Mint
- Forwarding Service
4.1. Create the script file (Self-managed)
You can skip to the full transfer script if you prefer.touch transfer-from-evm.ts
4.2. Define constants and types
This script uses Arc Testnet as the source chain and Base Sepolia as the destination chain.transfer-from-evm.ts
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
// Create a burn intent for crosschain transfer
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
function createBurnIntent(params: {
depositorAddress: string;
recipientAddress?: string;
}) {
const { depositorAddress, recipientAddress = depositorAddress } = params;
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destConfig = chainConfig[DEST_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
const intent = createBurnIntent({
depositorAddress: account.address,
});
const typedData = burnIntentTypedData(intent);
const signature = await account.signTypedData(typedData);
const requests = [{ burnIntent: typedData.message, signature }];
console.log("Signed burn intent.");
4.5. Request attestation from Gateway API
transfer-from-evm.ts
const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts(requests),
});
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 Base Sepolia
transfer-from-evm.ts
const destConfig = chainConfig[DEST_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 (${destConfig.chain.name}):`, mintTx);
4.7. Full EVM transfer script (Self-managed)
The complete transfer script creates and signs a burn intent for Arc Testnet, submits it to the Gateway API for attestation, and mints USDC on Base Sepolia. The script includes inline comments to explain each stage.transfer-from-evm.ts
import {
createPublicClient,
createWalletClient,
getContract,
http,
pad,
zeroAddress,
maxUint256,
formatUnits,
type Hex,
} from "viem";
import { randomBytes } from "node:crypto";
import {
account,
chainConfig,
GATEWAY_API_BASE,
GATEWAY_WALLET_ADDRESS,
GATEWAY_MINTER_ADDRESS,
stringifyBigInts,
} from "./config.js";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
// 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;
async function main() {
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destConfig = chainConfig[DEST_CHAIN];
console.log(`Using account: ${account.address}`);
console.log(
`Transferring from: ${sourceConfig.chain.name} -> ${destConfig.chain.name}`,
);
// [1] Create and sign the Arc Testnet burn intent.
console.log(
`Creating burn intent from ${sourceConfig.chain.name} -> ${destConfig.chain.name}...`,
);
const burnIntent = {
maxBlockHeight: maxUint256,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: sourceConfig.domainId,
destinationDomain: destConfig.domainId,
sourceContract: pad(GATEWAY_WALLET_ADDRESS.toLowerCase() as Hex, {
size: 32,
}),
destinationContract: pad(GATEWAY_MINTER_ADDRESS.toLowerCase() as Hex, {
size: 32,
}),
sourceToken: pad(sourceConfig.usdcAddress.toLowerCase() as Hex, {
size: 32,
}),
destinationToken: pad(destConfig.usdcAddress.toLowerCase() as Hex, {
size: 32,
}),
sourceDepositor: pad(account.address.toLowerCase() as Hex, { size: 32 }),
destinationRecipient: pad(account.address.toLowerCase() as Hex, {
size: 32,
}),
sourceSigner: pad(account.address.toLowerCase() as Hex, { size: 32 }),
destinationCaller: pad(zeroAddress as Hex, { size: 32 }),
value: TRANSFER_VALUE,
salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
hookData: "0x" as Hex,
},
};
const typedData = {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: burnIntent,
};
const signature = await account.signTypedData(typedData);
const requests = [{ burnIntent: typedData.message, signature }];
console.log("Signed burn intent.");
// [2] Request attestation from Gateway API
const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts(requests),
});
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 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 = TRANSFER_VALUE;
console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
console.log(`Mint transaction hash (${destConfig.chain.name}):`, mintTx);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the EVM direct-mint script
Run the script to burn from your Arc Testnet Gateway balance and callgatewayMint(...) on Base Sepolia.Confirm these values before running:EVM_PRIVATE_KEYcontrols the source Arc Testnet account- the source account has a Gateway balance on Arc Testnet
- the destination account has enough Base Sepolia ETH to submit the mint transaction
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-evm
4.1. Create the script file
You can skip to the full transfer script if you prefer.touch transfer-from-evm.ts
4.2. Define constants and types
This script uses Arc Testnet as the source chain and Base Sepolia as the destination chain.This forwarding flow signs the burn intent on Arc Testnet, submits it with forwarding enabled, and then lets Circle complete the destination mint on Base Sepolia.transfer-from-evm.ts
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
// 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;
4.3. Define the burn-intent shape
The forwarding flow first estimates the burn-intent limits for the Arc Testnet to Base Sepolia route, then uses the same burn-intent shape for signing and submission.transfer-from-evm.ts
type TransferSpecBytes32 = {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: `0x${string}`;
destinationContract: `0x${string}`;
sourceToken: `0x${string}`;
destinationToken: `0x${string}`;
sourceDepositor: `0x${string}`;
destinationRecipient: `0x${string}`;
sourceSigner: `0x${string}`;
destinationCaller: `0x${string}`;
value: bigint;
salt: `0x${string}`;
hookData: `0x${string}`;
};
transfer-from-evm.ts
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
function createTransferSpec(): TransferSpecBytes32 {
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destConfig = chainConfig[DEST_CHAIN];
return {
version: 1,
sourceDomain: sourceConfig.domainId,
destinationDomain: destConfig.domainId,
sourceContract: addressToBytes32(GATEWAY_WALLET_ADDRESS),
destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: addressToBytes32(sourceConfig.usdcAddress),
destinationToken: addressToBytes32(destConfig.usdcAddress),
sourceDepositor: addressToBytes32(account.address),
destinationRecipient: addressToBytes32(account.address),
sourceSigner: addressToBytes32(account.address),
destinationCaller: addressToBytes32(zeroAddress),
value: TRANSFER_VALUE,
salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
hookData: "0x",
};
}
function addressToBytes32(address: string): Hex {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
4.4. Estimate forwarding fees
Before you submit the forwarding transfer, call the Gateway estimate endpoint withenableForwarder=true. The response returns the maxFee and
maxBlockHeight values you must include in the signed burn intent.transfer-from-evm.ts
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ spec }]),
},
);
4.5. Sign and submit the forwarding burn intent
Sign the burn intent with the Arc Testnet self-managed wallet, then submit it to the Gateway transfer endpoint withenableForwarder=true.transfer-from-evm.ts
const typedData = {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: { maxBlockHeight, maxFee, spec },
};
const signature = await account.signTypedData(typedData);
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ burnIntent: typedData.message, signature }]),
},
);
4.6. Poll for forwarding completion
The Forwarding Service completes the Base Sepolia mint for you. Poll the transfer status endpoint until the transfer is finalized or fails.transfer-from-evm.ts
const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
4.7. Full EVM forwarding script (Self-managed)
The script estimates forwarding fees, signs the burn intent on Arc Testnet, submits it to the Forwarding Service, and polls until Circle completes the Base Sepolia mint. Inline comments explain each stage.transfer-from-evm.ts
import { pad, zeroAddress, formatUnits, type Hex } from "viem";
import { randomBytes } from "node:crypto";
import {
account,
chainConfig,
GATEWAY_API_BASE,
GATEWAY_WALLET_ADDRESS,
GATEWAY_MINTER_ADDRESS,
stringifyBigInts,
} from "./config.js";
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
const SOURCE_CHAIN = "arc" as const;
const DEST_CHAIN = "base" as const;
// 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;
async function main() {
const sourceConfig = chainConfig[SOURCE_CHAIN];
const destConfig = chainConfig[DEST_CHAIN];
console.log(`Using account: ${account.address}`);
console.log(
`Forwarding from: ${sourceConfig.chain.name} -> ${destConfig.chain.name}`,
);
const spec = {
version: 1,
sourceDomain: sourceConfig.domainId,
destinationDomain: destConfig.domainId,
sourceContract: pad(GATEWAY_WALLET_ADDRESS.toLowerCase() as Hex, {
size: 32,
}),
destinationContract: pad(GATEWAY_MINTER_ADDRESS.toLowerCase() as Hex, {
size: 32,
}),
sourceToken: pad(sourceConfig.usdcAddress.toLowerCase() as Hex, {
size: 32,
}),
destinationToken: pad(destConfig.usdcAddress.toLowerCase() as Hex, {
size: 32,
}),
sourceDepositor: pad(account.address.toLowerCase() as Hex, { size: 32 }),
destinationRecipient: pad(account.address.toLowerCase() as Hex, {
size: 32,
}),
sourceSigner: pad(account.address.toLowerCase() as Hex, { size: 32 }),
destinationCaller: pad(zeroAddress as Hex, { size: 32 }),
value: TRANSFER_VALUE,
salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
hookData: "0x" as Hex,
};
// [1] Estimate forwarding fee inputs for the burn intent.
console.log("\n[1/3] Estimating forwarding fees...");
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ spec }]),
},
);
if (!estimateResponse.ok) {
const text = await estimateResponse.text();
throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
}
const estimateResult = (await estimateResponse.json()) as EstimateResponse;
const estimated = estimateResult.body[0]?.burnIntent;
if (!estimated) throw new Error("Missing burnIntent estimate");
const maxFee = BigInt(estimated.maxFee);
const maxBlockHeight = BigInt(estimated.maxBlockHeight);
const { fees } = estimateResult;
if (fees.forwardingFee) {
console.log(
`Forwarding fee: ${Number(fees.forwardingFee) / 1_000_000} ${fees.token}`,
);
}
console.log(`Estimated maxFee: ${formatUnits(maxFee, 6)} ${fees.token}`);
// [2] Sign and submit the forwarding burn intent.
console.log("\n[2/3] Signing and submitting burn intent...");
const typedData = {
types: { EIP712Domain, TransferSpec, BurnIntent },
domain,
primaryType: "BurnIntent" as const,
message: { maxBlockHeight, maxFee, spec },
};
const signature = await account.signTypedData(typedData);
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ burnIntent: typedData.message, signature }]),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = (await response.json()) as TransferResponse;
if (!json.transferId) throw new Error("Missing transferId in response");
const transferId = json.transferId;
console.log(`Transfer ID: ${transferId}`);
// [3] Poll until the Forwarding Service completes the destination mint.
console.log("\n[3/3] Polling for transfer completion...");
const pollStart = Date.now();
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
const pollRes = await fetch(
`${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
);
if (!pollRes.ok) {
console.error(`Poll error: ${pollRes.status}`);
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const details = (await pollRes.json()) as TransferStatusResponse;
console.log(`Status: ${details.status}`);
if (details.status === "finalized" || details.status === "confirmed") {
if (details.transactionHash) {
console.log(`Mint transaction hash: ${details.transactionHash}`);
}
break;
}
if (details.status === "failed") {
const reason = details.forwardingDetails?.failureReason ?? "unknown";
throw new Error(`Transfer failed: ${reason}`);
}
if (details.status === "expired") {
throw new Error("Transfer attestation expired before forwarding");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
throw new Error("Polling timed out waiting for transfer completion");
}
console.log(
`Transfer complete. ${formatUnits(TRANSFER_VALUE, 6)} USDC forwarded to ${destConfig.chain.name}.`,
);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the EVM forwarding script
Run the script to burn from your Arc Testnet Gateway balance and let the Forwarding Service mint on Base Sepolia.Confirm these values before running:EVM_PRIVATE_KEYcontrols the source Arc Testnet account- the source account has a Gateway balance on Arc Testnet
- no destination gas is required for this forwarding path
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-evm
Step 4. Transfer USDC from Solana to EVM
This step transfers USDC from your Solana Devnet Gateway balance to Arc Testnet. Both paths create and sign the Solana burn intent first. Direct Mint then requests the Gateway attestation and callsgatewayMint(...) on Arc Testnet
from your wallet, while the Forwarding Service lets Circle complete the
destination mint for you.- Direct Mint
- Forwarding Service
4.1. Create the script file
You can skip to the full transfer script if you prefer.touch transfer-from-sol.ts
4.2. Define constants and types
This script uses Arc Testnet as the destination chain.This direct-mint flow signs the burn intent on Solana Devnet, requests the Gateway attestation, and then callsgatewayMint(...) on Arc Testnet from your
wallet.transfer-from-sol.ts
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
// Custom layout for Solana PublicKey values in the burn intent payload.
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);
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
The helper layer encodes the Solana burn intent into the binary layout expected by Gateway, then normalizes addresses and payloads for the API request.transfer-from-sol.ts
function encodeBurnIntent(bi: {
maxBlockHeight: bigint;
maxFee: bigint;
spec: {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: string;
destinationContract: string;
sourceToken: string;
destinationToken: string;
sourceDepositor: string;
destinationRecipient: string;
sourceSigner: string;
destinationCaller: string;
value: bigint;
salt: string;
hookData: string;
};
}): 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);
}
function signBurnIntent(
keypair: Keypair,
payload: Parameters<typeof encodeBurnIntent>[0],
): 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")}`;
}
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
return new PublicKey(Buffer.from(cleanHex, "hex"));
}
4.4. Create and sign burn intent
Build the Solana burn intent, prefix the encoded payload, and sign it with the source Solana self-managed wallet.For Solana-origin transfers, the burn intent uses Solana-specific binary encoding and must be signed with the Solana signing-domain prefix. See the Solana Technical Guide for the signing rules and message format details.transfer-from-sol.ts
const destConfig = chainConfig[DEST_CHAIN];
const burnIntent = {
maxBlockHeight: MAX_UINT64,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domainId,
sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(SOLANA_USDC),
destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
sourceDepositor: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
destinationRecipient: evmAddressToBytes32(account.address),
sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
destinationCaller: evmAddressToBytes32(zeroAddress),
value: TRANSFER_VALUE,
salt: `0x${randomBytes(32).toString("hex")}`,
hookData: "0x",
},
};
const signature = signBurnIntent(solanaKeypair, burnIntent);
const request = [{ burnIntent, signature }];
4.5. Request attestation from Gateway API
transfer-from-sol.ts
const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts(request),
});
4.6. Mint on Arc Testnet
Use the destination EVM self-managed wallet to callgatewayMint(bytes,bytes)
on Arc Testnet, then wait for completion.transfer-from-sol.ts
const destConfig = chainConfig[DEST_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 },
});
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
[attestation, operatorSig],
{ account },
);
4.7. Full Solana transfer script (Self-managed)
The complete transfer script signs a Solana burn intent, requests a Gateway attestation, and mints on Arc Testnet. Inline comments explain each stage.transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import * as crypto from "crypto";
import {
createPublicClient,
createWalletClient,
getContract,
http,
pad,
zeroAddress,
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,
chainConfig,
GATEWAY_API_BASE,
GATEWAY_MINTER_ADDRESS,
stringifyBigInts,
} from "./config.js";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;
const MAX_UINT64 = 2n ** 64n - 1n;
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const DEST_CHAIN = "arc" as const;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
// Custom layout for Solana PublicKey values in the burn intent payload.
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);
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;
function encodeBurnIntent(bi: {
maxBlockHeight: bigint;
maxFee: bigint;
spec: {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: string;
destinationContract: string;
sourceToken: string;
destinationToken: string;
sourceDepositor: string;
destinationRecipient: string;
sourceSigner: string;
destinationCaller: string;
value: bigint;
salt: string;
hookData: string;
};
}): 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);
}
function signBurnIntent(
keypair: Keypair,
payload: Parameters<typeof encodeBurnIntent>[0],
): 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")}`;
}
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
return new PublicKey(Buffer.from(cleanHex, "hex"));
}
async function main() {
const destConfig = chainConfig[DEST_CHAIN];
if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
}
const solanaKeypair = Keypair.fromSecretKey(
Uint8Array.from(JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR)),
);
console.log(`Sender (Solana): ${solanaKeypair.publicKey.toBase58()}`);
console.log(`Recipient (EVM): ${account.address}`);
console.log(`Transferring from: Solana Devnet -> ${destConfig.chain.name}`);
// [1] Create and sign the Solana burn intent.
console.log(
`Creating burn intent from Solana Devnet -> ${destConfig.chain.name}...`,
);
const burnIntent = {
maxBlockHeight: MAX_UINT64,
maxFee: MAX_FEE,
spec: {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domainId,
sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(SOLANA_USDC),
destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
sourceDepositor: solanaAddressToBytes32(
solanaKeypair.publicKey.toBase58(),
),
destinationRecipient: evmAddressToBytes32(account.address),
sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
destinationCaller: evmAddressToBytes32(zeroAddress),
value: TRANSFER_VALUE,
salt: "0x" + randomBytes(32).toString("hex"),
hookData: "0x",
},
};
const signature = signBurnIntent(solanaKeypair, burnIntent);
const request = [{ burnIntent, signature }];
console.log("Signed burn intent.");
// [2] Request the attestation set from Gateway API.
const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts(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 Arc Testnet with the returned attestation set.
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 (${destConfig.chain.name}):`, mintTx);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the Solana to EVM direct-mint script
Run the script to burn from your Solana Devnet Gateway balance and callgatewayMint(...) on Arc Testnet.Confirm these values before running:SOLANA_PRIVATE_KEYPAIRcontrols the source Solana Devnet walletEVM_PRIVATE_KEYcontrols the destination Arc Testnet wallet- the source Solana wallet has a Gateway balance on Solana Devnet
- the destination Arc Testnet wallet can submit the mint transaction
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-sol
4.1. Create the script file
You can skip to the full transfer script if you prefer.touch transfer-from-sol.ts
4.2. Define constants and types
This script uses Arc Testnet as the destination chain.This forwarding flow signs the burn intent on Solana Devnet, submits it with forwarding enabled, and then lets Circle complete the destination mint on Arc Testnet.transfer-from-sol.ts
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
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;
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);
class UInt256BE extends Layout<bigint> {
constructor(property: string) {
super(32, property);
}
decode(b: Buffer, offset = 0) {
const buffer = b.subarray(offset, offset + 32);
return buffer.readBigUInt64BE(24);
}
encode(src: bigint, b: Buffer, offset = 0) {
const buffer = Buffer.alloc(32);
buffer.writeBigUInt64BE(BigInt(src), 24);
buffer.copy(b, offset);
return 32;
}
}
const uint256be = (property: string) => new UInt256BE(property);
const BurnIntentLayout = struct([
u32be("magic"),
uint256be("maxBlockHeight"),
uint256be("maxFee"),
u32be("transferSpecLength"),
struct(
[
u32be("magic"),
u32be("version"),
u32be("sourceDomain"),
u32be("destinationDomain"),
publicKey("sourceContract"),
publicKey("destinationContract"),
publicKey("sourceToken"),
publicKey("destinationToken"),
publicKey("sourceDepositor"),
publicKey("destinationRecipient"),
publicKey("sourceSigner"),
publicKey("destinationCaller"),
uint256be("value"),
blob(32, "salt"),
u32be("hookDataLength"),
blob(offset(u32be(), -4), "hookData"),
] as any,
"spec",
),
] as any);
4.3. Add helper functions
The helper layer encodes the Solana burn intent into the binary layout expected by Gateway, then normalizes addresses and payloads for the API request.transfer-from-sol.ts
function encodeBurnIntent(bi: {
maxBlockHeight: bigint;
maxFee: bigint;
spec: {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: string;
destinationContract: string;
sourceToken: string;
destinationToken: string;
sourceDepositor: string;
destinationRecipient: string;
sourceSigner: string;
destinationCaller: string;
value: bigint;
salt: string;
hookData: string;
};
}): 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);
}
function signBurnIntent(
keypair: Keypair,
payload: Parameters<typeof encodeBurnIntent>[0],
): 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")}`;
}
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
return new PublicKey(Buffer.from(cleanHex, "hex"));
}
4.4. Estimate forwarding fees
Before you submit the forwarding transfer, call the Gateway estimate endpoint withenableForwarder=true. The response returns the maxFee and
maxBlockHeight values you must include in the signed burn intent.transfer-from-sol.ts
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ spec }]),
},
);
4.5. Sign and submit the forwarding burn intent
Create the Solana burn intent with the estimated values, prefix the encoded payload, and sign it with the source Solana self-managed wallet. Then submit it to the Gateway transfer endpoint withenableForwarder=true.For Solana-origin transfers, the burn intent uses Solana-specific binary
encoding and must be signed with the Solana signing-domain prefix. See the
Solana Technical Guide for the signing
rules and message format details.transfer-from-sol.ts
const burnIntent = { maxBlockHeight, maxFee, spec };
const signature = signBurnIntent(solanaKeypair, burnIntent);
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ burnIntent, signature }]),
},
);
4.6. Poll for forwarding completion
The Forwarding Service completes the Arc Testnet mint for you. Poll the transfer status endpoint until the transfer is finalized or fails.transfer-from-sol.ts
const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
4.7. Full Solana forwarding script (Self-managed)
The script estimates forwarding fees, signs the burn intent on Solana Devnet, submits it to the Forwarding Service, and polls until Circle completes the Arc Testnet mint. Inline comments explain each stage.transfer-from-sol.ts
import { randomBytes } from "node:crypto";
import * as crypto from "crypto";
import { pad, zeroAddress, 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,
chainConfig,
GATEWAY_API_BASE,
GATEWAY_MINTER_ADDRESS,
stringifyBigInts,
} from "./config.js";
type EstimateResponse = {
body: Array<{
burnIntent: {
maxFee: string;
maxBlockHeight: string;
};
}>;
fees: {
forwardingFee?: string;
token: string;
};
};
type TransferResponse = {
transferId?: string;
};
type TransferStatusResponse = {
status: string;
transactionHash?: string;
forwardingDetails?: {
failureReason?: string;
};
};
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000;
const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
const SOLANA_DOMAIN = 5;
const DEST_CHAIN = "arc" as const;
const TRANSFER_SPEC_MAGIC = 0xca85def7;
const BURN_INTENT_MAGIC = 0x070afbc2;
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);
class UInt256BE extends Layout<bigint> {
constructor(property: string) {
super(32, property);
}
decode(b: Buffer, offset = 0) {
const buffer = b.subarray(offset, offset + 32);
return buffer.readBigUInt64BE(24);
}
encode(src: bigint, b: Buffer, offset = 0) {
const buffer = Buffer.alloc(32);
buffer.writeBigUInt64BE(BigInt(src), 24);
buffer.copy(b, offset);
return 32;
}
}
const uint256be = (property: string) => new UInt256BE(property);
const BurnIntentLayout = struct([
u32be("magic"),
uint256be("maxBlockHeight"),
uint256be("maxFee"),
u32be("transferSpecLength"),
struct(
[
u32be("magic"),
u32be("version"),
u32be("sourceDomain"),
u32be("destinationDomain"),
publicKey("sourceContract"),
publicKey("destinationContract"),
publicKey("sourceToken"),
publicKey("destinationToken"),
publicKey("sourceDepositor"),
publicKey("destinationRecipient"),
publicKey("sourceSigner"),
publicKey("destinationCaller"),
uint256be("value"),
blob(32, "salt"),
u32be("hookDataLength"),
blob(offset(u32be(), -4), "hookData"),
] as any,
"spec",
),
] as any);
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;
function encodeBurnIntent(bi: {
maxBlockHeight: bigint;
maxFee: bigint;
spec: {
version: number;
sourceDomain: number;
destinationDomain: number;
sourceContract: string;
destinationContract: string;
sourceToken: string;
destinationToken: string;
sourceDepositor: string;
destinationRecipient: string;
sourceSigner: string;
destinationCaller: string;
value: bigint;
salt: string;
hookData: string;
};
}): 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);
}
function signBurnIntent(
keypair: Keypair,
payload: Parameters<typeof encodeBurnIntent>[0],
): 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")}`;
}
function solanaAddressToBytes32(address: string): string {
const decoded = Buffer.from(bs58.decode(address));
return `0x${decoded.toString("hex")}`;
}
function evmAddressToBytes32(address: string): string {
return pad(address.toLowerCase() as Hex, { size: 32 });
}
function hexToPublicKey(hexString: string): PublicKey {
const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
return new PublicKey(Buffer.from(cleanHex, "hex"));
}
async function main() {
const destConfig = chainConfig[DEST_CHAIN];
if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
}
const solanaKeypair = Keypair.fromSecretKey(
Uint8Array.from(JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR)),
);
console.log(`Sender (Solana): ${solanaKeypair.publicKey.toBase58()}`);
console.log(`Recipient (EVM): ${account.address}`);
console.log(`Forwarding from: Solana Devnet -> ${destConfig.chain.name}`);
const spec = {
version: 1,
sourceDomain: SOLANA_DOMAIN,
destinationDomain: destConfig.domainId,
sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
sourceToken: solanaAddressToBytes32(SOLANA_USDC),
destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
sourceDepositor: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
destinationRecipient: evmAddressToBytes32(account.address),
sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
destinationCaller: evmAddressToBytes32(zeroAddress),
value: TRANSFER_VALUE,
salt: `0x${randomBytes(32).toString("hex")}`,
hookData: "0x",
};
// [1] Estimate forwarding fee inputs for the burn intent.
console.log("\n[1/3] Estimating forwarding fees...");
const estimateResponse = await fetch(
`${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ spec }]),
},
);
if (!estimateResponse.ok) {
const text = await estimateResponse.text();
throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
}
const estimateResult = (await estimateResponse.json()) as EstimateResponse;
const estimated = estimateResult.body[0]?.burnIntent;
if (!estimated) throw new Error("Missing burnIntent estimate");
const maxFee = BigInt(estimated.maxFee);
const maxBlockHeight = BigInt(estimated.maxBlockHeight);
const { fees } = estimateResult;
if (fees.forwardingFee) {
console.log(`Forwarding fee: ${fees.forwardingFee} ${fees.token}`);
}
console.log(`Estimated maxFee: ${formatUnits(maxFee, 6)} ${fees.token}`);
// [2] Create, sign, and submit the forwarding burn intent.
console.log("\n[2/3] Signing and submitting burn intent...");
const burnIntent = { maxBlockHeight, maxFee, spec };
const signature = signBurnIntent(solanaKeypair, burnIntent);
const response = await fetch(
`${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: stringifyBigInts([{ burnIntent, signature }]),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gateway API error: ${response.status} ${text}`);
}
const json = (await response.json()) as TransferResponse;
if (!json.transferId) throw new Error("Missing transferId in response");
const transferId = json.transferId;
console.log(`Transfer ID: ${transferId}`);
// [3] Poll until the Forwarding Service completes the destination mint.
console.log("\n[3/3] Polling for transfer completion...");
const pollStart = Date.now();
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
const pollRes = await fetch(
`${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
);
if (!pollRes.ok) {
console.error(`Poll error: ${pollRes.status}`);
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const details = (await pollRes.json()) as TransferStatusResponse;
console.log(`Status: ${details.status}`);
if (details.status === "finalized" || details.status === "confirmed") {
if (details.transactionHash) {
console.log(`Mint transaction hash: ${details.transactionHash}`);
}
break;
}
if (details.status === "failed") {
const reason = details.forwardingDetails?.failureReason ?? "unknown";
throw new Error(`Transfer failed: ${reason}`);
}
if (details.status === "expired") {
throw new Error("Transfer attestation expired before forwarding");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
throw new Error("Polling timed out waiting for transfer completion");
}
console.log(
`Transfer complete. ${formatUnits(TRANSFER_VALUE, 6)} USDC forwarded to ${destConfig.chain.name}.`,
);
}
main().catch((error) => {
console.error("\nError:", error);
process.exit(1);
});
4.8. Run the Solana forwarding script
Run the script to burn from your Solana Devnet Gateway balance and let the Forwarding Service mint on Arc Testnet.Confirm these values before running:SOLANA_PRIVATE_KEYPAIRcontrols the source Solana Devnet walletEVM_PRIVATE_KEYcontrols the destination Arc Testnet wallet- the source Solana wallet has a Gateway balance on Solana Devnet
- no destination gas is required for this forwarding path
Gateway fees are charged per burn intent and are based on the source
blockchain you burn from. Choosing where to hold and burn Gateway balances can
affect transfer costs. For fee details, see Gateway
Fees.
npm run transfer-from-sol