Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.circle.com/llms.txt

Use this file to discover all available pages before exploring further.

Use Eco’s gasless deposit flow to move 1 testnet USDC from Base Sepolia into Circle Gateway without holding ETH on the source blockchain. This quickstart shows a TypeScript script that requests an Eco deposit address, signs an EIP-3009 authorization, submits the deposit, and checks your Gateway balance on Polygon PoS Amoy.
Eco is a third-party fast-deposit service. Circle names Eco as an option for faster Gateway deposits in current docs, but Circle does not operate or audit Eco’s service. Review Eco’s docs and test the flow before you use it in production.
This quickstart uses Base Sepolia as the source blockchain and Polygon PoS Amoy (Gateway domain ID 7) as the destination. You sign an EIP-3009 TransferWithAuthorization payload locally, and Eco submits the transfer onchain. You still need USDC in your wallet, but you do not need ETH on Base Sepolia for the deposit. For other deposit methods and Eco-specific routing details, see the Eco Gateway Deposits guide.

Prerequisites

Before you begin, ensure you have:
  • Installed Node.js v22+
  • Obtained a private key for a Base Sepolia EOA
  • Funded your wallet with testnet USDC from the Circle Faucet (select Base Sepolia)
Base Sepolia network details
PropertyValue
Chain ID84532
USDC contract0x036CbD53842c5426634e7929541eC2318f3dCF7e
Faucetfaucet.circle.com
You do not need ETH on Base Sepolia for this gasless deposit flow.

Step 1. Set up your project

1.1. Create the project and install dependencies

mkdir eco-gasless-deposit
cd eco-gasless-deposit

npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx --env-file=.env eco-deposit.ts"

npm install viem
npm install --save-dev @types/node tsx typescript

1.2. Configure TypeScript (optional)

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

1.3. Set environment variables

Open .env in your editor and add:
PRIVATE_KEY=YOUR_BASE_SEPOLIA_PRIVATE_KEY
  • PRIVATE_KEY is the private key for the Base Sepolia EOA that holds the testnet USDC you want to deposit.
Prefer editing .env files in your IDE or editor so credentials are not leaked to your shell history.
The npm run start command loads variables from .env using Node.js native env-file support.
Steps 2 through 6 show individual parts of the flow. Each snippet highlights one stage and is not independently runnable. Use the full script in Full deposit script to run the quickstart end to end.

Step 2. Request an Eco deposit address

Request a deterministic Eco deposit address for Gateway on Polygon PoS Amoy.
const response = await fetch(
  "https://deposit-addresses-preproduction.eco.com/api/v1/depositAddresses/gateway/polygon",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      chainId: 84532, // Base Sepolia
      depositor: yourWalletAddress,
      evmDestinationAddress: yourWalletAddress,
    }),
  },
);

if (!response.ok) {
  throw new Error(`Failed to get deposit address: ${await response.text()}`);
}

const { data } = await response.json();
const depositAddress = data.evmDepositAddress;
The returned depositAddress is a reusable address for this depositor and destination combination.

Step 3. Generate EIP-3009 authorization

Create the authorization parameters. EIP-3009 uses a TransferWithAuthorization signed message to let a third party (Eco) submit the transfer on your behalf. For a deeper explanation of the signing flow, see EIP-3009 signing.
import { randomBytes } from "node:crypto";

const nonce = `0x${randomBytes(32).toString("hex")}`;
const validAfter = 0n;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour
const amount = 1000000n; // 1 USDC (6 decimals)
Sign the TransferWithAuthorization payload:
const signature = await walletClient.signTypedData({
  account,
  domain: {
    name: "USDC",
    version: "2",
    chainId: 84532,
    verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  },
  types: {
    TransferWithAuthorization: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
    ],
  },
  primaryType: "TransferWithAuthorization",
  message: {
    from: yourWalletAddress,
    to: depositAddress,
    value: amount,
    validAfter,
    validBefore,
    nonce,
  },
});
For this Base Sepolia flow, the EIP-712 domain name is USDC, not USD Coin.

Step 4. Submit the gasless deposit to Eco

