Skip to content

fix(nym): make large syncs over the mixnet reliable - #1219

Open
rachyandco wants to merge 66 commits into
hhanh00:mainfrom
rachyandco:main
Open

fix(nym): make large syncs over the mixnet reliable#1219
rachyandco wants to merge 66 commits into
hhanh00:mainfrom
rachyandco:main

Conversation

@rachyandco

Copy link
Copy Markdown
Contributor

Large syncs looped forever on "stalled (no reply for 120s)": a single GetBlockRange stream for the whole range starves the nym-rpc session of reply SURBs, and nym-sdk's MessageBuffer flushes messages that waited over 6s even when earlier ids are missing, corrupting the h2 stream (GOAWAY FRAME_SIZE_ERROR) under mixnet retransmission delays.

Over the mixnet, split the shielded sync into 1000-block chunks that each run a complete pass and commit, so a dropped session only costs the chunk in flight; clearnet/Tor keep the single-stream behavior. Replace the decay-based buffer with one that writes strictly consecutive message ids, drops duplicate retransmissions, and honors the server's Close message. A gap left unfilled for 120s trips the stall watchdog instead of hanging the session forever.

hhanh00 added 30 commits August 15, 2026 14:06
Replace the global CURRENT_MEMO buffer with a Memo value passed as a
process_memo(memo) argument. Fixes the cross-thread race that made the
rhai plugin tests flaky under parallel test execution.
The OA1 parser trimmed pair and value whitespace before decoding
backslash-escaped spaces, so a value ending in '\ ' lost the escaped
space and kept a stray trailing backslash (spec: spaces are trimmed
unless escaped). Decode escapes before trimming and preserve spaces
encoded by a trailing '\ ' run, matching the openalias-rs grammar.
Settings form edits no longer persist on every keystroke; the save chain
now runs once in SettingsPageState.didPop (matching the sub-page pattern)
via AppSettingsNotifier.save(). VotingConfigNotifier.build() returns only
the cached config so reading the provider never fetches; resolve() takes
an optional source so the settings button uses the typed URL.
VotingConfigNotifier.build() returns only the cached config (no network),
so reading the provider never triggers a fetch and resolve() is the single
fetch path. _resolve logs the real error, serves cache only when present,
and rethrows on total failure; voting polls init/refresh show the error
via showException.
Ballot intents FK-reference the voting round, which only exists after
delegation prepare, so writing intents at proposal-selection time failed
with a FOREIGN KEY error. Move intent writes to the submission job before
the cast loop (mirroring vizor), sourced from the persisted drafts; the
proposal page now persists drafts only.

Also parse proposal option ids from 'index' (vote-sdk format, omitted for
the first option) so Support/Oppose radios no longer collide on id 0, and
surface persistence errors via showException.
The fork's tree-sync HTTP path was the only blocking transport
(Runtime::block_on inside an existing tokio runtime panicked during
votingSyncTree). Bump to 54e6c72 which converts the transport chain
to async and drops the blocking runtime.
Add a bundled-sapling-params cargo feature that embeds the Sapling proving
parameters in the binary (via zcash_proofs/bundled-prover). When enabled,
get_sapling_prover returns LocalTxProver::bundled() and the download APIs
become no-ops; when disabled, behavior is unchanged. Enabled in CI for
desktop (mac/linux/windows) and zkool_graphql server builds; mobile builds
are unaffected.
A fresh round (Join -> ballot -> Confirm & submit) was a no-op: the
submission job is driven by the fork's resume_plan, which only emits
steps from existing DB state, so a never-joined round had no steps and
finished instantly with 'All steps already confirmed' while recording
nothing — the round stayed joinable after restart.

Mirror vizor's sequencing:
- pass draft proposal ids into votingPlan so needs_draft_setup (the
  fresh-round trigger) actually computes
- _runDelegation: treat needs_draft_setup as delegation work for the
  first un-confirmed bundle (prepare/prove/broadcast/confirm), and fill
  missing PIR URL/layout from the resolved voting config
- _runVotes: write ballot intents from drafts before reading the plan,
  reload the session, defer votes for bundles whose delegation is still
  pending, and sanitize drafts for the fork DraftVote deserializer
  (vc_tree_position/single_share, dropped skipped choices)
