Skip to content

feat(evm): share browser payment APIs and durable payment recovery - #17

Merged
jacderida merged 10 commits into
mainfrom
web-support
Sep 22, 2026
Merged

jacderida merged 10 commits into
mainfrom
web-support

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Allows browser clients to use the same EVM payment types, quote hashing, pricing, Merkle algorithms, and vault encodings as native clients. The portable layer excludes native process and environment facilities; optional RPC uses browser Fetch and timers, while the default native feature retains Anvil and native wallet support.

Merkle checkpoints preserve the original salts and random padding so recovery reconstructs the same payment intent. Native wallets can prepare and sign a payment without broadcasting, persist its exact transaction bytes, then submit or observe that same transaction after interruption. Journal validation binds the transaction to its wallet, chain, vault, and intended calldata.

Recovery distinguishes pending transactions, unfinalized failures, finalized reverts, and finalized nonce replacements. A same-intent fee replacement returns its successful receipt and settlement hash; a different finalized nonce consumer permits a fresh attempt. Missing history, RPC errors, and unexplained nonce advances retain the journal. Failed-payment retry decisions use canonical receipts and the RPC finalized tag; successful payments keep the existing optimistic canonical-inclusion semantics. See PAYMENT_RECOVERY.md for finality and historical-RPC requirements.

Native-only tests are feature-gated, and CI covers both portable and RPC-only test configurations.

Linear issue

Closes V2-803 — Linear issue

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Proposed for human review: changes payment submission and recovery behavior.

Compatibility

  • Wire: Existing quote hashes, vault ABI, payment proofs, and native serialization remain shared and unchanged. External signers reuse the native Merkle call encoder and event decoder.
  • Storage: Adds serializable Merkle checkpoints preserving salts and random padding; no on-chain storage change. Native signed-payment journals retain exact transaction bytes.
  • API: Default native wallet APIs remain available. Adds portable/RPC feature selection, shared encoding helpers, Merkle checkpoint serialization, and native prepare/broadcast/observe payment APIs.

Consumers of the draft journal API must handle PaymentStatus::Finalizing and PaymentStatus::Replaced. The stored SignedPayment representation is unchanged.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

Validated on the current revision during this refresh:

  • cargo test --test wallet journaled: 7 passed, covering canonical success, Merkle winner recovery, reverted transactions across a reorg, finality before retry, finalized nonce replacement, successful fee replacement, and unexplained nonce consumption.
  • cargo test --no-default-features: 10 tests passed; native-only integration targets are gated out.
  • cargo test --lib --test cryptography --no-default-features --features rpc,external-signer: 19 passed.
  • cargo clippy --all-targets --all-features -- -D warnings: passed.
  • cargo check --lib --no-default-features --features rpc,external-signer --target wasm32-unknown-unknown: passed.

The coordinated ant-client recovery suite also exercises the native caller's handling of finalized replacements. Local results do not replace CI or production RPC validation.

New dependency

Adds web-time for portable clocks and WASM-target gloo-timers, futures-util, and JavaScript-enabled getrandom. Existing Alloy and Tokio dependencies use explicit feature gates. No new standalone crate or parallel browser payment implementation is introduced.

ADR

ant-client ADR-0004: Direct browser immutable-data client documents shared payment preparation, journals, and recovery across native/browser adapters. Saorsa Core ADR-019 covers the shared portable dependency architecture.

Mitigation / rollback

Keep consumers on the default native feature or revert the coordinated client/EVM revisions together. Preserve unresolved transaction journals; a missing receipt is not proof that another payment is safe.

Coordinated consumers

Current stack revisions

All PRs link to V2-803. Immutable Git revisions used by the validated stack:

PR Revision
saorsa-pqc#7 29a2b272
evmlib#17 cf424c04
saorsa-transport#160 6f0b1f62
saorsa-core#158 0df7853e
ant-protocol#29 cdae7d19
ant-node#220 26f5fb79
ant-client#186 f1303e1a

