Skip to content

fix(send): hydrate numPubkeys from server-authoritative num_sends - #127

Merged
TaprootFreak merged 1 commit into
developfrom
fix/sync-num-pubkeys-from-balance
May 29, 2026
Merged

TaprootFreak merged 1 commit into
developfrom
fix/sync-num-pubkeys-from-balance

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

Summary

Re-opens PR #125 (closed in error). Resolves the persistent App-side Bug #2 (07-send-success E2E failure with "Beweisgenerierung fehlgeschlagen" = server 500 "prove failed").

Why PR #125 was closed

A prior closing comment claimed PR #125 was superseded by zk-coins/node#132 (server-side commitment_public_key store). That reasoning was wrong:

  • Node feat(unlock): allow wallet reset from the unlock screen #132 only stopped requiring the legacy prev_commitment_pubkey request field
  • It did NOT (and cannot) fix the in-circuit constraint at `program-plonky2/src/circuit/main.rs:615-623` that pins the request's `public_key` to the previous proof's `next_public_key`
  • If the App signs with `pk(0)` on a wallet whose server-side proof already references `pk(1)` as the next pubkey, Plonky2 rejects the witness → 500 `"prove failed"`

Root cause (verified via DEV request_log)

The deployed App's bundle reads `numPubkeys` from the local Zustand wallet store, which resets to `0` on every fresh page load and Playwright retry. After Alice's first successful send (server-side `num_sends → 1`), the next E2E retry re-signs with `pk(0)` instead of `pk(1)`. DEV `request_log` row 17952 shows the exact wire signature — `public_key=pk(0)`, `next_public_key=pk(1)` — which is precisely backwards from what the in-circuit constraint requires.

The api_remote test wallet (`TestWallet::sign_send_at(... idx)`) threads the index explicitly, so it never hits this fault — that's why `second_send_succeeds_without_prev_commitment_pubkey_field` is green on DEV but `07-send.spec.ts::send-success` is red.

What this PR does

Hydrate the BIP-32 child-index counter from the server's authoritative `num_sends` immediately before signing each send:

```ts
const preSend = await api.balance(account.address);
setBalance(preSend.balance);
syncNumPubkeys(preSend.num_sends);
const effectiveNumPubkeys = preSend.num_sends;
const keys = wasm.derivePublicKeys(account.xpriv, effectiveNumPubkeys);
const prevPk = effectiveNumPubkeys > 0
? wasm.derivePublicKeys(account.xpriv, effectiveNumPubkeys - 1).publicKey
: undefined;
const res = await api.sendSigned({...}, account.xpriv, effectiveNumPubkeys);
```

Also: WalletScreen's 5 s balance poll syncs `num_sends` so subsequent ticks keep the local store correct; Onboarding's seed/passkey restore syncs immediately after the first balance fetch.

Files changed

  • `src/lib/api/schemas.ts`: `BalanceResponseSchema.num_sends: z.number().default(0)`
  • `src/stores/wallet.ts`: `syncNumPubkeys(n)` action, replace-semantics, no-op when already synced
  • `src/components/screens/WalletScreen.tsx`: 5 s-poll calls `syncNumPubkeys`; faucet path too
  • `src/components/onboarding/Onboarding.tsx`: seed-restore + passkey-restore sync after `api.balance`
  • `src/app/send/page.tsx`: pre-send `api.balance` + `effectiveNumPubkeys` for signing/derive/commitment
  • 7 test files updated for the new `num_sends` field across response shapes

Test plan

The wallet tracks the BIP-32 child-index counter (`numPubkeys`)
purely in local state. After a seed restore the counter is reset
to 0 — but the server may still hold `account.proof = Some(...)`
from a previous session for the same address. The next /send then
either omits `prev_commitment_pubkey` (server 400
`"prev_commitment_pubkey required for account update"`, mapped
client-side to `Interner Fehler: Vorheriger Public Key fehlt.`)
or reuses pubkey[0] and collides on the same SMT slot at commit
time. The E2E `07-send.spec.ts::send-success` test exposed this
on every retry after the first global-setup minted balance was
spent.

This is the app side of zk-coins/node PR #129, which adds the
authoritative `Account.num_sends` counter and emits it on
`/api/balance`. The app:

* extends `BalanceResponseSchema` with `num_sends: z.number().default(0)`;
* adds `syncNumPubkeys(n)` to the wallet store — a no-op fast-path
  when the counter is already correct, replace semantics otherwise;
* drives a fresh `api.balance` BEFORE every send (SendPage) so the
  counter is hydrated against the server's source of truth even
  if the WalletScreen 5-s poll hasn't ticked yet;
