Skip to content

About

Open-RMF robot fleets on the Spraay x402 gateway: paid RTP dispatch and batch operator payouts on Base.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

rmf-spraay

Bridge Open-RMF fleets onto the Spraay x402 gateway as RTP (Robot Task Protocol) robots, and pay fleet operators their share of the fees actually collected — as a real Spraay batch on Base.

Two processes:

  1. Bridge (src/bridge) — registers RMF fleets as RTP robots, receives the gateway's dispatch webhook, submits the task to the rmf-web api-server, tracks it over socket.io (REST poll as fallback), and reports the result to robots/complete.
  2. Payout worker (src/payout) — settles the operator share of completed, gateway-accepted tasks as one Spraay batch/execute on Base, then decodes the receipt and verifies every line and the fee before marking anything settled.

MIT licensed. Spraay is the gateway, the batch rail and the author's own project.

Simulated fleet, real money. The demo fleet is the rmf_demos office world running headless in Gazebo. Robots move in simulation; every payment below is a real USDC transfer on Base mainnet.

How money moves

buyer ──$0.05 x402──▶ gateway payTo (0xAd62…95c8)          robots/task, per dispatch
                          │
            (no transfer: same owner)
                          │
payout wallet (pre-funded by the gateway owner, 220000 raw)
   ├─ $0.001 x402 ─▶ gateway                                batch/estimate (advisory)
   ├─ $0.02  x402 ─▶ gateway                                batch/execute
   └─ approve exact ─▶ Spraay batch ─▶ operator recipient 1 / 2  (+0.3% Spraay fee)

The payout wallet was pre-funded by the gateway owner. The gateway revenue wallet, the payout wallet and the operator recipients all belong to the same owner, so the demo does not move collected fees from the revenue wallet into the payout wallet. A transfer between them would add nothing. What the demo does prove is that the amount paid out is derived from a fee that was actually collected for a task that actually completed.

Fee-share disclosure

  • Every task that RMF reports completed and the gateway accepts as COMPLETED records fee_collected_raw = 50000 ($0.05), the gateway's fixed robots/task charge.
  • Operator share = OPERATOR_SHARE_BPS (default 5000 = 50%) of that fee, split across the robot's payout rules in robots.config.json. The demo splits 50/50 across two recipients.
  • The share is a configurable policy of this bridge, not a gateway rule. The amounts are derived from real collected fees.
  • Never settle on a dispatch 200. RMF returns 200 success:true for tasks no fleet can do and fails them asynchronously (failed_to_assign). Only RMF completed → robots/complete accepted makes a task settle-eligible. FAILED and timed-out tasks pay operators nothing.

Pricing honesty

The gateway charges a fixed $0.05 per robots/task. The price_per_task a robot registers is advertising only in v1: it is never read on the payment path. Operator-set pricing (dynamic x402 amounts from price_per_task) is planned gateway work.

Dispatch fees are not refunded on failure in v1. A buyer pays $0.05 when a task is dispatched, whatever its outcome. The escrow field in the gateway's robots/complete reply ("released" / "refunded") is a label only: no transfer accompanies it.

Proofs (Base mainnet)

Full write-ups: proofs/baseline.md, proofs/m4-reconciliation.md, proofs/m2-sim.md. Raw JSON alongside.