EVM recovery now retains ambiguous journals until finalized failure/replacement evidence is available. The protocol and native client include the matching consumer changes. Existing browser wire and payment formats are retained.

Measured on DEV-03 run 589 (2026-09-16) against sepolia-rollup.arbitrum.io:
26 of 938 native uploads failed at the payment step, none of them for a
network reason, all in the journal path introduced by a5f3d61:

- `observe_payment` reported `Finalizing` when a `latest` nonce read was
  higher than the nonce just signed from a `pending` read. The two reads are
  separate HTTP requests and the endpoint's replicas lag each other
  (reproduced: `pending=98` then `latest=99`, 1 pair in 150), and finality
  on Arbitrum Sepolia trails by ~28 nonces, so every stale read became
  "awaiting chain finality" on bytes that were never broadcast.
- Every read in `prepare_payment`/`observe_payment` and the broadcast itself
  were single-shot, where the legacy `send_transaction_with_retries` path
  retried three times. One `429` or `-32000 context deadline exceeded` on
  any of ~9 calls per wave failed the upload, after earlier waves had paid.

Now:

- `prepare_payment` takes the higher of a `pending` and a `latest` read, so
  one lagging replica cannot sign a consumed nonce, and sets the chain id
  explicitly so `fill` makes no RPC call.
- Reads go through `retry()`. Reads made while a caller polls use a 500ms
  base (the native client observes inside a 30s window).
- A consumed-but-unfinalised nonce with no receipt reads as `Pending`, not
  `Finalizing`: it is indistinguishable from a stale read, re-sending the
  journaled bytes is refused by the node rather than paid twice, and
  finality still resolves a real replacement into `Replaced`. `Finalizing`
  is kept for a mined revert that is not yet final.
- `broadcast_payment` retries the same bytes on transport-level failures
  (HTTP status, connection loss, timeouts, backend-deadline responses) and
  treats "already known" as success. Definitive rejections return at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@grumbach grumbach 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 at ccd65f18

Verdict: changes requested. Two small fixes, plus some hardening. The journal design holds up well, and the fix for the "Finalizing on a payment that was never sent" failure from staging run 589 is right: a nonce that looks consumed with no finalized evidence now returns Pending instead of failing the upload.

Also verified: the journal binds the signed transaction to the wallet (recovered signer), chain id, vault, zero value and the exact calldata, and the quote-hash and Merkle-calldata refactors produce the same bytes as main.

Must fix

  1. The gas-fee read is still single-shot. prepare_payment calls crate::retry::get_eip1559_fees directly (src/wallet/journal.rs:214), and that function has no retry (src/retry.rs:384). The chain-id, gas-estimate and nonce reads around it now go through the new rpc(...) wrapper, but this one does not. Staging run 591 (at this exact revision) lost 2 of 3,087 native uploads to Could not get current gas price (one HTTP 429, one -32000 context deadline exceeded), both before payment. Main's legacy path retried fee fetches inside send_transaction_with_retries, so this is a regression for CLI users. Fix: wrap the call in rpc("fee estimate", None, ...).
  2. Clippy job is red on clippy::result_large_err at src/wallet.rs:151 and :391. Those signatures and error types are unchanged from main (the lines only moved), so this looks like a newer-toolchain lint that main would also hit. It still blocks the merge check. Fix: box the error or allow the lint on those two functions.

Hardening (low likelihood, but worth doing before claiming a payment can never be made twice)

  • observe_payment returns Replaced whenever a finalized transaction with different calldata consumed the nonce (src/wallet/journal.rs:416-425), without checking whether that transaction paid some of the same quote hashes. Only reachable if the same wallet pays overlapping quote sets in two different transactions, but decoding the replacement's quote hashes first would close it.
  • A failed receipt is classified Reverted by comparing its block number with the finalized height (plus a canonical lookup at that number), not by checking that the receipt's block is an ancestor of the exact finalized block hash. A load-balanced RPC mixing replicas could in principle produce a false Reverted. Unlikely on Arbitrum.
  • Restoring a Merkle checkpoint recomputes the root from the stored leaves without comparing it to the root that was paid for (src/merkle_payments/merkle_tree.rs:84-106). Storing and checking the expected root would catch corrupted leaves early; the salts are not part of the root, so they would need their own check.

