RAIL20 for Agents
Give any autonomous agent a private balance and private payments on Base and Robinhood Chain. Integration is an HTTP API plus one signature - no circuit knowledge, no client-side proving, no new wallet.
Why agents need this
Agents that hold and move value on public chains broadcast everything: treasury size, payment timing, and the full counterparty graph. That leaks strategy, invites front-running, and exposes agent-to-agent (A2A) relationships. RAIL20 shields the balance and the payment while keeping the agent's normal signing key.
The integration model
An agent never handles zero-knowledge proofs. It signs a message, and the RAIL20 relayer derives the account, builds the proof, pays gas, and broadcasts. On-chain, only a commitment and a nullifier appear - no sender, recipient, or amount.
- No new wallet. The private account is derived deterministically from the agent's existing signature.
- No gas. The relayer fronts gas and deducts a fee in the transacted token.
- No server-side account. Nothing to register; balance is reconstructed from on-chain notes with a key only the agent can derive.
- Recoverable. Because keys derive from the signature, a crashed or restarted agent can always resume.
Quickstart: the rail20 CLI
Prefer skipping the HTTP glue? Install the reference CLI (@rail20/cli) - it wraps every endpoint on this page (with a --chain base|robinhood flag) and is verified end-to-end on both Base and Robinhood Chain mainnet.
# install the latest release npm install -g @rail20/cli@latest # confirm what you got, and check for updates any time rail20 --version # prints the installed version rail20 latest # compares installed vs the latest on npm # auth: either interactive (hidden prompt) or env var for agents/CI rail20 login # prompts for private key, stored chmod 600 export RAIL20_KEY=0x... # or set env var and skip the prompt # everyday flow — Base (default, --chain omitted) rail20 balance # private + public balances (both pools), Base by default rail20 deposit 5 --pool usdc # shield 5 USDC into the private pool rail20 send 0xRECIPIENT 2.5 # private send, no gas from you rail20 swap 0.001 --from eth # private same-chain swap ETH -> USDC rail20 bridge arb_usdc 0xRECIPIENT 2.5 # private cross-chain bridge to Arbitrum rail20 recover # sweep stranded funds (registered + legacy burners) # same flow — Robinhood (every command takes --chain robinhood, or the short alias --chain rh) rail20 balance --chain rh # pools on Robinhood are eth, usdg (not usdc) rail20 deposit 5 --pool usdg --chain rh # shield 5 USDG into the private pool rail20 send 0xRECIPIENT 2.5 --pool usdg --chain rh rail20 swap 0.001 --from eth --chain rh # private same-chain swap ETH -> USDG rail20 bridge rh_eth 0xRECIPIENT 0.01 --from eth --chain base # Base -> Robinhood (dest rh_*, --chain base) rail20 bridge rbase_eth 0xRECIPIENT 0.01 --from eth --chain robinhood # Robinhood -> Base (dest rbase_*, --chain robinhood) rail20 bridge rbase_usdc 0xRECIPIENT 3 --from usdg --chain robinhood # RH USDG -> Base USDC (cross-asset) rail20 recover --chain rh # multichain in one shot — loops Base + Robinhood, one signature rail20 balance --chain all # every chain's private + public balances, one run rail20 recover --chain all # sweep every chain, fully idempotent, safe to run any time # useful flags on top of the above rail20 balance --pool usdc --wait # poll balance until the indexer catches up (post-tx) rail20 swap 3 --from usdc --slippage 50 # custom slippage in bps (default 100 = 1%; 50 = 0.5%) rail20 recover --to 0xOTHERADDR # sweep to a different address instead of your own wallet rail20 recover --to 0xOTHERADDR --chain all
Nine commands total. --chain accepts base (default), robinhood (or the shorter rh), or all on balance/recover. Pools differ per chain: Base has eth, usdc; Robinhood has eth, usdg — run rail20 help any time to see the exact set. Source, package, and full docs: npmjs.com/package/@rail20/cli. If you need programmatic access rather than a CLI, keep reading - the raw HTTP API is documented below.
# environment variables (optional — for agents/CI, skips interactive prompts) export RAIL20_KEY=0x... # agent private key (0x-hex) — if set, `login` is skipped entirely export RAIL20_API=https://rail20-api.fly.dev # override the API base URL (default shown) export RAIL20_ROBINHOOD_RPC=https://your-rpc # override the Robinhood RPC (see troubleshooting below) export RAIL20_BASE_RPC=https://your-rpc # override the Base RPC (rarely needed)
rpc.mainnet.chain.robinhood.com) is unreachable from some networks/ISPs entirely —
no amount of retry helps if the endpoint can't be reached from your connection. If Robinhood
commands keep failing while Base works fine, point the CLI at your own RPC:
# use any Robinhood Chain (4663) RPC you can reach — e.g. a free Alchemy/Infura endpoint export RAIL20_ROBINHOOD_RPC="https://your-robinhood-rpc-url" rail20 balance --chain rh # now routes through your RPC
~/.zshrc / ~/.bashrc) so it persists. Keep the URL private —
it contains your API key.
Three steps to integrate
1Authenticate the private account
The agent's existing private key derives its RAIL20 account - no new wallet. Either drop the key into an env var (agents/CI) or log in interactively (hidden prompt, stored chmod 600). The CLI signs the fixed RAIL20 message and caches the signature for you.
# agents / CI: key from your secret manager, no prompt export RAIL20_KEY=0xYOUR_AGENT_KEY rail20 login # or interactive (key never shown, never in shell history) rail20 login # prompts: private key: ●●●●●●
Prefer programmatic control? (raw signature)
// The exact message string is fixed. Sign it once, cache the result. const RAIL20_SIGN_MSG = "RAIL20 Private Account Sign-in\n\nSigning this message derives your account keys.\nNo transaction will be sent." const signature = await agent.signMessage(RAIL20_SIGN_MSG)
2Fund the private balance
Shield ETH or the chain's stablecoin (USDC on Base, USDG on Robinhood) into the pool (or just receive private notes from another agent). One command handles the approve + deposit; the agent wallet pays gas only on this shielding step.
# shield 50 USDC from the public wallet into the private pool rail20 deposit 50 --pool usdc # check it landed (--wait polls through indexer lag) rail20 balance --pool usdc --wait
Prefer programmatic control? (raw HTTP flow)
const prep = await post("/api/deposit/prepare", { signature, address: agent.address, amount: "50", pool: "usdc" }) // prep = { to, data, value, approveTx? } - sign & broadcast from the agent wallet if (prep.approveTx) await agent.sendTransaction(prep.approveTx) await agent.sendTransaction({ to: prep.to, data: prep.data, value: prep.value })
3Transact privately
Read the private balance any time (reconstructed locally, nothing hits the chain), then pay another agent privately with one command. The relayer builds the proof and broadcasts - the agent pays no gas.
# read private balance (no tx, derived from on-chain notes) rail20 balance # pay a peer agent privately - amount + recipient hidden on-chain rail20 send 0xPeerAgentAddress 25 # on-chain: one commitment + one nullifier. no sender, recipient, or amount.
Prefer programmatic control? (raw HTTP flow)
// Read private balance - derived from on-chain notes, no tx const { balance } = await post("/api/balance", { signature, address: agent.address, pool: "usdc" }) // Pay a peer agent privately. Relayer broadcasts; agent pays no gas. await post("/api/withdraw", { signature, recipient: peerAgent.address, amount: "25", // amount hidden on-chain pool: "usdc" }) // On-chain: one commitment + one nullifier. No sender, recipient, or amount.
Private same-chain swap
Swap ETH <-> the chain's stablecoin (USDC on Base, USDG on Robinhood) without either side of the trade appearing linked to the agent on-chain. Under the hood: the relayer withdraws private funds into a fresh random burner wallet (its key encrypted and registered for recovery before funding), that burner runs a Uniswap V3 trade, and the output is re-shielded into the agent's private balance. Observers see one commitment leaving the pool, an unrelated burner trading on Uniswap, and one commitment entering the pool - nothing links them to the agent.
# swap 3 USDC private -> ETH private (all steps handled by the CLI) rail20 swap 3 --from usdc # or reverse direction rail20 swap 0.001 --from eth # tune slippage (default 100 bps = 1%) rail20 swap 3 --from usdc --slippage 50 # 0.5%
One command handles all 6 sub-steps: create + register burner, private withdraw, gas top-up (USDC origin), Uniswap approve + swap, WETH unwrap (ETH destination), and re-shield. If anything fails, rail20 recover sweeps burner funds back home - each swap uses a fresh random burner whose key is encrypted (AES-256-GCM under your sign-in signature) and pushed to a recovery registry, so any device that re-signs can recover it. Legacy burners (derived at nonce 0-19 in older versions) are still swept too.
Prefer programmatic control? (raw HTTP flow)
// 1. Create a fresh RANDOM burner, encrypt its key (AES-256-GCM under keccak256(signInSig)), // and register the ciphertext BEFORE funding so recovery works from any device. // (Legacy builds derived the burner at nonce 0-19 from the signature; `recover` still sweeps those.) const burner = Wallet.createRandom() const encKey = await encryptBurnerKey(burner.privateKey, signature) await post("/api/burner/register", { authSig, burnerAddress: burner.address, chain: "base", token: USDC, encKey }) // 2. Ask the relayer to privately move USDC from the pool -> burner. await post("/api/swap-private", { signature, fromAsset: "base_usdc", amount: "3", burnerAddress: burner.address }) // 3. For stablecoin origin, top up burner gas (relayer sends ETH for the burner's txs). // Pass chain (base|robinhood) and txCount (approve+deposit = 2) to size the top-up. await post("/api/burner-gas", { signature, burnerAddress: burner.address, chain: "base", txCount: 2 }) // 4. Burner approves + swaps on Uniswap V3 (SwapRouter02, fee tier 100 = 0.01%). await usdc.connect(burner).approve(UNISWAP_ROUTER, amountIn) await router.connect(burner).exactInputSingle({ tokenIn: USDC, tokenOut: WETH, fee: 100, recipient: burner.address, amountIn, amountOutMinimum: minOut, sqrtPriceLimitX96: 0 }) // If output is WETH and destination is ETH: weth.withdraw(amount) to unwrap. // 5. Re-shield: burner deposits the swap output back to the AGENT's private balance. // Note: address is the AGENT wallet, but tx is broadcast FROM the burner. const prep = await post("/api/deposit/prepare", { signature, address: agent.address, amount: swapOutput, pool: "eth" }) await burner.sendTransaction({ to: prep.to, data: prep.data, value: prep.value })
Gotcha: Base runs at ~0.001-0.05 gwei and Robinhood at ~0.047 gwei (its RPC may "suggest" ~1.6 gwei, ~34x too high), so pass an explicit maxFeePerGas capped per chain when broadcasting from the burner - the default 1.5 gwei drains the tiny gas top-up. The CLI caps at 0.05 gwei (Base) / 0.1 gwei (Robinhood) for you.
Private cross-chain bridge
Move private funds between chains (Base ↔ Robinhood, or Base -> Ethereum / Arbitrum / BSC) without exposing the agent's public identity on either side. Two routes: NEAR Intents 1Click (broad chain coverage) and a direct Relay/Across router (Base ↔ Robinhood). The relayer withdraws privately into the solver/router deposit; settlement happens on the destination chain and pays the recipient. Origin chain sees only a nullifier - no sender, no destination.
# list supported destinations (chain + symbol pairs) rail20 assets # ═══ ROUTER: Base <-> Robinhood (direct Relay/Across, ~2-3s) ═══ # dest prefix = target chain | --chain = origin chain | --from = origin pool # Base -> Robinhood (--chain base, --from eth|usdc) rail20 bridge rh_eth 0xRECIPIENT 0.01 --from eth --chain base # Base ETH -> RH ETH rail20 bridge rh_usdg 0xRECIPIENT 10 --from usdc --chain base # Base USDC -> RH USDG rail20 bridge rh_eth 0xRECIPIENT 10 --from usdc --chain base # cross-asset: Base USDC -> RH ETH # Robinhood -> Base (--chain robinhood, --from eth|usdg) rail20 bridge rbase_eth 0xRECIPIENT 0.01 --from eth --chain robinhood # RH ETH -> Base ETH rail20 bridge rbase_usdc 0xRECIPIENT 3 --from usdg --chain robinhood # RH USDG -> Base USDC rail20 bridge rbase_eth 0xRECIPIENT 3 --from usdg --chain robinhood # cross-asset: RH USDG -> Base ETH # ═══ 1CLICK: Base -> Arbitrum / BNB / Ethereum (NEAR Intents, ~30s-5min) ═══ # always from Base. --from base_eth or base_usdc (default base_usdc if omitted) rail20 bridge arb_eth 0xRECIPIENT 0.005 --from base_eth # -> Arbitrum ETH rail20 bridge arb_usdc 0xRECIPIENT 2.5 --from base_usdc # -> Arbitrum USDC rail20 bridge arb_usdt 0xRECIPIENT 2.5 --from base_usdc # -> Arbitrum USDT0 rail20 bridge arb_arb 0xRECIPIENT 2.5 --from base_usdc # -> Arbitrum ARB rail20 bridge bsc_bnb 0xRECIPIENT 2.5 --from base_usdc # -> BNB Chain BNB rail20 bridge bsc_usdc 0xRECIPIENT 2.5 --from base_usdc # -> BNB Chain USDC rail20 bridge bsc_usdt 0xRECIPIENT 2.5 --from base_usdc # -> BNB Chain USDT rail20 bridge eth_eth 0xRECIPIENT 0.005 --from base_eth # -> Ethereum ETH rail20 bridge eth_usdc 0xRECIPIENT 2.5 --from base_usdc # -> Ethereum USDC rail20 bridge eth_usdt 0xRECIPIENT 2.5 --from base_usdc # -> Ethereum USDT rail20 bridge eth_cbbtc 0xRECIPIENT 2.5 --from base_usdc # -> Ethereum cbBTC rail20 assets # full live list of 1Click destinations
Route rules: Base<->Robinhood goes through the router (dest rh_* / rbase_*) and works both directions — the rh_*/rbase_* prefix names the destination chain, --chain names the origin, --from names the origin pool (cross-asset like USDC->ETH is fine, the router swaps in-flight). Bridges to Arbitrum/BNB/Ethereum go through 1Click and always originate from Base (--from base_eth or base_usdc). Run rail20 assets for the authoritative live list.
The CLI submits the intent and polls settlement until it reports SUCCESS (~1-6 min). If the solver can't fill within its window, funds are refunded automatically to the agent's private balance - no manual recovery needed. Amount delivered is the solver quote minus RAIL20 fee (flat + 0.35%); the solver spread and destination-chain gas are baked into the quote.
Prefer programmatic control? (raw HTTP flow)
// 1. List supported destinations. const { assets } = await get("/api/assets") // 2. Private withdraw straight to the 1Click solver deposit address. // Relayer builds the Groth16 proof, pays gas, and broadcasts. // Returns { success, txHash, depositAddress, amountOut }. const intent = await post("/api/swap", { signature, fromAsset: "base_usdc", toAsset: "arb_usdc", amount: "2.5", recipient: peerAgent.address }) // 3. Poll settlement by the solver deposit address. while (true) { const s = await get(`/api/intent-status?depositAddress=${intent.depositAddress}`) if (s.status === "SUCCESS") break if (s.status === "REFUNDED" || s.status === "FAILED") throw new Error(`solver ${s.status}`) await sleep(4000) }
API reference
All endpoints are served from https://api.rail20.org and accept a JSON body containing the agent's signature.
| Endpoint | Method | Purpose |
|---|---|---|
/api/balance | POST | Reconstruct the agent's private balance from its notes |
/api/deposit/prepare | POST | Build an unsigned deposit (shield) transaction |
/api/withdraw | POST | Private send to any address (up to 2 recipients) |
/api/swap-private | POST | Same-chain swap into a burner (step 1 of private swap) |
/api/burner-gas | POST | Relayer gas top-up for a burner mid-swap |
/api/burner/auth-message | GET | The message to sign for registry auth (distinct from the sign-in message) |
/api/burner/register | POST | Register a swap burner's encrypted key for cross-device recovery |
/api/burner/list | POST | List the agent's registered burners (encrypted keys) to sweep in recovery |
/api/burner/mark-swept | POST | Mark a burner swept in the registry (bookkeeping) |
/api/swap | POST | Cross-chain bridge via NEAR Intents 1Click |
/api/bridge/quote | POST | Quote a swap or bridge (dry or real) |
/api/router/quote | POST | Competing Relay + Across quotes for the direct router bridge (Base ↔ Robinhood) |
/api/router/execute | POST | Ordered txs[] (approve + deposit) for the burner to broadcast a router bridge |
/api/intent-status | GET | Poll cross-chain intent settlement (by depositAddress) |
All endpoints accept an optional chain field (base | robinhood, default base). On Base the stablecoin pool is usdc; on Robinhood it is usdg.
Common agent patterns
Private treasury
An agent receives funds to its public wallet, deposits into the pool, and from then on manages balance privately. Withdraw to a fresh address when spending publicly - an observer sees deposits into the pool and withdrawals from the pool, but cannot connect the two.
Agent-to-agent (A2A) payments
Agents pay each other directly from private balance via /api/withdraw. Amount and counterparties stay hidden, so recurring A2A relationships and payment sizes never appear on-chain.
Private swap & rebalance
An agent rebalances ETH into USDC (or back) via /api/swap-private, which routes through a one-time burner and Uniswap V3 on-chain, then re-shields the result. Strategy shifts never leak.
Considerations
| Consideration | Detail |
|---|---|
| Signing | One personal_sign over the fixed message. Cache per session; it derives all account keys. |
| Gas | Relayer pays gas for withdrawals and private transactions. Deposits are broadcast by the agent wallet. |
| Fees | Flat + 0.35% in the transacted token. Flat is 0.00015 ETH on every ETH pool (Base, Robinhood, Arbitrum) and 0.001 BNB on BSC; 1 USDC on Base, 0.5 USDG on Robinhood, 0.5 USDC/USDT on Arbitrum and BSC. |
| Minimums | Send, swap and bridge: 0.001 ETH (or BNB), 2 units on the stablecoin pools. Deposit: 0.001 ETH on the ETH pools, 0.02 BNB, 3 units on every stablecoin pool. These floors keep the withdraw fee from eating a meaningful share of a small note — at 0.001 ETH the flat fee is already ~15%. The relayer additionally refuses anything where the fee is at least half the amount. |
| Recovery | Keys derive from the signature; a restarted agent resumes with no state loss. |
| Latency | Send: seconds. Same-chain swap: ~30s. Cross-chain bridge: poll intent status (~1-6 min). |