- _runDelegation/_runVotes/_submitShares report whether they did work;
  the done label is honest: 'Delegation confirmed' / 'Votes submitted'
  / 'Shares submitted' / 'All steps already confirmed'

Also: the confirmation poll now requires a positive block height
(proof of inclusion, not just HTTP 200), and the status screen shows
the tx hash + block height as verifiable evidence.
The fresh voting path (Join -> ballot -> Confirm & submit) never passes
a lightwalletd URL, so delegation_prepare got '' and the fork's
gather_delegation_lwd_inputs failed with 'invalid lightwalletd URL'.
Fall back to the app-configured settings.lwd (same source the sync path
uses) when the status-page param is empty; the resume path is untouched
(its URL comes from the config saved by the first prepare).
unspent_ironwood_notes selected every unspent Ironwood note without a
height bound, so a note created after the round snapshot was passed to
the prepare path; rewinding its witness to the snapshot anchor edge
failed with 'note position is after anchor edge position'. Apply the
same a.height <= snapshot_height filter as eligible_voting_weight, so
the prepared bundle matches the eligibility shown on the ballot page.
Delegation keys embed an app-owned voting hotkey, but nothing in the
app ever created one, so delegation prepare failed with 'no voting
hotkey; create one first' (voting_hotkey_load). Mirror vizor's
_ensureHotkey: before preparing, create the hotkey when missing and
the round is not yet hotkey-bound — a bound round without the stored
key keeps failing instead of silently generating a mismatched key.
The fresh voting flow could show 'Delegation confirmed' without an
actual on-chain confirmation, and stale local setup state made the
build step permanently refuse to proceed. Fixes:

- delegation_build_submission and voting_commit_with_progress: the FRB
  stream binding drops the returned future (unawaited), so Rust errors
  surfaced as uncatchable 'Unhandled Exception'. Deliver errors through
  the sink (decoded as AnyhowException) instead of the dropped future.
- _buildDelegation: run the build with empty pczt_bytes (the fork's
  software path — the build re-runs setup internally with fresh PCZT
  randomness, so a separate delegationSetup call always produced a
  conflicting sighash). Retry once after resetting unsigned setup state
  when the stored sighash is stale from a previous run.
- _runDelegation: after delegationConfirm, read back the bundle phase
  from the session and require 'confirmed' with a tx hash before
  returning true — the done label cannot be claimed on un-backed state.
- voting_status: the done label no longer falls back to
  'Delegation confirmed' (that claimed success for stale done states);
  it renders the honest doneLabel.
- new FRB voting_reset_session_state (fork reset_voting_session_state)
  for the stale-setup recovery.
- Cargo.toml: patch zcash_voting to the local clone carrying the SQL
  alias fixes (voting_votes referenced as 'votes' in
  clear_stale_share_delegations and record_vote_submission).
PirClientBlocking owns a tokio runtime and block_ons on every call, which
panics inside the FRB async runtime ('cannot start a runtime from within
a runtime'). Connect via the async connect_pir and let the fork's prove
path (now generic over PirProofSource) fetch IMT proofs without a
blocking detour.
- new votingRoundTitle provider fetches the chain round-status title
  (the only friendly-name source; config and local DB carry none)
- polls page: Join tiles and round tiles show the title instead of the
  bare hex round id
- ballot page: the round-name lookup now checks 'title' first, so the
  friendly name flows through review -> status -> confirmation
- status page: shows the round name above the stage label
- confirmation page: 'Your vote for <title> has been submitted.'
- review page: fetches the chain options and lists the chosen option
  LABEL (e.g. 'Smooth issuance curve') instead of 'Option N', falling
  back to indices when the fetch fails
- error text on the status page is selectable for copy/paste
The hyper client was created on the FRB runtime and its connections were
pooled there; the prove thread's own runtime then reused them, but the
FRB runtime is parked on the thread join, so the pooled connections' I/O
could never progress and PIR requests stalled until the 60s transport
timeout. Connect inside the thread so all PIR traffic lives on the
thread's runtime (matches vizor's shape). A probe against the stage
server confirmed the cross-runtime pattern was the wedge: single- and
thread-runtime PIR both complete in ~6s.

