|
| 1 | +# Live Agentic-Escrow Demo — design contract |
| 2 | + |
| 3 | +> **SIMULATED / MOCK CHAIN — not a live network.** Everything here runs against |
| 4 | +> an in-memory `MockChain` (see `onchain.py`) implementing the **AgentEscrow** |
| 5 | +> surface (`lock → confirm → release / refund`). **No real ETH, no real RPC, no |
| 6 | +> network, no funds.** The agents and keys are synthetic. Nothing in this folder |
| 7 | +> is a live or production deployment. |
| 8 | +> |
| 9 | +> _Demo by Pattermesh (Patty / P. Sundaram)_ on top of **kcolbchain/switchboard** |
| 10 | +> (the collective's agentic-payments rail; Abhishek Krishna / @abhicris leads). |
| 11 | +> The escrow + x402 + SafeSwap orchestration being shown is switchboard's, not |
| 12 | +> Patty's — this page only makes it **watchable**. |
| 13 | +
|
| 14 | +This document is the **fixed contract** between the three pieces of the live |
| 15 | +demo. The Architect owns this file and `server.py`'s stub signatures; the |
| 16 | +backend builder fills `observable.py` + the `server.py` handler bodies; the |
| 17 | +frontend builder writes the page that calls the API below. **Do not change the |
| 18 | +event field names, step ids, actor ids, or route strings** without updating all |
| 19 | +three. |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## 1. What already exists (do NOT rewrite) |
| 24 | + |
| 25 | +`examples/agentic_demo/` already runs the whole A2A flow in one shot via |
| 26 | +`scenario.run_scenario()`, which drives the **real** switchboard library |
| 27 | +(`switchboard.x402_middleware.X402Middleware`, `switchboard.gas_tracker.GasTracker`) |
| 28 | +against the mock substrate: |
| 29 | + |
| 30 | +``` |
| 31 | +402 offer → validate(cap/allowlist/gas) → escrow lock → deliver |
| 32 | + → confirm/release → SafeSwap quote → SafeSwap execute |
| 33 | +``` |
| 34 | + |
| 35 | +`scenario.py`, `onchain.py`, `safeswap.py` stay **byte-for-byte unchanged**. The |
| 36 | +live demo *wraps* `run_scenario` to record an ordered, render-ready **timeline**, |
| 37 | +then serves it over a stdlib HTTP server. The existing `web/lab/swap.html` is a |
| 38 | +client-side cartoon with a hardcoded `STEPS` array; the live demo replaces those |
| 39 | +fake numbers with the **real** orchestration output (real escrow state, real |
| 40 | +SafeSwap quote, real spend summary). |
| 41 | + |
| 42 | +### Canonical step ids (from `scenario.StepLog.step`, already matched by `swap.html`) |
| 43 | + |
| 44 | +| seq | `step` id | meaning | |
| 45 | +|-----|----------------|------------------------------------------------------| |
| 46 | +| 0 | `setup` | Agent A funded; Agent B empty (pre-roll, UI may skip)| |
| 47 | +| 1 | `402` | Agent B → `402 Payment Required` + x402 escrow offer | |
| 48 | +| 2 | `validate` | Agent A: offer passes cap / allowlist / gas budget | |
| 49 | +| 3 | `pay` | Agent A locks funds in `AgentEscrow` → **Locked** | |
| 50 | +| 4 | `deliver` | Agent B serves the work (`200 OK`) | |
| 51 | +| 5 | `settle` | Agent A `confirmPayment()` → escrow **Released** | |
| 52 | +| 6 | `swap.quote` | SafeSwap best-execution route quoted | |
| 53 | +| 7 | `swap.execute` | Routed swap settled → `SwapReceipt` | |
| 54 | + |
| 55 | +These ids and their order are **load-bearing**: `tests/test_agentic_demo.py` |
| 56 | +already asserts `402 < pay < settle < swap.quote < swap.execute`, and the |
| 57 | +observable wrapper MUST reproduce the same ids in the same order. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## 2. The TIMELINE-EVENT model |
| 62 | + |
| 63 | +A run is an **ordered list of `TimelineEvent`s** plus a small `summary`. One |
| 64 | +event per step above (the `setup` pre-roll included). Each event is a frame the |
| 65 | +UI can render to show the two agents transacting one step at a time: who acts, |
| 66 | +what moves, what the escrow + balances look like *after* the step, and which |
| 67 | +mock-chain block we're on. |
| 68 | + |
| 69 | +### `TimelineEvent` (JSON object) |
| 70 | + |
| 71 | +| field | type | notes | |
| 72 | +|----------------|------------------|----------------------------------------------------------------------------------------| |
| 73 | +| `seq` | int | 0-based order index (matches the table above). | |
| 74 | +| `step` | string | one of the canonical step ids. | |
| 75 | +| `phase` | string | coarse grouping for styling: `"setup" \| "pay" \| "swap"`. `swap.*` → `"swap"`. | |
| 76 | +| `actor` | string | the node that **initiates** this step: `"A" \| "B" \| "escrow" \| "safeswap" \| "system"`. | |
| 77 | +| `peer` | string \| null | the counterparty / destination node id (same vocabulary as `actor`), or `null`. | |
| 78 | +| `title` | string | short label, e.g. `"402 Payment Required"`. | |
| 79 | +| `detail` | string | one-line human description (plain text; UI may bold/format). | |
| 80 | +| `amount` | object \| null | value moved **this step** (see **Amount** below), or `null` if nothing moves. | |
| 81 | +| `escrow` | object \| null | escrow snapshot **after** this step (see **Escrow** below); `null` before lock. | |
| 82 | +| `balances` | object | balances **after** this step: see **Balances** below. | |
| 83 | +| `block` | int | `MockChain.block_number` **after** this step (mock-chain "block height"). | |
| 84 | +| `tx` | string \| null | tx hash / escrow `request_id` produced this step (`0x…` or a uuid), else `null`. | |
| 85 | +| `data` | object | step-specific extras, passthrough of `StepLog.data` plus the typed fields below. | |
| 86 | + |
| 87 | +#### Amount (`event.amount`) |
| 88 | + |
| 89 | +```jsonc |
| 90 | +{ "token": "USDC", "units": 5000000, "decimals": 6, "display": "5.00" } |
| 91 | +``` |
| 92 | + |
| 93 | +* `units` is the integer base-unit amount (USDC = 6dp, ETH = 18dp), exactly as |
| 94 | + the library/`MockChain` track it — never a float. |
| 95 | +* `display` is the human string the UI shows; computed as `units / 10**decimals`. |
| 96 | + |
| 97 | +#### Escrow (`event.escrow`) |
| 98 | + |
| 99 | +```jsonc |
| 100 | +{ "request_id": "…", "state": "Locked", "amount": { /* Amount */ }, "payer": "A", "payee": "B" } |
| 101 | +``` |
| 102 | + |
| 103 | +`state` is one of `EscrowState` values: `"Locked" | "Confirmed" | "Released" | |
| 104 | +"Refunded" | "Cancelled"` (from `onchain.EscrowState`). |
| 105 | + |
| 106 | +#### Balances (`event.balances`) |
| 107 | + |
| 108 | +Map of **node id → Amount**, reflecting on-chain balances after the step: |
| 109 | + |
| 110 | +```jsonc |
| 111 | +{ "A": { "token": "USDC", "units": 95000000, "decimals": 6, "display": "95.00" }, |
| 112 | + "B": { "token": "USDC", "units": 5000000, "decimals": 6, "display": "5.00" } } |
| 113 | +``` |
| 114 | + |
| 115 | +After `swap.execute`, Agent B's balance Amount switches `token`/`decimals` to the |
| 116 | +swap output asset (e.g. `ETH`, 18dp) — mirroring `swap.html`'s `balBUnit` flip. |
| 117 | + |
| 118 | +### Run envelope (`POST /api/demo/run` → 200) |
| 119 | + |
| 120 | +```jsonc |
| 121 | +{ |
| 122 | + "ok": true, |
| 123 | + "sandbox": "SIMULATED / MOCK CHAIN — not a live network. No real ETH/RPC/funds.", |
| 124 | + "deterministic": true, |
| 125 | + "params": { "price_units": 5000000, "swap_to": "ETH", "seed": 42 }, |
| 126 | + "agents": { |
| 127 | + "A": { "id": "A", "role": "payer", "label": "Agent A", "address": "0x…" }, |
| 128 | + "B": { "id": "B", "role": "payee", "label": "Agent B", "address": "0x…" }, |
| 129 | + "escrow": { "id": "escrow", "role": "contract", "label": "AgentEscrow" }, |
| 130 | + "safeswap": { "id": "safeswap", "role": "orchestrator", "label": "SafeSwap" } |
| 131 | + }, |
| 132 | + "timeline": [ /* TimelineEvent, in seq order */ ], |
| 133 | + "summary": { |
| 134 | + "settled": true, |
| 135 | + "swap_routed": true, |
| 136 | + "escrow_request_id": "…", |
| 137 | + "escrow_state": "Released", |
| 138 | + "offer": { "amount_units": 5000000, "currency": "USDC", "scheme": "escrow", "recipient": "0x…" }, |
| 139 | + "swap": { /* SwapReceipt.to_dict() */ }, |
| 140 | + "spend_summary": { /* X402Middleware.get_spend_summary() */ }, |
| 141 | + "blocks_mined": 4 |
| 142 | + } |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +`summary` is derived from the real `ScenarioResult` (`settled`, `swap_routed`, |
| 147 | +`spend_summary`, `swap_receipt.to_dict()`), so the page footer shows genuine |
| 148 | +library output, not recomputed JS. |
| 149 | + |
| 150 | +On error the envelope is `{ "ok": false, "error": "<message>" }` with HTTP 400/500. |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +## 3. Determinism |
| 155 | + |
| 156 | +The page must be **replayable to the same bytes** so the demo and its tests are |
| 157 | +stable. `scenario.run_scenario()` itself uses `time.time()` (offer `nonce` / |
| 158 | +`expires_at`, `SwapReceipt.settled_at`) and `uuid4()` (escrow `request_id`, tx |
| 159 | +hashes) — non-deterministic. The observable wrapper therefore runs in a |
| 160 | +**seeded / logical-clock** mode: |
| 161 | + |
| 162 | +* A **logical clock** starts at a fixed epoch and advances by a fixed tick per |
| 163 | + recorded event, so `expires_at`, `settled_at`, and event `at` are reproducible. |
| 164 | +* A **seeded RNG** (default `seed=42`) derives the escrow `request_id` and any |
| 165 | + ids the wrapper itself mints, so `tx`/`request_id` are stable across runs. |
| 166 | +* The **mock-chain block numbers** are already deterministic (every |
| 167 | + `transfer`/`escrow_*` calls `mine()`), so `block` is stable for free. |
| 168 | + |
| 169 | +Determinism is opt-outable (`deterministic=False` / no seed) for an organic live |
| 170 | +run, but the **default** for the server + all tests is deterministic. The |
| 171 | +contract: **two `POST /api/demo/run` with identical params + seed return |
| 172 | +byte-identical `timeline` + `summary`** (this is the headline backend test). |
| 173 | + |
| 174 | +The wrapper **must not** edit `scenario.py`; it injects determinism by |
| 175 | +constructing its own `MockChain` / seeded clock and recording the same step |
| 176 | +sequence `run_scenario` performs (it may call `run_scenario(chain=…, safeswap=…)` |
| 177 | +and post-process its `StepLog`s, or re-drive the identical calls — builder's |
| 178 | +choice — but the resulting step ids/order MUST match §1). |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## 4. HTTP API (stdlib `http.server`, no framework) |
| 183 | + |
| 184 | +All JSON, `Content-Type: application/json`, permissive same-origin. Pure |
| 185 | +`http.server.BaseHTTPRequestHandler` — **no Flask/FastAPI, no build step**, house |
| 186 | +style. Bind `127.0.0.1` by default. |
| 187 | + |
| 188 | +| Method + path | body | → | |
| 189 | +|------------------------|---------------------------------------|------------------------------------------------------| |
| 190 | +| `GET /` | — | serves the demo HTML page (text/html). | |
| 191 | +| `GET /api/demo/state` | — | the **last** run envelope, or a fresh seeded run if none yet. | |
| 192 | +| `POST /api/demo/run` | `{ "price_units"?, "swap_to"?, "seed"?, "deterministic"? }` | runs a full scenario; returns the **run envelope** (§2). | |
| 193 | +| `POST /api/demo/step` | `{ "reset"? : bool }` | **optional** stepwise mode: advance one event and return `{ "ok", "done", "index", "event": TimelineEvent }`; `reset:true` rewinds to before `setup`. | |
| 194 | +| `GET /healthz` | — | `{ "ok": true }` liveness. | |
| 195 | + |
| 196 | +* `price_units` integer base units (default `5_000_000` = 5 USDC); `swap_to` ∈ |
| 197 | + `{"ETH","LUX","USDC"}` (default `"ETH"`); `seed` int (default `42`); |
| 198 | + `deterministic` bool (default `true`). |
| 199 | +* `POST /api/demo/run` is the primary path: the page calls it once and animates |
| 200 | + the returned `timeline` client-side (it already has the canvas + ledger from |
| 201 | + `swap.html`). `POST /api/demo/step` is a convenience for a true server-driven |
| 202 | + step mode; if unimplemented it returns `501`. |
| 203 | +* `GET /api/demo/state` lets a freshly-loaded page render the last run (or a |
| 204 | + default seeded run) without re-POSTing — handy for the PWA offline shell. |
| 205 | + |
| 206 | +### Static page serving |
| 207 | + |
| 208 | +`GET /` returns an HTML page. The server resolves it from (in order): |
| 209 | + |
| 210 | +1. `examples/agentic_demo/demo.html` if present (a self-contained live page the |
| 211 | + frontend builder may add), else |
| 212 | +2. the repo's `web/lab/swap.html` (the existing visualization), rewriting its |
| 213 | + hardcoded `STEPS`/balances to fetch `/api/demo/run` instead. |
| 214 | + |
| 215 | +Either way the page is **vanilla ES + the existing `shared.css` tokens** (gold |
| 216 | +`#d4a853`, emerald `#4ecb71`, violet `#a78bfa`, cyan `#67d4e0`, mono |
| 217 | +`JetBrains Mono`). No external requests beyond what `swap.html` already declares. |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## 5. Contract surface the builders implement |
| 222 | + |
| 223 | +These names are **fixed** by `server.py`'s stubs. Backend builder fills the |
| 224 | +bodies; frontend builder calls the routes. |
| 225 | + |
| 226 | +**`examples/agentic_demo/observable.py`** (new; the timeline recorder — wraps |
| 227 | +`run_scenario`, never edits it): |
| 228 | + |
| 229 | +```python |
| 230 | +@dataclass |
| 231 | +class TimelineEvent: # the §2 object; .to_dict() emits the JSON above |
| 232 | + seq: int; step: str; phase: str; actor: str; peer: str | None |
| 233 | + title: str; detail: str |
| 234 | + amount: dict | None; escrow: dict | None; balances: dict |
| 235 | + block: int; tx: str | None; data: dict |
| 236 | + def to_dict(self) -> dict: ... |
| 237 | + |
| 238 | +@dataclass |
| 239 | +class DemoRun: # the run envelope (§2) |
| 240 | + params: dict; agents: dict; timeline: list[TimelineEvent]; summary: dict |
| 241 | + def to_dict(self) -> dict: ... |
| 242 | + |
| 243 | +def run_observable(*, price_units: int = 5_000_000, swap_to: str = "ETH", |
| 244 | + seed: int = 42, deterministic: bool = True) -> DemoRun: ... |
| 245 | + # drives the real scenario, returns the recorded, render-ready timeline. |
| 246 | + |
| 247 | +class StepCursor: # backs POST /api/demo/step (optional stepwise mode) |
| 248 | + def __init__(self, run: DemoRun): ... |
| 249 | + def reset(self) -> None: ... |
| 250 | + def advance(self) -> tuple[bool, int, TimelineEvent | None]: ... # (done, index, event) |
| 251 | +``` |
| 252 | + |
| 253 | +**`examples/agentic_demo/server.py`** (skeleton fixed by Architect; bodies by |
| 254 | +backend builder): |
| 255 | + |
| 256 | +```python |
| 257 | +SANDBOX_NOTE = "SIMULATED / MOCK CHAIN — not a live network. No real ETH/RPC/funds." |
| 258 | + |
| 259 | +class DemoHandler(BaseHTTPRequestHandler): |
| 260 | + def do_GET(self): ... # routes: / , /api/demo/state , /healthz |
| 261 | + def do_POST(self): ... # routes: /api/demo/run , /api/demo/step |
| 262 | + # helpers (fixed names): |
| 263 | + def _send_json(self, obj, status=200): ... |
| 264 | + def _send_html(self, html, status=200): ... |
| 265 | + def _read_json_body(self) -> dict: ... |
| 266 | + def _serve_page(self): ... # resolves the §4 static page |
| 267 | + def _handle_run(self, body: dict): ... # → run_observable → envelope |
| 268 | + def _handle_state(self): ... # last run or default seeded run |
| 269 | + def _handle_step(self, body: dict): ... # StepCursor; 501 if unimplemented |
| 270 | + |
| 271 | +def make_server(host="127.0.0.1", port=8402) -> ThreadingHTTPServer: ... |
| 272 | +def main(argv: list[str] | None = None) -> int: ... # CLI: --host --port --open |
| 273 | +``` |
| 274 | + |
| 275 | +The frontend calls only the §4 routes; it never imports Python. The whole thing |
| 276 | +launches with `PYTHONPATH=. python examples/agentic_demo/server.py` and is then |
| 277 | +viewable at `http://127.0.0.1:8402/`. |
| 278 | + |
| 279 | +--- |
| 280 | + |
| 281 | +## 6. Tests the backend builder must turn green |
| 282 | + |
| 283 | +Scoped to `tests/test_agentic_demo_live.py` (new file; does NOT touch the |
| 284 | +existing `test_agentic_demo.py`). At minimum: |
| 285 | + |
| 286 | +* **determinism** — two `run_observable(seed=42)` calls produce equal |
| 287 | + `to_dict()` (timeline + summary byte-identical). |
| 288 | +* **step coverage + order** — the timeline contains exactly the §1 step ids in |
| 289 | + order; `402 < pay < settle < swap.quote < swap.execute`. |
| 290 | +* **truthful amounts** — `pay` shows the escrow `Locked` at the offer amount; |
| 291 | + `settle` shows escrow `Released` and Agent B credited the price; balances after |
| 292 | + `settle` are A=95 / B=5 USDC (units), matching `test_agentic_demo`'s ledger |
| 293 | + assertions; after `swap.execute` B's balance Amount flips to the swap asset. |
| 294 | +* **summary is real** — `summary.settled is True`, `summary.swap_routed is True`, |
| 295 | + `summary.spend_summary["total_spent_wei"] == price_units`, |
| 296 | + `summary.swap.amountOut > 0`. |
| 297 | +* **HTTP** — spin up `make_server` on an ephemeral port; `POST /api/demo/run` |
| 298 | + returns `ok:true` + a non-empty `timeline`; `GET /healthz` → `ok:true`; |
| 299 | + unknown path → `404`; `GET /` → `200 text/html`. |
| 300 | + |
| 301 | +`scenario.py`/`onchain.py`/`safeswap.py` and `test_agentic_demo.py` remain |
| 302 | +unchanged; the live layer is purely additive. |
0 commit comments