step tx block result
M0 dispatch ($0.05) 0x73809e1ad191c099d056e04eb3b8df20d989b02c47c9136fc5a45b24a8d32df6 52148943 paid; task FAILED; fee kept (no refunds in v1; escrow is a label only)
M4 patrol coe→lounge ($0.05) 0xfdcf886a208c5265ca65ec344b794e0d92acd345037f0394cbbea722842ff902 52150801 RMF completed by tinyRobot1 → complete COMPLETED → 200 → settle-eligible, share 25000 raw
M4 ghost fleet ($0.05) 0x36087a7f9951ad516a958852405f5e090ca9729b8ad57831ec8b85ee9f11cac8 52150824 RMF 200, then failed_to_assign → complete FAILED → not eligible (never settle on a dispatch 200, shown live)
funding — — none. Payout wallet pre-funded by the gateway owner
x402 batch/estimate ($0.001) 0x6b3c1529323d5cac5370c7b8d8deabf80e6c70c14306a4ef0449ea1ad2380918 52171200 payout → gateway, 1000 raw
x402 batch/execute ($0.02) 0x7091966d1051d371f29d02f667bf6e8f45cabcf686b30e36a06492527845d92c 52171201 payout → gateway, 20000 raw
approve (exact 25075) 0x93f768b3cec289d722848ac6d6a786bf76d3d8673113b05156237b5068f08a7f 52171201 nonce 0
Spraay batch 0xb2f18fa6df1d73232b0fa3b08df153c5d11e1b6014e629bbbd6bb1f30d7d1c8a 52171202 nonce 1, verified

Decoded batch: payout → Spraay batch contract 0x1646…5eEC 25075; contract → recipient 1 0x0b2E9104224A3dB1193FF2a9000560870917d518 12500, recipient 2 0x5bb66aaCE0512E5172C1e35A627906B048EE9965 12500, Spraay fee 0x033d…71b5 75 (30.00 bps).

Reconciliation of payout wallet 0x21718600Cd4B9cbC9a4690CCbD31e6D09d59da9E:

  • USDC: 220000 − 1000 − 20000 − 25075 = 173925 raw ✔ (read at block 52171210)
  • ETH: 200000000000000 − 334144132559 (approve) − 771584155665 (batch) = 198894271711776 wei ✔
  • Buyer: 500000 − 3 × 50000 = 350000 raw ✔

The first worker attempt (run 1cfaf656…2453) failed before any network call because local DNS could not resolve the gateway. Nothing was signed or sent. It was released with the explicit retry command and the second run (da0fb199…a17a) settled.

Quickstart

Requires Node 24.15.0 or later in the 24 line (the only version this was run and tested on; it runs the TypeScript directly, no build step), Docker, and Git Bash on Windows.

npm ci
cp .env.example .env              # fill in real values; .env is gitignored
npm run typecheck && npm test     # 66 tests, no network, no spend

# 1. Office world, headless. api-server on 127.0.0.1:8000 INSIDE the sim's netns only.
deploy/sim-up.sh

# 2. Bridge + Cloudflare quick tunnel inside the same netns; prints the tunnel URL.
deploy/bridge-up.sh               # then set WEBHOOK_BASE_URL=<printed https URL> in .env

# 3. Register / update fleets (free).
node src/bridge/main.ts sync

# 4. Buy a dispatch ($0.05, real).
node scripts/buy-dispatch.ts patrol office-tinyrobot patrol '{"places":["coe","lounge"],"rounds":1}' 600

# 5. Settle operator shares.
node src/payout/main.ts dry-run   # no key, no spend
node src/payout/main.ts run       # $0.001 + $0.02 + 0.3% + gas

# 6. Teardown.
node src/bridge/main.ts deregister office-tinyrobot
deploy/sim-down.sh