Tested locally

  • cargo test --test wallet journaled (with Anvil): 7 passed.
  • cargo test --no-default-features: 10 passed.
  • cargo check --lib --no-default-features --features rpc,external-signer --target wasm32-unknown-unknown: passes.
  • cargo clippy --all-targets --all-features -- -D warnings: fails exactly as CI does.

The consumer side of the journal (ant-client's stale-nonce retry) has a related issue; details on ant-client #186.

@jacderida

Copy link
Copy Markdown
Member

Testnet evidence — DEV-03 run 591 (2026-09-17), web-support rebased on main

Four-hour staging-scale run of the web-support branches — 990 nodes + 7 bootstraps (nat_percent: 0, one node VM clock-skewed +3 h), a native client tier (7 uploaders, 1 downloader) and a WASM/browser-path client tier (3 uploaders, 1 downloader; Node.js + node-datachannel driving ant-core compiled to WASM over WebRTC-Direct, bootstrapping from the 7 bootstrap multiaddrs and nothing else). Builds: ant-node 41acee71 (crate 0.19.0), ant-client 9b85491b (crate 0.3.7; native ant and the WASM bundle from the same head); evmlib ccd65f18, saorsa-transport 3ed66a9c. Full write-up with all 21 evidence items: V2-1270.

This is the first run of this series where the node fleet stayed the build it was deployed as (the previous run's 0.18.1-identified nodes auto-upgraded mid-run to a stable without the WebRTC listener): 997/997 services logged No upgrade available at every check, 0 New version detected, 0 service restarts, 0 replaced binaries, 7/7 bootstraps live at the end.

The result that matters: sustained, flat throughput over the whole run, on both transports

Successful / attempted transfers per 30-minute slot from T0 (measurement window = slots 3–8):

30-min slot from T0 (UTC) native uploads WASM uploads native downloads WASM downloads
T0 → +30m (19:16) 356/356 (29.8 GiB) 12/13 (1.4 GiB) 42/42 (9.8 GiB) 13/13 (0.5 GiB)
+30m → +60m 371/371 (30.9 GiB) 16/16 (2.6 GiB) 48/48 (8.9 GiB) 15/15 (0.5 GiB)
+60m → +90m window 385/385 (32.3 GiB) 16/16 (2.0 GiB) 42/42 (12.8 GiB) 7/7 (0.3 GiB)
+90m → +120m window 392/392 (33.4 GiB) 16/16 (2.6 GiB) 50/50 (10.3 GiB) 11/11 (1.1 GiB)
+120m → +150m window 391/391 (32.4 GiB) 16/16 (2.6 GiB) 48/48 (10.7 GiB) 14/14 (0.5 GiB)
+150m → +180m window 388/388 (32.3 GiB) 18/18 (2.6 GiB) 49/49 (11.2 GiB) 11/11 (0.6 GiB)
+180m → +210m window 388/389 (32.7 GiB) 16/16 (1.7 GiB) 53/53 (9.3 GiB) 11/11 (0.6 GiB)
+210m → +240m window 392/393 (33.8 GiB) 17/17 (2.6 GiB) 50/50 (9.9 GiB) 15/15 (0.6 GiB)

No decay in rate or success on either transport from the first half-hour to the last. Measurement-window totals: native uploads 2336/2338 (99.91%), native downloads 292/292, WASM uploads 99/99, WASM downloads 69/69. Whole run (incl. warm-up): native 3085/3087 up / 386/386 down; WASM 127/128 up / 98/98 down. WASM covered 20 MB, 300 MB (single payment) and 900 MB (merkle) uploads, and downloads up to 500 MB, all paid through the JS callback (approve + payForQuotes) with the payment network taken from the node's HELLO.

Durations (ok ops, window): native upload p50 24.4 s / p95 126.5 s, WASM upload p50 146.1 s / p95 1021.8 s; native download p50 17.2 s / p95 72.2 s, WASM download p50 110.8 s / p95 236.3 s. Size-matched 20 MB: upload 16.8 s native vs 137.5 s WASM, download 15.5 s vs 96.6 s — the browser path is slower, as expected, but consistently so.

Failures, all three of them (out of 3,699 transfers)

  • WASM upload fix: use random port for Anvil instead of hardcoded 61611 #1 at T0 on the 20 MB uploader: Witnessed close group inconclusive before payment: got 0/1 quorum-recognised peers — first-op-after-start transient, pre-payment, nothing spent; ci: add PR checks and release workflows #2 succeeded three minutes later and nothing recurred in 127 further uploads.
  • Native sgp/50 MB #336 (22:19Z): witnessed close group initial lookup found 6 peers, need 7 — DHT transient, pre-payment.
  • Native sjc/100 MB #418 (23:06Z): payment error: Could not get current gas price: HTTP error 429 from the public Arbitrum Sepolia RPC, pre-payment, nothing spent, next upload fine.

That last one (plus one identical -32000 context deadline exceeded on the same call five minutes after the window) technically fails the run's zero-tolerance "no RPC-transport payment failure" criterion, so the issue is scored 10/11. It is not a significant finding: 2 in 3,087 native uploads (0.065%), never post-payment, never money lost, and the cause is narrow — get_gas_price is the one read in the payment prepare path that evmlib ccd65f18 did not put behind retry() (estimate_gas, get_transaction_count, get_chain_id, get_transaction_receipt, get_block_by_number are). Zero evmlib::retry warnings appeared anywhere in the run, i.e. the public RPC was otherwise quiet. A one-line retry wrap closes it; it says nothing about the transport, the node, or the WASM path.

Also clean: 0 clock-skew signatures on either tier against the +3 h node (unsubmitted payment quotes expired / paid proof expired), 0 awaiting chain finality, 0 partial uploads, 0 not within its local closest peers, 0 invalid public file size (the WASM downloader skipped 111 over-cap entries by design), 0 OOM (WASM downloader peak 2.45 GB on the 500 MB pick), and a fleet-wide sweep of 1,009 ant-* units on 85 hosts found no panic, restart or replaced binary.

Node tier under this load: CPU mean 23.5% per host, per-service RSS p50 262 MB / p95 283 MB; steady-state 35.4 GB egress per GB uploaded, 8.7 GB disk per GB stored — in line with the previous (contaminated) run and not split by transport, since one fleet served both.

@jacderida
jacderida marked this pull request as ready for review September 21, 2026 23:29
The "Enforce Clippy constraints" job started failing on PR #17 with `clippy::result_large_err` for
both `pay_for_quotes` functions in `wallet.rs`. Nothing in the PR touched the error type: CI tracks
`stable`, which is now Rust 1.98, and its clippy flags `PayForQuotesError` where 1.97 did not.

`wallet::Error` is exactly 128 bytes, right at the lint threshold, so it passes on its own. Wrapping
it together with the `BTreeMap` of succeeded payments pushes `PayForQuotesError` to 160 bytes.
Boxing the inner error brings the struct down to 32 bytes.

This is preferred over an `#[allow]` because every downstream consumer in `ant-protocol` and
`ant-client` destructures the error as `PayForQuotesError(err, _)` and only formats `err` via
`Display`, which works unchanged through `Box`.

Verified on Rust 1.98.1:
- `RUSTFLAGS="-D warnings" cargo clippy --all-targets --all-features`: clean
- `cargo check --lib --no-default-features --features rpc,external-signer --target wasm32-unknown-unknown`: clean
- `cargo fmt --check`: clean
- `cargo test --lib --all-features`: 21 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jacderida
jacderida merged commit d134b36 into main Sep 22, 2026
12 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.

3 participants