Skip to main content
You have three ways to fund a Gateway deposit address. 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 /api/v1/depositAddresses/gateway/polygon once to register the deposit address. The address is deterministic (CREATE2) and can be shared before it’s deployed onchain. Reference UI that implements the signing and polling end-to-end: gateway.eco.com.

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 deposit address. 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 { parseUnits, toHex } from 'viem'

const value = parseUnits('100', 6)
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: evmDepositAddress, value, validAfter: BigInt(validAfter), validBefore: BigInt(validBefore), nonce },
})

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

// data.id → poll GET /api/v1/gasless/jobs/{id}
Status transitions PENDING → COMPLETED (or FAILED).

EIP-2612 Permit

Fallback when the token supports permit() but not ERC-3009. User signs a Permit offchain; the operator wallet submits two txs on their behalf:
  1. permit(owner, spender = depositAddress, value, deadline, v, r, s) on the token
  2. createIntentWithApproval(funder = owner) on the deposit contract, which atomically transferFroms the tokens and publishes the intent(s)
import { parseUnits } from 'viem'

const value = parseUnits('100', 6)
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: evmDepositAddress, value, nonce, deadline },
})

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

// data.id → poll GET /api/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 deposit address. Balance monitoring detects the deposit and kicks off deployment (first time) and intent creation.
import { createWalletClient, http, parseUnits, 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: [evmDepositAddress, parseUnits('100', 6)],
})
No signature, no API call beyond the initial POST to obtain the address. The sender pays gas.

Polling the job

const { data } = await fetch(`${SERVICE_URL}/api/v1/gasless/jobs/${jobId}`).then((r) => r.json())
// data: { id, status, permitTxHash?, transferTxHash?, intentHash?, amount?, error? }
Typical client: poll every few seconds until status is COMPLETED or FAILED, with a 1–2 minute overall timeout.

Validation

The service rejects requests before queueing if any of:
  • Deposit address isn’t registered on chainId
  • USDC isn’t configured for chainId
  • Permit deadline / ERC-3009 validBefore is in the past, or validAfter is in the future
  • value <= 0, or 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.

Fast Deposits for Circle Gateway

Product context and security model.

Programmable Addresses

Overall product overview.

Gasless Gateway deposit recipe

End-to-end gasless walkthrough.