Also patch pir-client from a local copy adding the missing public
circuit_root getter on the async client (needed by the fork's
PirProofSource impl).
…ts 0.10.0

The vote chain reports tx confirmation heights as JSON strings
("7163319"), so parseVoteChainTxConfirmation rejected every confirmed
tx and the confirmation poll timed out even though the delegation was
accepted on-chain (leaf_index recorded). Accept numeric strings.

Also point the voting-circuits patch at the local 0.10.0 clone with the
ZSA-orchard shim (matching the chain's verifier), replacing the stale
0.9.0-rc.3 git rev that made the chain reject every delegation proof.
Add rust/examples/vk_probe.rs to compare circuit/proof fingerprints
against the fork's standalone build.
The vote circuit derives each cast-vote's VAN from a proposal-authority
mask that clears one bit per submission (load_zkp2_inputs drops proposals
whose vote has a recorded tx_hash). The port committed ALL drafts in one
batch, so every proposal used the bundle's VAN and proposals after the
first were rejected by the chain ('nullifier already spent').

Restructure _runVotes to mirror vizor's per-draft build loop: cast one
proposal, submit + confirm it, then cast the next — each subsequent
commitment sees the previous submission and derives the chained VAN.
Extract the submit/confirm sequence into _submitVote, reused by the
cast path and the resume submit_vote path.
The voting flow never passes shareServerUrls, so _submitShares planned
against zero servers and the round stayed on 'Resume' forever with
pending submit_shares steps. The vote chain servers double as helper
servers — mirror vizor's context.config.voteServers: when no explicit
share server list is provided, use the resolved config's vote server
URLs.
A run that performs no work can still have pending plan steps — the
helper shares are scheduled near the vote window, so 'All steps already
confirmed' was a misnomer while submit_shares steps remained. Derive the
label from the plan: pending share steps -> 'Waiting for the share
window', other pending steps -> 'Waiting for the next step', empty plan
-> 'All steps already confirmed'.
…reens

The evidence block surfaced only the delegation tx; the confirmed votes
were verifiable in the DB and on the chain but invisible in the UI. Show
each confirmed vote's tx hash and vote-tree position (from the session
recovery) on the status screen's done state and the confirmation page,
selectable for copy/paste.
Replace the machine-local path patches with the pushed branches so the
build is reproducible anywhere:
- zcash_voting: dependency rev 54e6c72 -> aa5338f1 (feat/sqlx-storage:
  SQL alias fixes, voting-circuits 0.10.0 pin, PirProofSource, witness
  pruning, prove stacks); the patch section is removed — a patch keyed
  by its own git URL must point at a different source, so the rev lives
  in the dependency spec itself
- voting-circuits: git rev 4403369 (feat/zsa-orchard-0.10: 0.10.0
  release source + ZSA-orchard note API shim)
- pir-client: git rev b704640 (feat/async-circuit-root-getter on
  hhanh00/vote-nullifier-pir: public circuit_root accessor for the
  async client)
The done and confirmation screens listed 'Proposal N: <tx> · tree N'.
Add a votingRoundProposals provider (chain round status: id, title,
option id -> label) and render each confirmed vote as
'<title> — <option label>' above its tx hash and tree position.
The confirmation page now receives chainUrl to fetch the proposals.
The ballot evidence fell back to 'Option ${choice + 1}', which showed
choice 1 (Oppose, option id 1) as 'Option 2' — off by one against the
vote-sdk's 0-based option ids. Fall back to the option id itself; the
proposal label ('Oppose') still renders once the round proposals fetch
resolves.
hhanh00 and others added 28 commits August 17, 2026 21:52
- voting_share_payloads now excludes shares already recorded in
  voting_share_delegations, so a resume no longer re-submits the same
  shares (it was duplicating submissions to the helpers).
- _submitShares arms the background tracker even when all shares are
  already recorded, so confirmations still get polled after a restart.
- _trackShares refreshes the voting session after each tick, so the
  round tile flips from 'Resume' to 'View results' once every share
  confirms instead of showing stale status.