* hydrates `numPubkeys` on every seed/passkey restore flow;
* re-syncs on every WalletScreen balance tick + after the post-send
  /api/balance refresh.

Tests:
* `wallet.test.ts` — `syncNumPubkeys` replace semantics + no-op
  shortcut + multi-account safety.
* `send-pipeline.test.tsx` — pre-send balance hydration is mocked
  in every send-confirm path; the existing `prev_commitment_pubkey`
  + `numPubkeys` assertions still hold.

App-PR depends on node-PR being merged + DEV-deployed first —
otherwise the wallet reads `undefined` for `num_sends` against the
old server. The schema's `.default(0)` keeps the fallback safe
(local counter behaviour preserved).
@github-actions

Copy link
Copy Markdown
Contributor

[OK] Button-Inventory-Audit — all clear

Checked 71 testid(s) in src/ against 69 reference(s) in e2e/. Nothing to do.

@TaprootFreak
TaprootFreak merged commit 333c408 into develop May 29, 2026
24 of 28 checks passed
TaprootFreak added a commit that referenced this pull request May 29, 2026
…atency

The send-flow now does an extra `/api/balance` round-trip before
signing (`num_sends` hydration per the thin-client rule in
CONTRIBUTING.md), and the matching server-side writes from
zk-coins/node #129 / #132 add a few seconds of legitimate per-send
latency for the atomic `Account.num_sends` + `commitment_public_key`
upsert. Observed wall-time on the bundle PR's CI runs is 180-220 s,
which the previous 180 s test cap was treating as a deadlock.

