Integration

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.

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.

terminal
# 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.

bash
# 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)
Robinhood RPC errors ("could not detect network", "missing response", or a "?" balance)? The CLI retries transient RPC drops automatically, but the public Robinhood RPC (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:
bash
# 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
Get a free Robinhood Chain RPC from a provider like Alchemy, then set it once in your shell profile (~/.zshrc / ~/.bashrc) so it persists. Keep the URL private — it contains your API key.
Wiring the calls is the easy part. The harder part is telling your agent when and how much to move. See Operating your agent for copy-paste spending policies, real failure modes (fee guard, indexer lag, stranded burners), and debugging.

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.

terminal
# 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)
1-derive.ts
// 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.

terminal
# 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)
2-deposit.ts
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.

terminal
# 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)
3-pay.ts
// 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.

terminal
# 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)
4-swap.ts
// 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.

terminal
# 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)
5-bridge.ts
// 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.

EndpointMethodPurpose
/api/balancePOSTReconstruct the agent's private balance from its notes
/api/deposit/preparePOSTBuild an unsigned deposit (shield) transaction
/api/withdrawPOSTPrivate send to any address (up to 2 recipients)
/api/swap-privatePOSTSame-chain swap into a burner (step 1 of private swap)
/api/burner-gasPOSTRelayer gas top-up for a burner mid-swap
/api/burner/auth-messageGETThe message to sign for registry auth (distinct from the sign-in message)
/api/burner/registerPOSTRegister a swap burner's encrypted key for cross-device recovery
/api/burner/listPOSTList the agent's registered burners (encrypted keys) to sweep in recovery
/api/burner/mark-sweptPOSTMark a burner swept in the registry (bookkeeping)
/api/swapPOSTCross-chain bridge via NEAR Intents 1Click
/api/bridge/quotePOSTQuote a swap or bridge (dry or real)
/api/router/quotePOSTCompeting Relay + Across quotes for the direct router bridge (Base ↔ Robinhood)
/api/router/executePOSTOrdered txs[] (approve + deposit) for the burner to broadcast a router bridge
/api/intent-statusGETPoll 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

ConsiderationDetail
SigningOne personal_sign over the fixed message. Cache per session; it derives all account keys.
GasRelayer pays gas for withdrawals and private transactions. Deposits are broadcast by the agent wallet.
FeesFlat + 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.
MinimumsSend, 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.
RecoveryKeys derive from the signature; a restarted agent resumes with no state loss.
LatencySend: seconds. Same-chain swap: ~30s. Cross-chain bridge: poll intent status (~1-6 min).
The RAIL20 relayer builds proofs from the agent's signature, so it can see the amounts and recipients it relays for the agent. It cannot move funds without the signature, and on-chain observers still learn nothing. A self-hosted relayer removes this trust.