Skip to content

feat: browser-extension wallet approval via loopback signing bridge - #197

Merged
Nic-dorman merged 6 commits into
mainfrom
nic/browser-wallet-bridge
Aug 4, 2026
Merged

feat: browser-extension wallet approval via loopback signing bridge#197
Nic-dorman merged 6 commits into
mainfrom
nic/browser-wallet-bridge

Conversation

@Nic-dorman

Copy link
Copy Markdown
Member

Summary

Adds a third way to approve upload payments alongside the WalletConnect QR and mobile flows: a browser-extension wallet (MetaMask, Rabby, …) on the same computer. Extensions can't run inside the Tauri webview, so the app runs a loopback signing bridge (Truffle-Dashboard pattern):

  • src-tauri/src/wallet_bridge.rs — axum server bound to 127.0.0.1:17423 (stable port, so the wallet's per-site approval survives across sessions), serving a single-file signing page plus two relay endpoints. Hardening: per-session 256-bit bearer token carried in the URL fragment (never on the wire, no Referer leakage), constant-time token comparison, exact Host allowlist (DNS-rebinding defense), loopback Origin required on state-changing POSTs, no CORS ever emitted, strict page CSP.
  • src-tauri/bridge-page/index.html — EIP-6963 wallet picker + long-poll relay: forwards EIP-1193 requests to the injected provider and posts answers back. Remembers the chosen wallet, warns before closing mid-approval.
  • utils/wallet-bridge-connector.ts — wagmi connector rendered as a "Browser extension" card in the existing connect modal; relays every wallet request over Tauri IPC to the bridge. utils/payment.ts and the payment flow are unchanged.

Lifecycle safety

Closing the signing tab cannot strand a payment:

  • a request arriving with no live page reopens the page automatically (debounced, retried by a session watchdog — survives a dropped tab-close beacon and browser tab-sleep);
  • requests the page had already handed to the wallet fail fast with a "check your wallet before retrying" error and are never re-sent (the wallet may still execute the first copy — resending could double-pay);
  • bridge_stop / bye unblock all in-flight requests immediately instead of waiting out timeouts.

Also in this PR

  • Windows test executables died at load (STATUS_ENTRYPOINT_NOT_FOUND): rfd/muda's TaskDialogIndirect import is comctl32-v6-only and tauri-build's manifest only reaches bin targets. Fixed by delay-loading comctl32 in build.rs.
  • Payment errors now render viem's one-line shortMessage (rejections: "Cancelled in your wallet") instead of the full multi-line diagnostic dump, which overflowed the file row and toast. Full dump still goes to the devtools console.

Testing

  • Rust 13/13 — 7 wallet_bridge tests: token uniqueness, constant-time compare, Host/Origin/token guards, request/response roundtrip, stop-unblocks-inflight, bye fail-fast for claimed and queued requests.
  • Vitest 58/58 — includes 8 connector tests.
  • Manual on Windows against the live network:
    • connect → default browser opens the signing page, extension connect prompt fires
    • pay with the page open → approval appears straight in the extension
    • pay with the page closed → page reopens itself, extension approval window opens
    • reject in the wallet → one-line "Cancelled in your wallet" error

Size cost measured earlier on release builds: +1.25 MB exe (axum/tower stack), est. ~+0.4–0.5 MB on the MSI; frontend ~2 KB gz.

Follow-ups (not in this PR)

  • "Reopen approval page" affordance + paying-badge copy driven by bridge_status
  • Cancel-during-payment UX (safe for queued requests; warn for claimed)
  • i18n for the signing page / new error strings
  • macOS/Linux manual verification (bridge is platform-neutral but only Windows was tested end-to-end)

🤖 Generated with Claude Code

Nic-dorman and others added 4 commits August 3, 2026 18:14
Extension wallets (MetaMask, Rabby) cannot run inside the Tauri webview,
so payment approval has been WalletConnect-mobile only. This adds a
Truffle-Dashboard-style bridge that meets the extension where it lives:
the app opens a signing page in the system default browser and relays
EIP-1193 requests to the wallet injected there.

- Rust wallet_bridge module: axum server bound to 127.0.0.1 (stable port
  17423, falls forward if occupied) serving the signing page plus two
  relay endpoints; per-session 256-bit token carried in the URL fragment,
  constant-time compare, Host and Origin allowlists, no CORS; commands
  bridge_start / bridge_request / bridge_status / bridge_stop.
- Signing page (served same-origin from the loopback listener): EIP-6963
  wallet picker with remembered choice, long-poll relay loop forwarding
  requests to the injected provider, liveness heartbeat, close signal.
- browserBridge wagmi connector routing wallet requests over Tauri IPC
  to the bridge; registered on the WagmiAdapter so AppKit renders a
  "Browser extension" card in the connect modal. Payment logic in
  utils/payment.ts is untouched - the bridge is just another connector,
  and public reads keep flowing through the per-chain HTTP transports.

Tests: 5 Rust (request/response roundtrip over a real listener, token/
Host/Origin rejection matrix, teardown unblocks in-flight requests) and
8 Vitest (connect flow, EIP-1193 error-code preservation, switch-chain
4902 fallback, teardown).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The signing page's death used to strand payments: a request queued with
no page consuming it hung until the flow timeout, and a request the page
had already forwarded to the wallet could still be approved from the
extension panel with no way to deliver the answer back to the app.

- reopen the signing page (debounced) when a request arrives with no
  live page, and keep retrying from a session watchdog — a dropped bye
  beacon or the debounce window can no longer strand a queued request
- fail requests a dead page had claimed immediately (bye or liveness
  lapse) with a distinct "check your wallet before retrying" error;
  claimed requests are never re-queued, since the wallet may still
  execute the first copy and a resend could double-pay
- clear the reopen debounce on bye so paying right after closing the
  tab reopens the page instead of waiting out the window
- warn before unload while a request is with the wallet (best effort)
- page copy: the tab may stay in the background; the app reopens it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tauri-build embeds its common-controls v6 manifest into bin targets
only. Test binaries link the same rfd/muda code, whose comctl32
TaskDialogIndirect import exists only in v6 — with no manifest they
bind comctl32 v5 at load and die with STATUS_ENTRYPOINT_NOT_FOUND
before running a single test (whether the import survives linker
dead-stripping is whim, so this surfaced out of nowhere). Delay-loading
defers the bind to the first actual call: tests never make one, and the
app resolves v6 through its manifest exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rejected transaction rendered viem's full multi-line error — request
arguments, raw calldata, docs link, library version — in the file row
and toast, overflowing both. Use the error chain's shortMessage,
special-case EIP-1193 4001 rejections as "Cancelled in your wallet"
(the code survives WalletConnect and the browser bridge alike), and
keep the full dump in the devtools console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — hold before merge

Reviewed exact head a15b6dc94223b90d7ebdff6b7e602e8169a80387.

Material lifecycle blocker: normal slow approvals are treated as a dead page

signer_requests stamps last_signer_poll and hands the request to the page (src-tauri/src/wallet_bridge.rs:377-390). The page then waits inside active.provider.request(...) (src-tauri/bridge-page/index.html:284-302) and does not issue another long-poll until the wallet answers.

After 35 seconds without another poll, the five-second watchdog calls fail_claimed (src-tauri/src/wallet_bridge.rs:522-526). That therefore fires during an ordinary human-paced approval, not only when the page has died. The app receives code 4900 and marks the payment failed, while the original wallet prompt remains live. If the user approves afterwards, /signer/response gets 410 because the pending slot was removed; retrying from the failed row can submit another payment although the first transaction may have landed.

I reproduced this on the exact head with a temporary regression test:

  1. enqueue eth_sendTransaction;
  2. GET /signer/requests so the page claims it;
  3. wait SIGNER_LIVENESS_WINDOW + 10s without bye, simulating a live page awaiting the wallet;
  4. assert the waiter is still pending.

Result: failed after 45.03s — the waiter had already been terminated by the watchdog. The temporary test was removed and the worktree returned clean.

Required invariant: a claimed request must not be failed solely because the request-poll timestamp is stale while the page is awaiting provider.request. Use a liveness signal independent of the single poll loop, or let the existing claimed-request/RPC deadline govern this state. Please add a regression test that waits beyond the liveness window and then proves the response is still accepted with 204 and delivered to the app.

Current CI blockers

  • utils/wallet-bridge-connector.ts:67connect does not satisfy wagmi's generic withCapabilities return contract; nuxi typecheck fails with TS2322.
  • src-tauri/src/wallet_bridge.rs:900cargo fmt --check fails on the single-line assertion.

Verified

  • npm test -- --run58/58 passed.
  • cargo test --manifest-path src-tauri/Cargo.toml wallet_bridge --lib --no-fail-fast7/7 passed with the CI-style local sidecar stub.
  • GitHub rust (build) and rust (clippy) — passed.
  • Loopback bind, token, Host/Origin, no-CORS and late-response controls otherwise look sound; no standalone security blocker found.

Please hold merge until the watchdog defect and the two red CI gates are fixed.

Nic-dorman and others added 2 commits August 4, 2026 18:50
Review catch: the request long-poll was the only liveness signal, but it
blocks inside provider.request for as long as a human ponders a wallet
prompt — after 35 quiet seconds the watchdog failed the claimed request
with "check your wallet" while the prompt was still live, and a later
approval broadcast a transaction whose answer got 410. Exactly the
double-pay setup the watchdog exists to prevent.

The page now keeps a dedicated /signer/ping parked at the server at all
times (stamped on entry; pure awaited fetch, so background-tab timer
throttling cannot starve it). Liveness = polls OR pings; the watchdog
only fails claimed requests when both loops are gone — a real death.
The ping park races session shutdown so stopped sessions don't linger.

Timing knobs (SessionTuning) are injectable so the regression tests run
in milliseconds: one proves an approval outliving the liveness window is
still accepted with 204 and delivered, the other proves a page dying
without a bye still fails the claim promptly. Includes the cargo fmt
pass CI flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nuxi typecheck failed with TS2322: wagmi's createConnector types
connect() with a conditional return keyed on the withCapabilities
generic. Accept the parameter and return address+capabilities records
when it is set, with the same cast wagmi's own connectors use.

Also exclude src-tauri/target and .claude from vue-tsc's sweep — local
release builds leave binary tauri-codegen assets named *.js that drown
typecheck output (CI never sees them; this makes local runs match).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Nic-dorman

Copy link
Copy Markdown
Member Author

@dirvine Thanks — the watchdog finding was exactly right, and reproducing it against a live page past the window made the failure mode unambiguous. All three items are addressed as of fd4c685:

Watchdog defect → fixed in 4678339 with a dedicated liveness channel. The request long-poll can't double as the liveness signal — it blocks inside provider.request for as long as a human ponders the prompt. The page now keeps one GET /signer/ping parked at the server at all times (stamped on entry; park paced at 20s, comfortably inside the 35s window; a pure awaited fetch, so background-tab timer throttling can't starve it; the park races session shutdown so a stopped session doesn't linger on open handlers). Liveness is now polls or pings: the watchdog only fails claimed requests when both loops have gone quiet — an actual page death (crash, tab-sleep) — and the explicit bye path is unchanged.

Your required invariant is a permanent regression test, slow_wallet_approval_survives_liveness_window: claim the request → request loop silent for ~3 liveness windows while pings continue → assert the waiter is still pending → POST the wallet's answer → assert 204 and the result delivered to the app. Timing knobs are injectable (SessionTuning), so it runs in ~1.5s instead of 45. The counterpart watchdog_fails_claimed_request_when_pings_stop pins the prompt fail-fast when pings stop too.

TS2322 → fixed in fd4c685. connect() now honors wagmi's withCapabilities conditional return (address + capabilities records when set), using the same cast wagmi's own connectors employ. nuxi typecheck exits 0. (Same commit also excludes src-tauri/target from vue-tsc's sweep — local release builds leave binary codegen assets named *.js that drowned local typecheck output; CI never sees them.)

cargo fmt → clean (folded into 4678339).

Local state on the new head: Rust 15/15 (9 wallet_bridge tests including the two new liveness ones), Vitest 58/58, typecheck clean. CI is re-running now.

🤖 Generated with Claude Code

@Nic-dorman
Nic-dorman merged commit e30685d into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants