Skip to main content
You have three ways to fund a Gateway deposit vault. The right choice depends on whether your users have gas and which token standards they support.
MethodUser txsUser needs gas?When to use
ERC-3009 transferWithAuthorization0NoRecommended: single-tx gasless (USDC-native)
EIP-2612 Permit0NoFallback for tokens that support permit() but not ERC-3009
Direct transfer1YesSender already has source-chain gas
Under the gasless flows the user pays nothing onchain: the deposit address service pays gas for the source-chain transaction, and the Eco solver service pays gas for final fulfillment on the destination chain. For both gasless methods, Eco’s operator wallet submits the onchain transactions. Requests are validated, then routed through a serialized transaction queue to prevent operator-wallet nonce collisions under concurrent load. Prerequisite for all three: call POST /circle-gateway/v2/depositAddresses to create a quoted vault. The response includes the vaultAddress to fund, the quoted amount, and the quote deadline. The vault must receive at least amount USDC before deadline.

ERC-3009 transferWithAuthorization

Recommended. USDC’s native gasless path. User signs a TransferWithAuthorization offchain; the operator wallet submits a single transferWithAuthorization() call that moves tokens directly from the signer to the vault. USDC’s EIP-712 domain name varies per chain, "USDC" on Base Sepolia, "USD Coin" elsewhere. Use the correct value for the chain you’re signing on.
import { toHex } from 'viem'

const value = BigInt(amount) // the quoted amount from POST /circle-gateway/v2/depositAddresses
const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)))
const validAfter = '0'
const validBefore = String(Math.floor(Date.now() / 1000) + 3600)

const signature = await signTypedDataAsync({
  domain: { name: 'USD Coin', version: '2', chainId, verifyingContract: USDC_ADDRESS },
  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: owner, to: vaultAddress, value, validAfter: BigInt(validAfter), validBefore: BigInt(validBefore), nonce },
})

const { data } = await fetch(`${BASE_URL}/circle-gateway/v1/gasless/transferWithAuthorization`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    chainId,
    from: owner,
    to: vaultAddress,
    value: value.toString(),
    validAfter,
    validBefore,
    nonce,
    signature,
  }),
}).then((r) => r.json())

// data.id → poll GET /circle-gateway/v1/gasless/jobs/{id}
The authorization value must be at least the quoted amount, the vault’s quote deadline must not have passed, and validBefore must be at least a minute in the future. Status transitions PENDING → COMPLETED (or FAILED).

EIP-2612 Permit

Fallback when the token supports permit() but not ERC-3009. User signs a Permit offchain with the vault as spender; the operator wallet submits the transactions that pull the tokens into the vault on their behalf via POST /circle-gateway/v1/gasless/permit.
const value = BigInt(amount) // the quoted amount from POST /circle-gateway/v2/depositAddresses
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600)

const signature = await signTypedDataAsync({
  domain: { name: 'USD Coin', version: '2', chainId, verifyingContract: USDC_ADDRESS },
  types: {
    Permit: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ],
  },
  primaryType: 'Permit',
  message: { owner, spender: vaultAddress, value, nonce, deadline },
})

const { data } = await fetch(`${BASE_URL}/circle-gateway/v1/gasless/permit`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    chainId,
    owner,
    depositAddress: vaultAddress,
    value: value.toString(),
    deadline: deadline.toString(),
    signature,
  }),
}).then((r) => r.json())

// data.id → poll GET /circle-gateway/v1/gasless/jobs/{id}
Response is 202 Accepted with { id, status: "PENDING" }. Status transitions PENDING → PERMIT_SENT → COMPLETED (or FAILED).

Direct transfer

The sender submits a vanilla ERC-20 transfer() to the vault address. Balance monitoring detects when the vault balance reaches the quoted amount and publishes the deposit intent.
import { createWalletClient, http, erc20Abi } from 'viem'
import { base } from 'viem/chains'

const walletClient = createWalletClient({ chain: base, transport: http() })

await walletClient.writeContract({
  address: USDC_ADDRESS,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [vaultAddress, BigInt(amount)], // the quoted amount
})
No signature, no API call beyond the initial POST to obtain the vault. The sender pays gas.

Polling

For gasless transfers, poll the relayer job:
const { data } = await fetch(`${BASE_URL}/circle-gateway/v1/gasless/jobs/${jobId}`).then((r) => r.json())
// data: { id, status, permitTxHash?, transferTxHash?, amount?, error? }
For any method, poll the vault status to track the deposit itself:
const { data } = await fetch(
  `${BASE_URL}/circle-gateway/v2/depositAddresses/${vaultAddress}?sourceChainId=${chainId}`,
).then((r) => r.json())
// data: { vaultAddress, amount, deadline, state, intentHash, sourceChainId }
Typical client: poll every few seconds until the job is COMPLETED/FAILED and the vault state is PUBLISHED, with a 1–2 minute overall timeout. See the state table for all vault states.

Validation

The service rejects gasless requests before queueing if any of:
  • to isn’t a known quoted vault (or legacy deposit address) on chainId
  • The quoted vault isn’t in a fundable state, or its quote deadline has passed
  • USDC isn’t configured for chainId
  • Permit deadline is in the past, ERC-3009 validBefore is less than a minute in the future, or validAfter is in the future
  • value is below the vault’s quoted amount, or the signer’s USDC balance is less than value
  • Signature, nonce, or address fields don’t match the expected shapes (see Validation rules)
On failure after queueing, the job record captures the error and moves to FAILED; the transaction is never retried automatically.