Worker commands: dry-run, run, recover (checks interrupted runs against the chain), retry <run-id> (explicitly releases a failed run's tasks, and nothing else does), status. Bridge commands: sync [--allow-insecure], deregister <key>, serve, tasks, robots.

Configuration reference

Environment (.env, never committed)

var used by meaning
GATEWAY_URL both default https://gateway.spraay.app
BASE_RPC_URL worker, scripts default https://mainnet.base.org
RMF_API_URL bridge default http://127.0.0.1:8000
RMF_API_SECRET bridge JWT secret of the api-server (see Security)
WEBHOOK_BASE_URL bridge public https base the gateway calls (--allow-insecure for local tests)
WEBHOOK_SECRET bridge ≥ 32 chars; derives the per-robot secret webhook path
BRIDGE_HOST / BRIDGE_PORT bridge default 127.0.0.1 / 8787
DB_PATH both SQLite ledger, default rmf-spraay.db. Also holds each robot's update secret: back it up, keep it private
ROBOTS_CONFIG both default robots.config.json; comma-separated list allowed
RECONCILE_MS, LOG_LEVEL bridge poll interval, log verbosity
PAYOUT_PRIVATE_KEY worker key of the dedicated payout wallet (never the gateway revenue key)
PAYOUT_ADDRESS both must equal the address of PAYOUT_PRIVATE_KEY; also the robots' registered payment_address
PAYOUT_RECIPIENT_1 worker operator recipient 1 (falls back to PAYOUT_ADDRESS if unset)
PAYOUT_RECIPIENT_2 worker operator recipient 2
OPERATOR_SHARE_BPS bridge share of each collected fee, 1..10000, default 5000
PREFLIGHT_ESTIMATE worker 0 skips the advisory $0.001 estimate (default on)
PAYOUT_MIN_RAW worker don't settle below this total, default 1
STALE_MINUTES worker age at which an unfinished run is checked by recovery
BUYER_PRIVATE_KEY scripts demo buyer only

Addresses are validated and checksummed at load. A value that is not an address is never echoed back. A 32-byte hex value (a pasted private key) is reported as such, without the value.

robots.config.json

One entry per fleet: key, name, description, tags, simulated, the rmf block (fleet, robots, world, pinned image digests, places, handlers), capabilities (rmf_category patrol|delivery, expected_duration_seconds, min_timeout_seconds, where the bridge refuses tasks whose buyer timeout is shorter), payment_address, and payout rules {label, wallet, bps} where wallet is 0x…, env:NAME or env:A|env:B, and the bps must sum to 10000.

Security notes

  • Rotate the api-server JWT secret. The rmf-web api-server image ships with jwt_secret = "rmfisawesome". Anyone who knows it can mint tokens. That is acceptable for a local simulation only. For anything else, set your own secret in deploy/api_config.py and RMF_API_SECRET.
  • Never publish port 8000. The api-server's /_internal websocket is unauthenticated: anything that can reach it can inject task state. sim-up.sh publishes no ports. The api-server binds 127.0.0.1 inside the sim's network namespace, and only the bridge joins it. The tunnel's only origin is the bridge (:8787).
  • The dispatch webhook is unsigned (no HMAC, no retry). The bridge defends with a per-robot secret path /hooks/dispatch/<hmac(WEBHOOK_SECRET, key)>. Wrong paths 404 and are logged. A lost webhook loses the task.
  • Treat task IDs and the webhook URL as credentials.
  • Robot secret. Since the gateway update of 4 October 2026, robots/register accepts an optional update_secret (32–256 chars). After that, robots/update and robots/deregister require it in the X-Robot-Secret header: 401 if missing, 403 if wrong. On each first registration the bridge generates a fresh random secret per robot (32 bytes, 64 hex chars) and sends it. It is stored only in the local SQLite DB (robots.update_secret). It is never logged or printed, and gateway replies are scrubbed of it before they are stored or logged. It is sent only on register, update and deregister, and dropped after a successful deregistration. A 401 or 403 is a hard error: the bridge never re-registers in response, because that would orphan a robot that is still routable.
  • Losing the local DB means losing control of the robot. The secret exists nowhere else, and there is no recovery route. Without it, that robot can no longer be updated (webhook, payment address, description) or deregistered by anyone, and it stays listed. Back up rmf-spraay.db and keep it as private as a key. Robots registered before the gateway update of 4 October 2026 have no secret and are updated without the header.
  • Payout wallet isolation. The worker signs with a dedicated, small payout wallet, never the gateway revenue key. Approvals are exact, never infinite. A signed batch is persisted before broadcast. No failure path releases a claim, so a crash cannot double-pay. Only an explicit retry on a failed run releases its tasks.
  • No private key has ever been committed (every commit was scanned against the keys in use). .env and .env.* are gitignored. Only .env.example with placeholders ships.

License

MIT. See LICENSE.

About

Open-RMF robot fleets on the Spraay x402 gateway: paid RTP dispatch and batch operator payouts on Base.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages