feat: browser-extension wallet approval via loopback signing bridge - #197
Conversation
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
left a comment
There was a problem hiding this comment.
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:
- enqueue
eth_sendTransaction; - GET
/signer/requestsso the page claims it; - wait
SIGNER_LIVENESS_WINDOW + 10swithout bye, simulating a live page awaiting the wallet; - 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:67—connectdoes not satisfy wagmi's genericwithCapabilitiesreturn contract;nuxi typecheckfails with TS2322.src-tauri/src/wallet_bridge.rs:900—cargo fmt --checkfails on the single-line assertion.
Verified
npm test -- --run— 58/58 passed.cargo test --manifest-path src-tauri/Cargo.toml wallet_bridge --lib --no-fail-fast— 7/7 passed with the CI-style local sidecar stub.- GitHub
rust (build)andrust (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.
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>
|
@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 Watchdog defect → fixed in Your required invariant is a permanent regression test, TS2322 → fixed in cargo fmt → clean (folded into 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 |
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 to127.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, exactHostallowlist (DNS-rebinding defense), loopbackOriginrequired 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.tsand the payment flow are unchanged.Lifecycle safety
Closing the signing tab cannot strand a payment:
bridge_stop/ bye unblock all in-flight requests immediately instead of waiting out timeouts.Also in this PR
STATUS_ENTRYPOINT_NOT_FOUND): rfd/muda'sTaskDialogIndirectimport is comctl32-v6-only and tauri-build's manifest only reaches bin targets. Fixed by delay-loading comctl32 inbuild.rs.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
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)
bridge_status🤖 Generated with Claude Code