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:
- 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 torobots/complete. - Payout worker (
src/payout) — settles the operator share of completed, gateway-accepted tasks as one Spraaybatch/executeon 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.
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.
- Every task that RMF reports
completedand the gateway accepts asCOMPLETEDrecordsfee_collected_raw = 50000($0.05), the gateway's fixedrobots/taskcharge. - Operator share =
OPERATOR_SHARE_BPS(default 5000 = 50%) of that fee, split across the robot'spayoutrules inrobots.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:truefor tasks no fleet can do and fails them asynchronously (failed_to_assign). OnlyRMF completed → robots/complete acceptedmakes a task settle-eligible. FAILED and timed-out tasks pay operators nothing.
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.
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.
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.shWorker 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.
| 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.
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.
- 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 indeploy/api_config.pyandRMF_API_SECRET. - Never publish port 8000. The api-server's
/_internalwebsocket is unauthenticated: anything that can reach it can inject task state.sim-up.shpublishes 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/registeraccepts an optionalupdate_secret(32–256 chars). After that,robots/updateandrobots/deregisterrequire it in theX-Robot-Secretheader: 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.dband 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
retryon afailedrun releases its tasks. - No private key has ever been committed (every commit was scanned against the keys in
use).
.envand.env.*are gitignored. Only.env.examplewith placeholders ships.
MIT. See LICENSE.