From 662e0907dafb8ac463d4e5cbcde8e226476dc44f Mon Sep 17 00:00:00 2001 From: Temrjan Date: Tue, 23 Jun 2026 14:33:11 +0500 Subject: [PATCH] docs(uniswap): exposure-seam design note + roadmap update (PR-2 slice 1) Record how the signing path is wired (the net-new exposure-seam artifact) and sync the roadmap with reality: - New EXPOSURE-SEAM.md: Q1=(A) orchestrator+gates in the TS glue; Q2=(B) transport to core signing via the gateway (core gRPC stays internal); the 3 F5 locks (sole sign-route owner, MCP exposes only request_swap, remove the dead eip712 branch); the Slice 1 (hermetic) / Slice 2 (live wiring) split; deferred invariants (recover==order.swapper, trusted `now`, destination-token gate). - Roadmap: PR-0/PR-1 done; PR-1 = M0 read-path is prod-shipped+verified (no code, read-only demo folds into Slice 1); PR-2 split into Slice 1 (done) + Slice 2 (live wiring); open fork #1 (gates/transport) resolved -> EXPOSURE-SEAM.md. --- docs/EXPOSURE-SEAM.md | 71 +++++++++++++++++++++++++++++++++++++ docs/UNISWAP-INTEGRATION.md | 11 +++--- 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 docs/EXPOSURE-SEAM.md diff --git a/docs/EXPOSURE-SEAM.md b/docs/EXPOSURE-SEAM.md new file mode 100644 index 0000000..6767b9a --- /dev/null +++ b/docs/EXPOSURE-SEAM.md @@ -0,0 +1,71 @@ +# Exposure Seam — how the signing path is wired + +> Companion to [UNISWAP-INTEGRATION.md](./UNISWAP-INTEGRATION.md). Records the load-bearing +> design decisions for **how an agent reaches EIP-712 signing without a bypass**. Source of +> truth — changes go through a PR, not silent drift. + +--- + +## The gap this resolves + +The core gRPC `SignTypedData(domain_separator, struct_hash)` RPC exists and signs, but at +the start of PR-2 there was **no path to it from the agent**: the gateway has no +`sign_typed_data` route (`lib.rs:96-102`) and no client method (`client.rs`), the MCP has no +tool, and the dead `eip712` branch in the gateway's `sign_message` maps to core `SignMessage` +which **rejects** eip712 (`server.rs:316`). The decode / encode logic must stay TypeScript +(the official UniswapX SDK is consumed as-is — never re-implement the witness/domain). So the +seam spans **TS glue ↔ gateway ↔ core gRPC**, and it must have exactly one route to signing. + +--- + +## Decisions (locked) + +- **Q1 = (A) — orchestrator + gates live in the TS glue (`rustok-org/uniswap`).** One runtime + where decode / encode / reconstruct / gate / sign-seam co-locate, so the gate and the signer + see one order object and there is minimal seam for a bypass to hide. Core stays a *dumb* + EIP-712 signer (it never decodes the order); the MCP stays a thin capability-gated proxy. + +- **Q2 = (B) — transport to core signing is via the gateway (HTTP), not a direct gRPC client.** + Add `/api/v1/wallet/sign_typed_data` (route + gateway gRPC client method) and the TS + orchestrator calls it with Bearer auth, exactly as the MCP calls the other wallet routes. + Rationale: the gateway is the **single policy/auth/rate-limit/audit boundary** to core, and + core gRPC stays **internal / non-public** (only the gateway is exposed at `api.rustokwallet.com`). + A direct gRPC client would open a *second door* to core and bypass that boundary. + +- **Three F5 locks (no bypass to signing):** + 1. the TS orchestrator is the **sole owner** of the route/credential to the sign operation; + 2. the MCP exposes only **`request_swap(intent)`** — never `sign(hashes)`, never a direct + gRPC sign-client; + 3. the **dead `eip712` branch** in the gateway's `sign_message` is **removed**. + +- **Single sign-route by construction:** the orchestrator has exactly one call site that can + sign — an injected `Signer` seam. Enforced by test (runtime call-count + structural: the only + `.signDigest(` caller outside the signer impl is the orchestrator). + +--- + +## Slice split (forced by repo boundary + the hermetic form) + +`uniswap`, `meta` and `core` are **separate repos** — one PR cannot span them. And the hermetic +smoke must not depend on a running core. So: + +| Slice | Where | What | +|---|---|---| +| **Slice 1** ✅ | `uniswap` | hermetic orchestrator: reconstruct-before-sign → recipient-aware minOut → freshness → injected `Signer` seam (dev key, recover-verified). No core / gateway / submit. | +| **Slice 2** | `core` (gateway) + `uniswap` | live wiring: gateway `sign_typed_data` route + client + remove the dead eip712 branch + a gateway-HTTP `Signer` impl + E2E on a funded dev wallet (wallet-as-MCP). | + +The `Signer` interface is the seam: Slice 1 plugs a dev-key impl; Slice 2 plugs the gateway-HTTP +impl. Same single route — only the implementation changes. + +--- + +## Deferred to Slice 2 (named so the live slice does not omit them) + +- **`recover == order.swapper`** — sign only your *own* order. Slice 1 verifies only the seam + (`recover == dev addr`; the dev key ≠ the order's swapper). The live slice must bind the + wallet's address to the order's `swapper`. +- **`now` must be a trusted orchestrator clock** — never from the agent or the quote, or freshness + is bypassable. Slice 1 injects `now` for deterministic tests; the live slice binds it to a + trusted source. +- **Destination-token gate** (`output token == intent.tokenOut`) — minOut is *amount*-safety only; + the token check is a later gate PR. diff --git a/docs/UNISWAP-INTEGRATION.md b/docs/UNISWAP-INTEGRATION.md index c25cc2a..544b9f9 100644 --- a/docs/UNISWAP-INTEGRATION.md +++ b/docs/UNISWAP-INTEGRATION.md @@ -116,9 +116,10 @@ PR-by-PR vertical, each = branch + CI + two gates. Small reversible slices: ever | Step | What | Why separate | |---|---|---| | **Spike** (done) | one live `/quote` (DUTCH_V2) — confirmed live shape + that our re-encode reproduces the API digest on a quote-time order | discovery, throwaway, kills the main unknown cheaply | -| **PR-0** | this doc into `meta/docs` + reconcile with reality | source of truth must be versioned | -| **PR-1** | read-path M0 (`get_wallet_context`/`positions` + Uniswap planning) — confirm/finish | the vertical reuses it; if ready, near no-op | -| **PR-2** | narrow full-height vertical: glue → core sign through one **orchestrator** (sole route to signing) + **one load-bearing gate (minOut)** + freshness (refuse-when-stale, re-quote-before-submit, re-gate-on-requote). E2E on dev keyring, recover-verified | this is "full-height but narrow"; closes the no-bypass invariant | +| **PR-0** ✅ | this doc into `meta/docs` + reconcile with reality | source of truth must be versioned | +| **PR-1** ✅ | M0 read-path is **prod-shipped, verified** (core RPCs + mcp→gateway, live on `api.rustokwallet.com`) — no code; the read-only demo folds into PR-2 Slice 1's head | the wiring was already built + deployed; a hollow PR would be plan-worship | +| **PR-2 Slice 1** ✅ | **hermetic** TS orchestrator (sole sign-route) — reconstruct-before-sign + recipient-aware minOut + freshness + dev-key sign-seam, recover-verified, 8-case E2E (no network / no submit) | high-risk logic first, isolated from the live stack | +| **PR-2 Slice 2** | **live wiring** — gateway `sign_typed_data` route + gateway-HTTP `Signer` impl + remove the dead eip712 branch + E2E on a funded dev wallet (wallet-as-MCP) | mechanical transport (Q2 = via gateway); separate repo (core) | | **PR-3** | mainnet-fork harness + adversarial-order corpus | net-new foundation, reused by every gate | | **PR-4…N** | one gate per PR, full ceremony (grill → check → 2 reviews → security-review): destination-token (honeypot/fee-on-transfer on fork) → price cross-check DeFiLlama (fail-closed) → bounded approval → allowlist | security-critical; each PR = gate + fixtures proving rejection | | **Final** | M1 demo: green = a gate rejected a deliberately bad order | the plan's literal criterion | @@ -147,5 +148,7 @@ Plain words: the plan is a living map, not a law — but when we leave the route ## Open forks (resolved by spike + design note, not frozen) -1. **Where gates run + transport** (TS glue vs Python-MCP; gRPC direct vs via MCP) — design note in PR-2's lead-up. +1. **Where gates run + transport — RESOLVED** (see the [Exposure-seam design note](./EXPOSURE-SEAM.md)): + orchestrator + gates live in the **TS glue** (Q1 = A); transport to core signing is **via the gateway** + (Q2 = B — core gRPC stays internal). The agent's only door = MCP `request_swap(intent)`. 2. **Can UniswapX routing be forced at small size?** The spike showed small swaps route CLASSIC and only larger sizes route DUTCH_V2 — so a tiny mainnet UniswapX smoke may not be achievable without forcing the route. Resolve before the mainnet smoke.