Lift the waitFor cap from 150 s to 250 s and the overall test cap
from 180 s to 300 s. The race against the inline error banner stays
in place so a genuine server-side failure still surfaces with its
real message instead of timing out.
TaprootFreak added a commit that referenced this pull request May 29, 2026
…es + fresh Goldens (#129)

* ci(regen): add spec_glob + tolerate_failures inputs to baseline regen

Currently the workflow runs every spec under e2e/0*.spec.ts e2e/1*.spec.ts
unconditionally and the whole job aborts (set -e) the moment any spec
fails. When the only thing that needs regenerating is the wallet
header — and an unrelated spec (e.g. 07-send) is flaky on DEV — the
regen can't even reach its commit step. The fix that needs the
baselines gets stuck behind a problem it has nothing to do with.

Two new workflow_dispatch inputs:

- `spec_glob` (default unchanged) — narrows the run to specific
  files. e.g. `e2e/02-create-seed.spec.ts e2e/03-restore-seed.spec.ts
  e2e/04-unlock-password.spec.ts` only refreshes the wallet headers.
- `tolerate_failures` (default false, opt-in) — appends `|| true` to
  the playwright invocation. `--update-snapshots=all` still writes
  new PNGs for the specs that ran; the commit step inspects
  `git status` and commits only files that actually changed.
  Partial regens are safe.

Defaults preserve current behaviour — full suite, fail-fast — so
existing routine calls keep working.

* fix(errorMessages): family pattern matching for diagnostic node error strings

The node API emits diagnostic, field-naming error strings
(e.g. "account_address is not valid hex",
"recipient must be 32 bytes (64 hex chars)",
"Failed to broadcast commitment inscription on-chain") that did not
match the exact-string lookup table on the app side, so users saw the
raw "Serverfehler <status>: ..." fallback instead of the translated
message.

Adds a second lookup layer SERVER_ERROR_PATTERNS (regex) that runs only
when the existing exact-match pass misses. KNOWN_SERVER_ERRORS stays
unchanged so the lockstep test with
api_remote.rs::error_strings_match_known_app_mapping keeps holding.

Patterns are ordered specific-before-generic and intentionally do not
overlap with any KNOWN_SERVER_ERRORS string — exact-match always wins.

Three new test cases in error-mapping.test.ts cover one variant per
pattern, including an address-length variant with a different field
name to prove the family (not a specific field) is matched.

* fix(wallet): case-insensitive network check for faucet gating

The node reports `network` as a display string ("Mainnet", "Mutinynet",
…) — see `node::router::info_handler`. The existing check used a
case-sensitive comparison, so `"Mainnet" !== "mainnet"` evaluated true
and the faucet button rendered on Production. Lowercase the network
name before the comparison so a casing decision on the server can't
accidentally re-enable mint on Mainnet.

Add a WalletScreen unit test for both branches (Mainnet hidden,
Mutinynet visible). Update the `06-balance-zero-empty-banner` e2e
spec to reflect testnet reality (faucet button is the primary CTA
in the empty-wallet banner on Mutinynet); the Mainnet-hidden case
is covered at the unit-test layer since E2E always runs against
the testnet API.

* test(e2e): regenerate visual baselines

* test(e2e): raise send-success timeout to account for post-#127/#129 latency

The send-flow now does an extra `/api/balance` round-trip before
signing (`num_sends` hydration per the thin-client rule in
CONTRIBUTING.md), and the matching server-side writes from
zk-coins/node #129 / #132 add a few seconds of legitimate per-send
latency for the atomic `Account.num_sends` + `commitment_public_key`
upsert. Observed wall-time on the bundle PR's CI runs is 180-220 s,
which the previous 180 s test cap was treating as a deadlock.

Lift the waitFor cap from 150 s to 250 s and the overall test cap
from 180 s to 300 s. The race against the inline error banner stays
in place so a genuine server-side failure still surfaces with its
real message instead of timing out.

* test(e2e): raise send-success cap to 480 s for Mutinynet jitter + parallel-load

The 300 s cap from the previous commit was still insufficient on the
bundle PR's CI. Empirically the send-success spec lands between
180 s and ~5 min on DEV. The variance is driven by:

  * Publisher track-tx WS jitter — 30 s timeout per attempt, up to
    three attempts under load when Mutinynet block-event delivery
    stalls (see `node/src/publisher.rs::TRACK_TX_TIMEOUT_SECS`).
  * Playwright `fullyParallel: true` saturates the shared DEV node
    with mint + balance traffic from neighbour specs while
    07-send-success is its only real-send test, but everything else
    keeps hammering /api/balance, /api/info, and (via globalSetup)
    /api/mint — node mutex contention pushes the lone send past
    its happy path.

Raise the spec cap to 480 s and the waitFor cap to 420 s. Deadlock
still surfaces; well-behaved-but-slow runs no longer mask as a
regression. Follow-ups (out of scope for this PR): (a) tighten the
publisher's track-tx budget, (b) consider serializing the
mutation-heavy specs against the DEV node.

* test(e2e): harden globalSetup against cold-start navigation timeouts

The CI runner's first page.goto('/') after boot occasionally exceeds
Playwright's default 30 s navigation timeout — Cloudflare cold path,
Next.js standalone first-paint, and the WASM bundle handshake all
stack on the very first request. Subsequent navigations in the same
context are fast.

- Raise the context-wide navigation timeout to 90 s for both wallet-
  bootstrap contexts. Scoped to globalSetup only so test specs keep
  the 30 s default and a genuine app-side regression still surfaces
  as a timeout.
- Wrap each wallet bootstrap (clearWalletState + createSeedWallet) in
  a single retry on a fresh page so a transient first-paint timeout
  doesn't fail the whole CI run.
- Fix a TS strictness regression in 07-send.spec.ts where
  textContent() returns string | null and was passed directly to
  expect's message slot (string | undefined).

* test(e2e): serialize 07-send + raise send-success cap to 12 min

The 480 s cap introduced in 3abecfa was still consistently hit by the
real Send through DEV under the suite's parallel load. Both attempts
(test + retry) timed out, with the waitFor(420 s) draining before the
test cap.

Empirically the DEV Send takes 180 s to ~7 min depending on Mutinynet
block-time jitter plus neighbouring specs hitting the node. Raising
the cap is one half of the calibration; the other half is reducing
load:

-  on the file keeps the
  13 in-file tests in a single worker so two 07-send tests can no
  longer fight each other for DEV bandwidth.
-  (12 min) and the  waitFor
  budgets raised to 660 s (11 min). Setup overhead (login + balance
  hydration) lives in the remaining 60 s of headroom.

A genuine deadlock still surfaces in 11 min instead of 7. The
follow-up to tighten publisher track-tx is unchanged.

* ci(e2e): give send-success exclusive DEV-node bandwidth via two-step job

Three consecutive E2E runs failed on `07-send-success` at progressively
larger test-timeout caps (180 s -> 300 s -> 480 s -> 720 s). Every time
the DEV node's single proof-gen pipeline was starved by parallel mint
and balance traffic from the rest of the suite (09 / 10 / 11), pushing
the real Send wall-clock above the cap.

Increasing the cap further is the wrong direction. Structural fix:
split the existing E2E step in two.

  Step 1: `npx playwright test --grep-invert "send-success"` -- the
  parallel-safe bulk of the suite (75 specs) running with the default
  fullyParallel + 2 workers.

  Step 2: `npx playwright test --grep "send-success" --workers=1` --
  the one mutating Send test with exclusive DEV-node bandwidth, no
  competing proof-gen / balance traffic from neighbouring specs.

Side fixes:
- Drop `test.describe.configure({ mode: 'serial' })` from the spec.
  Serial mode caused a cascade-retry failure where send-no-funds-banner
  re-ran on the group retry and failed for an unrelated reason.
- Lower send-success cap back to 6 min (test) + 5 min (waitFor) since
  exclusive bandwidth drops the realistic upper bound to ~3-4 min.

globalSetup runs once per playwright invocation, so each step gets a
clean fresh-Alice + fresh-Bob fixture set independent of the other.

* fix(e2e): guard error.textContent() behind race-winner check

ROOT CAUSE of all `07-send-success` failures since PR #104.

The test races the success heading against the inline error banner via
`Promise.race`, then resolves both:

  await error.textContent().catch(() => null)

The catch handles a *rejected* promise, but Playwright's
`Locator.textContent()` does not reject when no element matches —
it WAITS for the element to appear, blocking up to the test-timeout.
On the success page (the happy path) `send-error` is not in the DOM,
so textContent never resolves and the assertion at line 216 sat
spinning until the 360 s test cap fired, even though the success
heading had rendered minutes earlier.

Failure-screenshot evidence from run 26651174055: the page clearly
shows "Sent privately" with proof #183 / #184 in both attempt 1 and
the retry. The Send succeeded server-side; only the post-race
assertion blocked.

Fix: tag which branch of the race won, then act only on that branch.
The error branch resolves textContent (which is safe because the
locator just became visible). The heading branch skips the error
check entirely. This was the intent of the race-against-error pattern
from the start — the implementation just resolved the error too
eagerly.

Side-effect: with this fix the previous timeout-budget escalations
(180 s -> 300 s -> 480 s -> 720 s -> 360 s) were all chasing the
wrong cause. The 360 s cap stays — it's a reasonable upper bound
for a real DEV-node Send, but the test now resolves as soon as
the success heading appears.

* test(e2e): refresh 07-send goldens for post-textContent-fix flow

The 07-send-success golden was generated against the broken textContent
hang (test never reached the snap() call, so no PNG was ever captured).
With the race-winner guard in a15cc24 the test now actually takes a
fresh screenshot of the success page and compares it to the on-branch
golden, which had drifted with the post-2026-05-23 header/balance
layout pass.

Includes three other 07-send goldens that the same regen run picked
up (confirm-dialog desktop/mobile + recipient-valid-hex) — the
maxDiffPixelRatio: 0.01 tolerance had been masking the same drift
under the threshold; refreshing them now keeps the diffs at 0 px
for any future regression that crosses the threshold.

Regen run: 26652625153 against chore/all-app-fixes-bundle.

* fix: address subagent review findings

MAJOR (security):
- regenerate-visual-baselines.yml — command injection via
  `${{ inputs.spec_glob }}` interpolated into a shell string and
  `eval`'d. Replaced with env-var passthrough + array-form CMD,
  no eval. Glob expansion still works ($SPEC_GLOB unquoted), but
  user-controlled metachars are now data, not parser tokens.
  CWE-78.

MINOR:
- errorMessages.ts — doc claim "patterns must NOT overlap with
  KNOWN_SERVER_ERRORS" was wrong (overlap exists today, exact-first
  shadows it harmlessly). Rewrote the comment to reflect reality
  and added the test that the comment now references:
  `every KNOWN_SERVER_ERRORS string resolves via the exact-match
  table, not via a pattern`. Catches a future refactor that
  deletes an exact entry and lets a pattern silently swap the
  German copy. Also exported SERVER_ERROR_PATTERNS +
  SERVER_ERROR_TO_USER_MESSAGE so the test can introspect them.

- _global-setup.ts::withPageRetry — page leaked on the success
  path (no close after `return await step(page)`) and on the
  terminal throw. Moved close into a `finally` block.

- ci.yaml::e2e-tests — added `timeout-minutes: 30` so a hung
  browser process can't sit on the default 6 h budget.

* fix(errorMessages): trim dead-code assertion + revert unused export

Subagent re-review NITs:
- error-mapping.test.ts had a tautological inner assertion
  (userMessageFor always returns the exact entry first, so the
  conditional check could never fire). Line 76's .toBeDefined()
  already enforces the real lockstep invariant — trim the rest.
- SERVER_ERROR_PATTERNS no longer needs export now that the test
  doesn't consult it. Reverted to module-private.
- SERVER_ERROR_TO_USER_MESSAGE stays exported (the test needs it),
  marked @internal so production callers stick to userMessageFor().

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.

1 participant