Submit the signed authorization:
const response = await fetch(
  "https://deposit-addresses-preproduction.eco.com/api/v1/gasless/transferWithAuthorization",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      chainId: 84532,
      from: yourWalletAddress,
      to: depositAddress,
      value: amount.toString(),
      validAfter: validAfter.toString(),
      validBefore: validBefore.toString(),
      nonce,
      signature,
    }),
  },
);

if (!response.ok) {
  throw new Error(`Failed to submit transfer: ${await response.text()}`);
}

const { data } = await response.json();
const jobId = data.id;
Eco returns a jobId you can poll until the deposit completes or fails.

Step 5. Wait for the deposit to complete

Poll the job status endpoint:
let status = "PENDING";
let attempts = 0;
const maxAttempts = 30;

while (status === "PENDING" && attempts < maxAttempts) {
  await new Promise((resolve) => setTimeout(resolve, 2000));

  const response = await fetch(
    `https://deposit-addresses-preproduction.eco.com/api/v1/gasless/jobs/${jobId}`,
  );

  if (!response.ok) {
    throw new Error(`Failed to poll status: ${await response.text()}`);
  }

  const { data } = await response.json();
  status = data.status;
  attempts++;

  console.log(`Status: ${status} (${attempts * 2}s elapsed)`);
}

if (status !== "COMPLETED") {
  throw new Error("Deposit failed or timed out");
}

Step 6. Check your Gateway balance

After the job completes, query the Gateway balances endpoint:
const response = await fetch(
  "https://gateway-api-testnet.circle.com/v1/balances",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      token: "USDC",
      sources: [{ domain: 7, depositor: yourWalletAddress }], // Polygon PoS Amoy
    }),
  },
);

if (!response.ok) {
  throw new Error(`Failed to fetch balance: ${await response.text()}`);
}

const { balances } = await response.json();
const polygonBalance = balances.find((b) => b.domain === 7);

if (!polygonBalance) {
  throw new Error("No Polygon PoS Amoy Gateway balance returned");
}

console.log(`Gateway balance: ${polygonBalance.balance} USDC`);
The current Gateway balances API returns balance as a decimal USDC string for this flow.

Full deposit script

This script requests the Eco deposit address, signs the EIP-3009 payload, submits the gasless deposit, waits for completion, and prints the resulting Gateway balance.
import { randomBytes } from "node:crypto";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";

const USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const BASE_SEPOLIA_CHAIN_ID = 84532;
const POLYGON_AMOY_DOMAIN = 7;
const ECO_BASE_URL = "https://deposit-addresses-preproduction.eco.com/api/v1";
const GATEWAY_API_URL = "https://gateway-api-testnet.circle.com/v1/balances";
const DEPOSIT_AMOUNT = 1000000n; // 1 USDC

async function requestDepositAddress(depositor: `0x${string}`) {
  const response = await fetch(
    `${ECO_BASE_URL}/depositAddresses/gateway/polygon`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        chainId: BASE_SEPOLIA_CHAIN_ID,
        depositor,
        evmDestinationAddress: depositor,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`Failed to get deposit address: ${await response.text()}`);
  }

  const { data } = await response.json();
  return data.evmDepositAddress as `0x${string}`;
}

async function signTransferAuthorization(
  walletClient: ReturnType<typeof createWalletClient>,
  account: ReturnType<typeof privateKeyToAccount>,
  from: `0x${string}`,
  to: `0x${string}`,
) {
  const nonce = `0x${randomBytes(32).toString("hex")}` as `0x${string}`;
  const validAfter = 0n;
  const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600);

  const signature = await walletClient.signTypedData({
    account,
    domain: {
      name: "USDC",
      version: "2",
      chainId: BASE_SEPOLIA_CHAIN_ID,
      verifyingContract: USDC_BASE_SEPOLIA as `0x${string}`,
    },
    types: {
      TransferWithAuthorization: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "value", type: "uint256" },
        { name: "validAfter", type: "uint256" },
        { name: "validBefore", type: "uint256" },
        { name: "nonce", type: "bytes32" },
      ],
    },
    primaryType: "TransferWithAuthorization",
    message: {
      from,
      to,
      value: DEPOSIT_AMOUNT,
      validAfter,
      validBefore,
      nonce,
    },
  });

  return {
    nonce,
    validAfter,
    validBefore,
    signature,
  };
}

