Skip to main content
Three ways to fund a Gateway deposit address. All three produce the same downstream result: once USDC lands on the deposit address, the contract publishes the Routes intents and solvers fulfill on Polygon.
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 on-chain: 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 on-chain transactions. Requests are validated, then queued through RabbitMQ (prefetch=1) to serialize submissions and 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 on-chain — first deposit deploys it. Reference UIs that implement the signing and polling end-to-end: gateway.eco.com (mainnet) and deposit-addresses-sandbox.vercel.app (preproduction).

ERC-3009 transferWithAuthorization

Recommended. USDC’s native gasless path. User signs a TransferWithAuthorization off-chain; 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 off-chain; 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.