REST API

Base URL: https://api.zerodust.xyz

  • JSON in and out. Amounts are strings in wei, chain IDs are numbers.
  • No key is needed. An API key raises your rate limits.
  • Browsers may only call the API from the ZeroDust site (CORS). Call it from a server, a script or an agent.
  • Send only signatures. No endpoint takes a private key.

The SDK wraps the chain, balance, quote and sweep endpoints. Chains without EIP-7702 use separate planning endpoints, documented on Direct chains.

Rate limits

Limits are per client IP and per minute unless noted. With an API key, the read, quote and sweep routes the SDK uses count against the key instead.

Group Limit Routes
Reads 200 /chains, /chains/:chainId, /bridges/timing, /destinations, /balances/*, /prices, /prices/:symbol, GET /sweep/:sweepId, /sweeps/:address, /direct/chains, /direct/balances/:address, /direct/status
Quotes 60 /quote, POST /quote/:quoteId/relay-route, POST /authorization, POST /authorization/batch, /direct/route, /direct/prepare, /direct/exit
Sweep submission 60, and 60 per wallet address POST /sweep
Key registration 5 per hour POST /agent/register
Everything else 100

Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset. Over the limit, the API returns 429:

{ "error": "Too Many Requests", "message": "You are requesting quotes too quickly. Maximum 60 quotes per minute.", "statusCode": 429 }

A wallet address over its sweep limit gets 429 with code RATE_LIMITED.

Errors

Errors are JSON with an error message and, in most cases, a machine-readable code:

{ "error": "Source chain not supported", "code": "INVALID_FROM_CHAIN" }
  • 400 with code FST_ERR_VALIDATION: a parameter failed validation.
  • 404 "Quote not found", "Sweep not found" and "Chain not found" have no code.
  • 500 responses are generic and carry no detail.

Sweep flow

  1. GET /quote: price the sweep and get a quoteId.
  2. POST /authorization: get the EIP-712 SweepIntent for that quote.
  3. Sign the intent, an EIP-7702 delegation to 0x3732398281d0606aCB7EC1D490dFB0591BE4c4f2, and a revoke authorization (delegation to address 0, nonce + 1).
  4. POST /sweep: submit the signatures.
  5. GET /sweep/:sweepId: poll until completed or failed.

Check the quote before signing it. The SDK's ZeroDustAgent and verifySweepQuote() do this; see Agent.

With MetaMask

A wallet that supports ERC-7715 Advanced Permissions (MetaMask 13.23 or later) can sweep without exposing its key, through the ZeroDust permission router 0x369A97dd256F7eb37fF7116C4EcBd50318eBb286 (same address on every chain it is deployed on). Several chains take one permission request and one signature:

  1. wallet_requestExecutionPermissions (in the wallet): per chain, a native-token-allowance of the balance, isAdjustmentAllowed: false, with the rules expiry (the site uses 10 minutes), redeemer = router and payee = router, and to = router. Keep each chain's returned context. The first grant on a chain also upgrades the account to MetaMask's smart account, which spends a little of that chain's balance, so quote after granting.
  2. GET /quote?signer=permission per chain.
  3. POST /authorization/batch with the quote IDs: the EIP-712 SweepBatch for all of them.
  4. Sign it once (eth_signTypedData_v4). The domain has no chain id, so no network switch is needed.
  5. For a quote routed through Relay, fetch the deposit from Relay yourself and bind it with POST /quote/:quoteId/relay-route: Relay keeps the recipient on its servers, so only a deposit you requested pays the address you set. Then check intent.callData is exactly that deposit.
  6. POST /sweep per quote, each with the same signature, that chain's permissionContext, and batchQuoteIds in the order of step 3.
  7. GET /sweep/:sweepId until completed or failed.

The SDK does not support this flow yet. Differences from the key flow:

  • No EIP-7702 authorizations from you and no revoke (autoRevoke: false, revokeGasUnits: "0"): the account stays MetaMask's smart account.
  • Cross-chain only through Relay, Across, Stargate or Hyperlane, which take the refund address and recipient explicitly. A pair only Gas.zip serves answers PERMISSION_ROUTE_UNAVAILABLE; the key flow can still sweep it.
  • A chain's own bridge (bridge=native) is not offered with a permission; sweep with the key.
  • Quotes live 55 seconds. If they expire while the wallet is signing, quote again and sign a new batch.

Chains

GET /chains

Chains ZeroDust sweeps with EIP-7702.

Query: testnet (boolean, default false). There are no testnet chains at the moment.

Response:

{
  "chains": [
    {
      "chainId": 8453,
      "name": "Base",
      "nativeToken": "ETH",
      "nativeTokenDecimals": 18,
      "minBalance": "100000000000000",
      "contractAddress": "0x3732398281d0606aCB7EC1D490dFB0591BE4c4f2",
      "explorerUrl": "https://basescan.org",
      "enabled": true,
      "crossChain": { "available": true, "bridges": ["gaszip", "relay", "across"], "checkedAt": "2026-10-01T16:01:01.302Z" },
      "simulation": { "reliable": true, "mode": "authorizationList", "checkedAt": "2026-10-01T16:00:51.180Z" },
      "metamask": true
    }
  ]
}
Field Notes
minBalance Informational. Quotes do not enforce a fixed minimum; the fee reserve decides.
crossChain Whether any bridge currently accepts this chain as a cross-chain source, and which. Checked every 10 minutes; null values until the first check. Advisory: /quote always asks the bridges live.
simulation Whether this chain's RPC can simulate an EIP-7702 sweep (checked hourly). mode is authorizationList or stateOverride. Quotes from a chain with reliable: false are refused.
metamask Whether a MetaMask permission sweep works on this chain (the permission router is deployed there)
closing Only for a chain that announced its shutdown: stage (closing or cutoff), closesAt, cutoffAt (3 days earlier: sponsored sweeps stop), source (the announcement) and note. Never offered as a destination. In the list only; /chains/:chainId leaves it out.

GET /chains/:chainId

One chain, same fields as above. 404 { "error": "Chain not found" } for an unknown ID.

GET /bridges/timing

How long each bridge usually takes to deliver, from real ZeroDust deliveries in the last 14 days. A bridge or pair with fewer than 3 deliveries is left out, so the lists can be empty. Cached for 5 minutes.

{
  "routes": {
    "relay": { "typicalSeconds": 18, "p90Seconds": 41, "samples": 23, "slowLately": false }
  },
  "pairs": {
    "relay:42161": { "typicalSeconds": 16, "samples": 9 }
  }
}
Field Notes
routes Per bridge (for example relay, across, gaszip, stargate, hyperlane): median and 90th percentile seconds from the source transaction to delivery, and the number of deliveries
slowLately The last 24 hours' median is above twice the usual median and at least 60 seconds more
pairs Per bridge and source chain, keyed bridge:chainId

GET /destinations

Chains a cross-chain sweep from fromChainId can deliver native gas to. Any EVM chain a routing bridge serves qualifies, not only ZeroDust chains. This is what bridges advertise; /quote confirms a route live.

Query: fromChainId (required, must be a ZeroDust chain).

{
  "fromChainId": 8453,
  "destinations": [
    { "chainId": 16661, "name": "0G", "nativeSymbol": "0G", "nativeDecimals": 18, "bridges": ["gaszip"], "zerodustChain": false }
  ]
}

zerodustChain is true when the destination is also a ZeroDust source chain. Errors: 400 INVALID_FROM_CHAIN.

Balances

GET /balances/:address

Native balance on every enabled chain.

Query: testnet (boolean, default false).

{
  "address": "0x...",
  "chains": [
    { "chainId": 8453, "name": "Base", "nativeToken": "ETH", "balance": "1200000000000000", "balanceFormatted": "0.0012", "canSweep": true, "minBalance": "100000000000000" }
  ]
}

canSweep is true for any balance above 0. Whether it covers the fees is decided by the quote.

GET /balances/:address/:chainId

One chain, same fields as an entry above. 404 { "error": "Chain not supported" } for an unknown chain.

Prices

GET /prices

USD prices of native tokens, cached for 10 minutes. For display.

{ "prices": { "ETH": 2681.24, "BNB": 765.75 }, "cachedAt": "2026-10-01T16:00:41.686Z", "ttlSeconds": 115 }

GET /prices/:symbol

{ "symbol": "ETH", "priceUsd": 2681.24 }. 404 when no price is available.

Quotes

GET /quote

Prices a sweep and stores the quote. Nothing is signed or sent.

Query Notes
fromChainId Required. A ZeroDust chain.
toChainId Required. Equal to fromChainId for a same-chain sweep, or any chain from /destinations.
userAddress Required. The wallet to empty.
destination Required. Where the funds go.
signer key (default): EIP-7702, signed with the wallet's key. permission: through MetaMask and the permission router (With MetaMask).
bridge native: through the source chain's own bridge to Ethereum, for chains that have one (Lisk, Arbitrum Nova; toChainId must be 1). Never chosen without it. See A chain's own bridge.

ZeroDust always chooses the route. For a cross-chain sweep it asks every bridge that serves the pair (Gas.zip, Relay, Across, Stargate; Hyperlane, Metalayer or Endurance for the token deliveries) and keeps the highest expected output. Every route delivers the destination's native gas (see What arrives for the token exceptions): Across is asked only when both chains' gas is ETH, and only a plain deposit to a recipient with no contract code (or an EIP-7702 delegation) on the destination is accepted from it. Passing callTarget or callData is refused with CUSTOM_ROUTE_UNSUPPORTED.

Response:

Field Notes
quoteId UUID, used by /authorization and /sweep
version 3
userBalance Balance at quote time
estimatedReceive The least that arrives. Same-chain: exactly userBalance - fees.maxTotalFeeWei (the actual fee can only be lower). Cross-chain: the bridge's expected output less 3%, because bridges settle at their own price on delivery.
mode 0 transfer (same-chain), 1 bridge call (cross-chain)
fees See below
autoRevoke true with signer=key; false with signer=permission (no revoke)
signer key or permission, as requested
permission signer=permission only: router (the permission's delegate, redeemer and payee, and the typed data's verifyingContract), delegationManager (MetaMask's) and domainVersion (permission-2)
receiveToken Token deliveries only (Mitosis and Endurance to BNB Chain, Intuition to Base): { symbol, address, decimals } of the ERC-20 token that arrives instead of gas. estimatedReceive and intent.minReceive are in this token.
bridge Cross-chain only: name, displayName, inputAmount (wei sent to the bridge), expectedOutput (wei the bridge expects to deliver). With bridge=native, name is native and the sweep is completed once its source transaction succeeds: the funds wait in the bridge until the recipient claims them on Ethereum.
intent Fields that go into the signed SweepIntent: mode, destination, destinationChainId (string), callTarget (zero address for same-chain), routeHash, minReceive, and callData for cross-chain. keccak256(callData) == routeHash, so the route can be decoded and checked before signing.
deadline Unix seconds. The contract accepts at most 60 seconds ahead.
nonce The wallet's ZeroDust intent nonce
authNonce The wallet's transaction count, which the EIP-7702 delegation must use (signer=key only)
validForSeconds 55

intent.minReceive is the least the contract will accept: equal to estimatedReceive for a same-chain sweep; for a cross-chain sweep, the bridge's expected output less 0.5%, then 5%.

Fees in a quote

Field Notes
maxTotalFeeWei The fee reserve. The whole reserve leaves the wallet: the contract reimburses the sponsor's gas from it and sends the unused part to the sponsor.
extraFeeWei Fixed part of the reserve: the service fee, the L1 data fee on rollups, and per-chain surcharges
overheadGasUnits Gas the contract cannot measure itself (intrinsic cost, authorization, signature checks)
protocolFeeGasUnits Always 0 (deprecated)
reimbGasPriceCapWei Highest gas price the sponsor can be reimbursed at: 1.2 times the gas price at quote time
revokeGasUnits Gas reserved for the revoke transaction

The service fee in extraFeeWei is 5% of the balance's USD value under $1, and 1% from $1 with a minimum of $0.05 and a maximum of $0.50.

Errors (400 unless noted):

Code Meaning
INVALID_FROM_CHAIN Source chain not supported
INVALID_TO_CHAIN No bridge delivers from the source to this chain
BALANCE_TOO_LOW The balance is 0
INSUFFICIENT_FOR_FEES The balance does not cover the fee reserve
SOURCE_CHAIN_DISABLED, DEST_CHAIN_DISABLED, BRIDGE_UNAVAILABLE No cross-chain route right now
SIMULATION_UNAVAILABLE The chain's RPC cannot simulate the sweep right now
CHAIN_PAUSED Sweeps from this chain are paused for now
SPONSOR_UNAVAILABLE The sponsor cannot pay for this sweep on this chain right now
L1_FEE_UNAVAILABLE The rollup's L1 data fee could not be priced; try again shortly
CHAIN_CLOCK_LAG The chain's latest block is too far behind to leave time to sign
CHAIN_CLOSING The source chain is past its cut-off or closed, or the destination is a closing chain; the message names the date and the chain's own bridge
ADDRESS_TEMPORARILY_BLOCKED Two or more sweeps from this address failed for wallet-side reasons; try again later
CUSTOM_ROUTE_UNSUPPORTED callTarget or callData was passed
AMOUNT_TOO_LOW Below every bridge's minimum; minimumBalanceWei is the balance to top up to (an estimate)
OWN_WALLET_ONLY Endurance: its bridge pays only the sweeping wallet, so destination must be userAddress
PERMISSION_UNAVAILABLE signer=permission on a chain without the router, or one MetaMask does not offer permissions on
PERMISSION_ROUTE_UNAVAILABLE signer=permission, and only a bridge unsafe for the router (Gas.zip) serves this pair
INTERNAL_ERROR (500) The quote could not be stored

POST /quote/:quoteId/relay-route

Binds a Relay deposit you requested from Relay yourself into a signer=permission quote routed through Relay. Relay keeps a request's recipient on its own servers and the deposit only names an id, so a route someone else requested cannot be checked; one you requested can.

Request the deposit with POST https://api.relay.link/quote: user and refundTo = the wallet, recipient = the destination, originChainId / destinationChainId, both currencies 0x0000000000000000000000000000000000000000, amount = the quote's bridge.inputAmount, tradeType EXACT_INPUT. Check Relay's answer (one transaction on the source chain for exactly that amount, details.recipient, native currencyOut on the destination, at least estimatedReceive), then send its transaction here.

Body: { "callTarget": "<tx.to>", "callData": "<tx.data>", "requestId": "<Relay's request id>" }

Returns { quoteId, intent }, the quote's intent now carrying that deposit (callTarget, callData, routeHash). Sign only if intent.callData is the deposit Relay gave you.

Errors (400): NOT_RELAY_ROUTE, PERMISSION_ONLY (key quotes keep their route), ROUTE_ALREADY_BOUND (one bind per quote), QUOTE_EXPIRED, QUOTE_USED (already swept), INVALID_RELAY_ROUTE (not a Relay contract on the source chain, a depository deposit crediting another wallet, a multicall refunding to a third party, or a deposit that would fail from the wallet), ROUTE_GAS_CHANGED (its gas no longer fits the quote's fee reserve: quote again). 404 when the quote does not exist.

POST /authorization

Returns the EIP-712 typed data for a quote.

Body: { "quoteId": "<uuid>" }

Field Notes
sweepType same-chain or cross-chain
typedData EIP-712 SweepIntent. Domain: name ZeroDust, version 3, chainId = source chain, verifyingContract = the wallet's own address.
contractAddress The ZeroDust contract to delegate to
version 3

Errors: 404 "Quote not found", 400 QUOTE_EXPIRED, 400 CONTRACT_NOT_DEPLOYED.

For a signer=permission quote, typedData is a SweepBatch of that one chain and contractAddress is the router. Use POST /authorization/batch to cover several chains with one signature.

The SweepIntent type is mode uint8, user address, destination address, destinationChainId uint256, callTarget address, routeHash bytes32, minReceive uint256, maxTotalFeeWei uint256, overheadGasUnits uint256, protocolFeeGasUnits uint256, extraFeeWei uint256, reimbGasPriceCapWei uint256, deadline uint256, nonce uint256. Build it from the verified quote and compare it with this response rather than signing the response as received.

POST /authorization/batch

Returns one EIP-712 SweepBatch for several signer=permission quotes, one per chain.

Body: { "quoteIds": ["<uuid>", ...] }, 1 to 64 quotes, in the order you want signed.

Field Notes
typedData EIP-712 SweepBatch(ChainSweep[] sweeps), ChainSweep(uint256 chainId, SweepIntent intent). Domain: name ZeroDust, version permission-2, verifyingContract = the router, no chainId.
router The permission router
entries { quoteId, chainId, index } per quote: its place in the batch

Errors (400): INVALID_BATCH (a quote missing or listed twice, not a signer=permission quote, another wallet, or the same chain twice), QUOTE_EXPIRED (quote again).

Rebuild each SweepIntent from its verified quote and compare before signing, as for a single quote.

Sweeps

POST /sweep

Submits a signed sweep. The relayer simulates it, sends it, then sends the revoke.

Body:

Field Notes
quoteId Required
signature Required. EIP-712 signature of the SweepIntent (64 or 65 bytes, hex).
eip7702Authorization Required with signer=key. { chainId, contractAddress, nonce, yParity, r, s }, delegating to the ZeroDust contract on the source chain.
revokeAuthorization Required with signer=key. Same shape, delegating to 0x0000000000000000000000000000000000000000 on the same chain with nonce = delegation nonce + 1, so the delegation is removed right after the sweep (its gas is in the quoted fee). A sweep without it is refused with REVOKE_AUTHORIZATION_REQUIRED.

| permissionContext | Required with signer=permission, and only then: the context the wallet returned for this chain's permission. It must hold one delegation, from this wallet, to the router. | | batchQuoteIds | signer=permission only: every quote the signature covers, in the order of POST /authorization/batch. Defaults to this quote alone. |

{ "sweepId": "6f1c...", "status": "pending", "sweepType": "cross-chain", "isExisting": false, "version": 3 }

Idempotent per quote: submitting a quoteId that already has a sweep returns that sweep with isExisting: true.

Errors (400 unless noted):

Code Meaning
QUOTE_EXPIRED Past the quote's deadline
INVALID_SIGNATURE The SweepIntent (or, with signer=permission, the SweepBatch) signature does not verify for this wallet
CHAIN_ID_MISMATCH The delegation is for another chain
CONTRACT_ADDRESS_MISMATCH The delegation targets another contract
EIP7702_INVALID_SIGNATURE The delegation was not signed by the wallet
INVALID_REVOKE_TARGET The revoke does not delegate to address 0
REVOKE_CHAIN_ID_MISMATCH The revoke is for another chain
REVOKE_NONCE_MISMATCH The revoke nonce is not delegation nonce + 1
REVOKE_INVALID_SIGNATURE The revoke was not signed by the wallet
EIP7702_AUTHORIZATION_REQUIRED, REVOKE_AUTHORIZATION_REQUIRED signer=key without the delegation or the revoke
PERMISSION_CONTEXT_REQUIRED signer=permission without permissionContext
INVALID_PERMISSION_CONTEXT The context is too large, not a MetaMask delegation chain, holds more than one delegation, or is not from this wallet to the router
PERMISSION_CONTEXT_UNEXPECTED permissionContext on a signer=key quote
INVALID_BATCH batchQuoteIds does not include this quote, fails the batch rules above, or was sent with a signer=key quote
ADDRESS_TEMPORARILY_BLOCKED, CHAIN_PAUSED As for /quote
RATE_LIMITED (429) More than 60 sweeps a minute from this address
404 Quote not found

GET /sweep/:sweepId

Field Notes
sweepId, status, sweepType, mode status is pending, simulating, executing, broadcasted, bridging, completed or failed
txHash Sweep transaction on the source chain
destinationTxHash Delivery transaction (cross-chain)
amountSent Wei
destination, fromChainId, toChainId
errorMessage When failed
bridgeTrackingUrl Cross-chain, once there is a transaction
revokeStatus not_requested, pending, executing, completed or failed
revokeTxHash, revokeError
createdAt, updatedAt, version

completed and failed are final. Confirm the result on-chain: the source balance is 0, the wallet has no code once the revoke lands, and the funds arrived.

GET /sweeps/:address

Sweeps for a wallet, newest first.

Query: limit (1 to 100, default 20), offset (default 0), status (one of the statuses above).

{ "sweeps": [{ "sweepId": "...", "status": "completed", "sweepType": "same-chain", "mode": 0, "fromChainId": 8453, "toChainId": 8453, "amountSent": "...", "txHash": "0x...", "version": 3, "createdAt": "..." }], "total": 1 }

API keys

A key gives you your own rate limit, separate from your IP's. Send it as X-API-Key: <key> (what the SDK and MCP server send) or Authorization: Bearer <key>. Treat a key as a secret; it cannot move funds.

  • Where it applies: the reads /chains, /chains/:chainId, /destinations, /balances/*, GET /sweep/:sweepId and /sweeps/:address, plus /quote, POST /authorization and POST /sweep. Each route has its own per-minute bucket for the key, at the key's limit (300 for agent keys) and never below the anonymous limit. The 60 sweeps per minute per wallet address still apply.
  • Where it does not: /prices and the /direct/* routes keep the per-IP limits.
  • One IP, at most two keys an hour. Further keys used from the same IP get that IP's anonymous limits.
  • An invalid, revoked or expired key is treated as no key: the request gets the anonymous limits, not an error. Check a key with GET /agent/me.
  • A revoked key can keep its higher limits for up to a minute.

POST /agent/register

Issues a key. Limited to 5 per IP per hour.

Body Notes
name Required, 3 to 100 characters
agentId Optional, up to 255 characters
contactEmail Optional
webhookUrl Optional URL, stored with the key
metadata Optional object, up to 20 properties

Response (201): apiKey (shown once, not retrievable later), keyPrefix, keyId, keyType (agent), rateLimits (perMinute 300, daily 1000) and message.

GET /agent/me

Requires a key. Returns keyId, keyType, agentId, rateLimits (perMinute, daily, dailyUsed, dailyRemaining) and stats for the last 7 days (totalRequests, successfulRequests, failedRequests, avgResponseTimeMs).

DELETE /agent/key

Requires a key. Revokes it permanently.

Requests to these routes count against the key's own per-minute limit (300 for agent keys) as well as the IP limit. Missing or invalid key: 401.

MCP and discovery

Route Notes
POST /mcp Hosted MCP server, JSON-RPC 2.0. See MCP server.
GET /mcp Server info and tool list
GET /mcp/tools Tool definitions as plain JSON
GET /.well-known/agent-card.json A2A agent card
GET /erc8004/agent.json ERC-8004 registration metadata

Health

  • GET /health: { "status": "ok", "timestamp": "..." }.
  • GET /ready: also checks the database. 200 { "status": "ok", "database": "connected", "timestamp": "..." }, or 503 with database: "disconnected".