- the remaining label distinguishes pending share submissions from
  pending share confirmations.
…ess closed rounds

Round tiles now derive their affordance from the chain round status plus the
resume plan: tallying/closed rounds show "View results" (a tally exists),
active rounds with pending recovery (incl. unconfirmed helper shares) show
"Resume" to the status page, active rounds with the wallet done show
"Review" to the vote receipt, and the "Open rounds" section only offers
Join for rounds the chain reports as active. The results page renders a
closed round with no recorded votes as a zero-filled ballot instead of an
empty screen.
…are wallets (hhanh00#1208)

* fix(ledger): skip Sapling FVK fetch for transparent-only transactions

sign_transaction unconditionally fetched the Sapling full viewing key
from sapling_accounts via fetch_one. Transparent-only accounts (e.g.
BIP-44 Ledger accounts) have no sapling_accounts row, so this returned
RowNotFound and aborted signing before the Ledger device was ever
contacted, surfacing as an opaque "no rows returned" error.

The FVK and derived OVK are only used inside the Sapling spend/output
loops, which only iterate when the PCZT has Sapling components. Fetch
the key lazily (only when stin > 0 || stout > 0) as an Option, and
unwrap by reference at each use site. SpendValidatingKey is not Copy,
so the proof-generation block uses ak.clone().

* fix(ledger): skip Sapling anchor fetch for transparent-only transactions

After proving, sign_transaction unconditionally called
pczt.sapling().anchor().expect("a Sapling bundle with spends must have
an anchor"). A transparent-only transaction has no Sapling bundle, so
anchor() returns None and the expect panicked:

  thread 'tokio-rt-worker' panicked at rust/src/ledger/builder.rs:494:
  a Sapling bundle with spends must have an anchor

The anchor is only read inside the Sapling spends serialization loop,
which never iterates for a transparent-only PCZT. Fetch it lazily (only
when stin > 0) as an Option and unwrap by reference inside the loop.

* fix(ledger): force v5 tx for hardware signing on NU6.3

NU6.3 (Ironwood) is active, so the Builder produces v6 transactions.
Hardware wallets using the Zondax "Zcash Shielded" Ledger app can only
sign v5 (ZIP-244) transactions; the app predates NU6.3 and computes a
pre-NU6 sighash for a v6 tx, so the PCZT signer rejects the device
signature with TransparentSign(InvalidExternalSignature).

Force the Builder to emit a v5 tx for hardware accounts (hw != 0) via
propose_version(TxVersion::V5), while keeping consensus_branch_id as
BranchId::for_height (Nu6_3 on the current network). V5 is valid in Nu6_3
per TxVersion::valid_in_branch, and a v5 tx carrying the current Nu6_3
branch id is consensus-valid, so the tx is both signable by the device
and acceptable to the network. Software accounts are unaffected and
still build v6.

This is expected to remain viable for the foreseeable future: the only
planned transaction-version phase-out is ZIP 2003 (Draft, proposed for
NU7), which disallows v4 (Sprout) transactions and explicitly keeps v5
valid. NU7 is not yet scheduled (activation height TBD), and no ZIP
proposes removing v5. The workaround depends on the Zondax Ledger app
remaining installed (it is unmaintained but functional); the proper
long-term fix is migrating the Ledger signer to the LedgerHQ PCZT-v2
app, which supports v6/Ironwood.

---------

Co-authored-by: hhanh00 <hanh425@gmail.com>
…imalDigits

flutter_localizations in Flutter 3.47 requires intl ^0.20.3. fixed <6.2.0
pins intl <=0.20.2, so pub resolution failed entirely. Bump fixed to ^6.2.0
(first version supporting intl 0.20.3) and update the four call sites that
used the renamed scale: parameter (decimalDigits: has identical semantics).
… voting page

Every voting read call acquired get_connection() for the wallet-id lookup and
then let VotingDb acquire again internally for migrations and queries —
2 connections held per call, so the 4 concurrent per-tile session loads
oversubscribed the 5-connection pool and self-stalled: holders blocked on
their 2nd acquire wait out the 30s acquire timeout, leaving the round tiles
spinning for minutes.

zcash_voting (rev faa06af4) now threads a caller-provided &mut SqliteConnection
through the read path (rounds, ballot_intents, resume_plan, round_snapshot and
all their helpers, including the previously-missed delegation_statuses,
recovered_*_work_from_steps, vote_has_recovery_bundle, get_commitment_bundle,
get_unconfirmed_delegations, share::{list,unconfirmed}). zkool's wrappers
acquire once and pass it down — one pool connection per FRB call (verified
with a standalone probe: full 4-round session load in ~13ms vs 30s timeouts).
The page already fetches the round title from the chain round status for
the submission flow; show it in the AppBar (falling back to the round id)
instead of always displaying the raw round id hash.
… free during delegation

- voting_sessions: new FRB that loads every round's plan/recovery/intents in
  one Rust call holding a single connection (per-round loads needed one
  connection per round and stalled the 5-slot pool with many rounds). The
  round list reads sessions from the shared batch provider.
- delegation prepare/build/setup/sign: scope the wallet connection around
  the short DB phases instead of holding it across the lightwalletd fetch,
  PCZT build, and PIR proving (a later internal acquire queued behind the
  held connection and timed out at 30s).
- get_connection logs slow acquires with pool size/idle counts.
- the round list invalidates when a submission job finishes, so a round
  leaves the Join list and shows its real status without a manual refresh.
Co-authored-by: macintoshhelper <6757532+macintoshhelper@users.noreply.github.com>
Done on the vote receipt used go('/voting'), which reset the stack and
left the polls page as the root with no way back to the account page.
Chain proposal -> review -> status via pushReplacement and pop the
confirmation page instead, so the polls page keeps its back arrow.
…ailures

Design review found six gaps against resumability: a server crash lost an
accepted tx from the in-memory mempool with no re-broadcast path; a client
crash between the broadcast response and persisting the tx hash could
dead-end on a duplicate-nullifier 422; no failover across configured vote
servers; transient blips killed the run with manual-only retry; share
tracking was session-scoped and helper failures failed the whole job; the
status page trapped the user during long polls.

- votechain client: every completed HTTP response is an answer (status +
  body + Retry-After pass through); only transport failures are Err
- VoteChainFailover service: rotates across configured servers, honors 503
  Retry-After, accepts 502-with-hash envelopes, remembers the last-working
  server per round
- bounded auto-retry with 30/60/120s backoff in the submission job; the
  plan-driven body is idempotent, so re-running is always safe
- poll-with-rebroadcast fallback: a recorded hash that never confirms is
  re-broadcast with byte-identical persisted wire (the vote wire is now
  persisted like the delegation wire), keeping the recorded hash valid
- duplicate-nullifier 422s reconcile confirmations from the commitment
  tree (cast-vote appends the VAN output immediately before the vote
  commitment, so one scan recovers both positions)
- share tracking extracted into a session-independent provider re-armed on
  wallet open and voting-page open; helper outages no longer fail the run
- voting pages: retrying stage, leave-anytime PopScope, tree-verified
  receipts without a tx hash
- pin rustc 1.95.0 via rust-toolchain.toml (1.88 cannot compile the pinned
  libcrux-psq)
- fork rev bumped to zkool-recovery (commitment-tree recovery APIs +
  standalone build repair)
Large syncs looped forever on "stalled (no reply for 120s)": a single
GetBlockRange stream for the whole range starves the nym-rpc session of
reply SURBs, and nym-sdk's MessageBuffer flushes messages that waited
over 6s even when earlier ids are missing, corrupting the h2 stream
(GOAWAY FRAME_SIZE_ERROR) under mixnet retransmission delays.

Over the mixnet, split the shielded sync into 1000-block chunks that
each run a complete pass and commit, so a dropped session only costs
the chunk in flight; clearnet/Tor keep the single-stream behavior.
Replace the decay-based buffer with one that writes strictly
consecutive message ids, drops duplicate retransmissions, and honors
the server's Close message. A gap left unfilled for 120s trips the
stall watchdog instead of hanging the session forever.
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.

4 participants