async function submitGaslessDeposit(params: {
  from: `0x${string}`;
  to: `0x${string}`;
  nonce: `0x${string}`;
  validAfter: bigint;
  validBefore: bigint;
  signature: `0x${string}`;
}) {
  const response = await fetch(
    `${ECO_BASE_URL}/gasless/transferWithAuthorization`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        chainId: BASE_SEPOLIA_CHAIN_ID,
        from: params.from,
        to: params.to,
        value: DEPOSIT_AMOUNT.toString(),
        validAfter: params.validAfter.toString(),
        validBefore: params.validBefore.toString(),
        nonce: params.nonce,
        signature: params.signature,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`Failed to submit transfer: ${await response.text()}`);
  }

  const { data } = await response.json();
  return data.id as string;
}

async function waitForCompletion(jobId: string) {
  let status = "PENDING";
  let attempts = 0;
  const maxAttempts = 30;

  while (status === "PENDING" && attempts < maxAttempts) {
    await new Promise((resolve) => setTimeout(resolve, 2000));

    const response = await fetch(`${ECO_BASE_URL}/gasless/jobs/${jobId}`);

    if (!response.ok) {
      throw new Error(`Failed to poll status: ${await response.text()}`);
    }

    const { data } = await response.json();
    status = data.status as string;
    attempts++;

    console.log(`Status: ${status} (${attempts * 2}s elapsed)`);
  }

  if (status !== "COMPLETED") {
    throw new Error("Deposit failed or timed out");
  }
}

async function checkGatewayBalance(depositor: `0x${string}`) {
  const response = await fetch(GATEWAY_API_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      token: "USDC",
      sources: [{ domain: POLYGON_AMOY_DOMAIN, depositor }],
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch balance: ${await response.text()}`);
  }

  const { balances } = await response.json();
  const polygonBalance = balances.find(
    (balance: { domain: number; balance: string }) =>
      balance.domain === POLYGON_AMOY_DOMAIN,
  );

  if (!polygonBalance) {
    throw new Error("No Polygon PoS Amoy Gateway balance returned");
  }

  return polygonBalance.balance as string;
}

async function main() {
  const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined;
  if (!privateKey) {
    throw new Error("Missing PRIVATE_KEY in .env");
  }

  const account = privateKeyToAccount(privateKey);
  const walletClient = createWalletClient({
    account,
    chain: baseSepolia,
    transport: http(),
  });

  console.log(`\nWallet: ${account.address}`);
  console.log(`Amount: ${Number(DEPOSIT_AMOUNT) / 1e6} USDC\n`);

  console.log("Step 1: Requesting deposit address...");
  const depositAddress = await requestDepositAddress(account.address);
  console.log(`Deposit address: ${depositAddress}\n`);

  console.log("Step 2: Signing authorization...");
  const authorization = await signTransferAuthorization(
    walletClient,
    account,
    account.address,
    depositAddress,
  );
  console.log("Signature generated\n");

  console.log("Step 3: Submitting to Eco...");
  const jobId = await submitGaslessDeposit({
    from: account.address,
    to: depositAddress,
    ...authorization,
  });
  console.log(`Job ID: ${jobId}\n`);

  console.log("Step 4: Waiting for completion...");
  await waitForCompletion(jobId);
  console.log("\nDeposit successful\n");

  console.log("Step 5: Checking Gateway balance...");
  const gatewayBalance = await checkGatewayBalance(account.address);
  console.log(`Gateway balance: ${gatewayBalance} USDC\n`);
}

main().catch((error) => {
  if (error instanceof Error) {
    console.error("Error:", error.message);
    return;
  }

  console.error("Error:", error);
});
Run the script:
npm run start
For withdrawal and crosschain transfer capabilities, see the Gateway documentation. To use your Gateway balance for Gateway-based USDC flows, explore the Arc Nanopayments Demo.