# Architecture This document describes the design of `browser-web3-signer` and the rationale behind the decisions that shaped it. It supersedes the original planning notes. ## Goal & context Reimplement, in Rust, the browser-signing capability of `mcp-wallet-signer` (a Deno/TS project), with two changes of intent: - **The CLI is the interface for agents** — MCP is dropped. An agent runs a command and reads stdout. - **The core is a reusable library**, so the capability can be embedded from other languages (via bindings that manage a Rust bridge subprocess) and wrapped by TypeScript adaptors. See [Roadmap](#roadmap) for why this, and not a daemon, is the planned path. The defining property is preserved: **the private key never leaves the user's browser wallet.** This process only ferries a request to a local page and reads the signed result back. The HTTP bridge binds `127.0.0.1` exclusively. ## Workspace layout ``` crates/ browser-web3-signer-core/ chain-agnostic engine (lib) browser-web3-signer-evm/ EVM requests, domain types, embedded UI (lib) browser-web3-signer-tron/ TRON requests, domain types, embedded UI (lib) browser-web3-signer/ the `browser-web3-signer` binary (one-shot CLI) web/ evm.html / tron.html self-contained vanilla-JS approval UIs (embedded via include_str!) ts/ TypeScript binding (viem transport + hybrid account over `serve`) go/ Go binding (go-ethereum-typed client over `serve`) ``` Edition 2024, MSRV 1.95. ## Request lifecycle ``` engine.prepare(request) create a UUID-keyed pending entry, build the approval URL │ (does NOT open a browser) ├─► browser opens /sign/:id ── GET /api/pending/:id ──► request JSON │ wallet signs / sends └─◄ ResultFuture resolves ◄── POST /api/complete/:id {success,result|error,code} ``` `Engine::submit` is the convenience path (prepare + open + await) for library/binding callers; the CLI uses `prepare` so it can print the URL before opening. Requests time out after 5 minutes; a timed-out or cancelled entry is removed so the bridge stops serving it. The HTTP bridge (axum) exposes exactly: `GET /api/pending/:id`, `POST /api/complete/:id`, `GET /api/health`, and a fallback that serves the embedded SPA for any other path (the in-page router dispatches `/connect/:id` and `/sign/:id`). CORS mirrors the reference (`*`, GET/POST/OPTIONS). The request/result JSON shapes and endpoint paths are kept **byte-compatible with the reference UI**, so the ported HTML works unchanged and future TS adaptors interoperate. `build_router_with` / `Engine::start_with` add an **extension point**: a caller can merge its own routes onto the core bridge, sharing the same `PendingStore`. Two callers use it today: the `serve` control API mounts `/api/v1/*` (the long-lived mode language bindings drive — see [Roadmap](#roadmap)), and the e2e test harness mounts `/api/test/*`, both rather than forking the router. The merged routes carry their own state and middleware; the core CORS layer applies only to the core routes. A request's approval page (`/connect` vs `/sign`) is reported by the request itself via `Request::url_kind`, and a request is reconstructed from its wire JSON via `Request::from_json` (the inverse of its `Serialize`). Both live on the core trait, so `Engine::prepare`/`submit`, the `serve` control API, and the e2e harness all stay chain-agnostic — there is one source of truth for the discriminator and the wire shape, shared across EVM and TRON. ## Core abstractions (`browser-web3-signer-core`) - **`PendingStore`** — `Mutex>`. `create` returns a receiver; `complete` fires it; `cancel`/timeout drop the entry. - **`Engine`** — owns the store + the lazily-started bridge. Generic over a chain's request type `R: Request` (a `Serialize` enum that also yields its `id`). - **HTTP bridge** — `build_router` + handlers, generic over `R`. - **Browser launcher** — `opener` crate, honoring `$BROWSER`; `BrowserChoice` of Default / Named / Print. - **Shared byte types** — `TxHash` (32 bytes), `Signature` (ECDSA bytes), `HexData` (calldata/memo). These are identical across EVM and TRON (both secp256k1/keccak), so they live here; only address encoding is chain-specific. - **`RequestMeta`** — the per-request fields common to every chain (`{ id }`), flattened into each request's JSON; one source of truth and a place to grow. - **`Url`** — `url::Url` re-exported so every crate shares one URL type. ## Key decisions & rationale ### Domain types everywhere; no algebraic blindness Values are modelled with precise types, never bare `String`/`u64`/`bool` where a meaningful type exists. A value that "is an address" cannot be confused with one that "is a tx hash". Examples: `Address`, `Wei`, `ChainId`, `CallData` (EVM); `TronAddress`, `Sun`, `EnergyLimit`, `Percentage`, `TronNetwork` (TRON); `Port`, `TxHash`, `Signature`, `HexData`, `Url` (shared). Each parses/validates at construction (a bad address fails at the boundary, not deep in a wallet call) and serializes to the exact wire shape the UI expects. Genuinely open-ended data (EIP-712 `domain`/`types`/`message`) stays as `serde_json::Value` inside a named `TypedData` container. The transport boundary (`Engine`) returns a raw `String` — the browser literally posts a string whose meaning only the chain layer knows — and the chain layer parses it into the right type immediately. ### `Port` / `BindPort`, and the preferred-port-with-fallback model A port is a `Port` (non-zero `NonZeroU16`); "use an ephemeral port" is a distinct `BindPort::Ephemeral` rather than the magic value `0`. The configured port is *preferred*, not mandatory: - **One-shot commands** try the preferred port so the browser origin (`127.0.0.1:3847`) stays stable across invocations — that's what lets a wallet skip the reconnect prompt. If it's already in use, they fall back to an OS-assigned ephemeral port instead of failing, so concurrent commands never collide. - **A long-lived signer** (a reused `EvmSigner`/`TronSigner`, or one held by a binding's managed bridge subprocess) keeps the preferred port for its whole lifetime, so the persistent tab's origin never changes — the same mechanism, just longer-lived. Only a future multi-client daemon (see [Roadmap](#roadmap)) would need a request queue to share one port across processes. ### `Shared` instead of scattered `Arc::clone` `Shared` wraps `Arc` and exposes `.share()` — naming the shared-ownership bump explicitly, which reads better than `Arc::clone(&x)` everywhere and keeps `clippy::clone_on_ref_ptr` satisfied. (See .) ### Per-chain crates, shared core The core is fully chain-agnostic. EVM and TRON each provide their request enum, domain types, embedded UI, and a typed signer over `Engine`. Adding a chain means a new crate, not changes to core. ### No read side: reads belong to the caller This tool is purely a browser-signing bridge. Plain JSON-RPC reads (native/token balances, etc.) need no wallet, browser, or key — they're trivially done with `cast` or any EVM/TRON SDK — so they live with the caller, not here. Dropping them keeps the dependency footprint small: the EVM crate uses `alloy` only for its primitive domain types (`Address`, `Wei`/`U256`, `Bytes`), not for a provider/RPC stack, and the TRON crate uses just `alloy-primitives` plus the maintained `bs58` crate (with checksum) for the Base58Check address codec — no HTTP client. EVM `Address` and `TronAddress` are validated on construction (the latter stored as its canonical 21 bytes: `0x41` prefix + 20-byte body). ### CLI: one-shot, runner structs, clean output streams Each invocation is one-shot (spin up bridge → act → exit). Dispatch lives in `EvmCli` / `TronCli` runner structs that own the signer **and** the presentation context, so each subcommand is a method rather than a function threading `(&signer, &ctx)`. Presentation (stdout text or `--json`) stays in the binary; the library signers are presentation-free. Progress/prompts go to **stderr**, results to **stdout**, so `--json` output is cleanly parseable. ## Embedded UI `web/evm.html` and `web/tron.html` are self-contained vanilla-JS pages (EIP-6963 / injected provider discovery for EVM, TronLink for TRON; no build step, no external requests). They're embedded into the binary with `include_str!` and ported near-verbatim from the reference so the wire contract stays in sync. This is the one part that must remain JavaScript — it runs in the wallet's page context. ### Shared core + thin chain adapters The logic the two pages used to duplicate — the bridge protocol (`fetchPendingRequest` / `completeSuccess` / `completeError`), the app state machine + view switching, `rejectWith` / address-matching, the settled-result delivery, and the **error contract** (show in-page + retry, propagate only on explicit Reject/Cancel) — lives once in [`web/app-core.js`](web/app-core.js). The core crate embeds it with `include_str!` and serves it at `GET /app-core.js` from `build_router` (so every bridge — CLI, `serve`, and the e2e harnesses — exposes it). Both pages load it via `