fix(firo): make Spark anon-set sync resumable and crash-safe#1298
fix(firo): make Spark anon-set sync resumable and crash-safe#1298reubenyap wants to merge 2 commits intocypherstack:stagingfrom
Conversation
Self-review — findings after cold adversarial readRan a second pass with a skeptical Dart/SQLite reviewer agent to catch things I missed. Below are all findings from both passes, triaged by actionability. TL;DR: no bugs found that this PR needs to fix before merge. Details: False positives (analyzed and dismissed)Claim: The scenario requires the server to return the same coin at two different indices for a given For cross-sync consistency: the The Claim: Redundant spark_interface.dart:608 and :614 both set Claim: Reader ordering is only newest-first within a set, not across sets. Traced end-to-end with the delta-storage model. For sets 1 (size 100 at H1) and 2 (delta 50 at H2=150):
End-to-end order is correct across sets. The comment in the reader accurately describes the behavior. Pre-existing issues not introduced by this PRConcurrency: main-isolate reads vs worker-isolate writes on the same SQLite file. The sqlite3 package opens in rollback-journal mode by default; two handles on the same file can contend on the database-level lock. In my sync flow, main-isolate reads happen before the first worker task is dispatched, so the sync itself doesn't contend. But UI-initiated reads ( TOCTOU between spark_interface.dart:589 then :597 reads coins, then reads meta. Between the two
The FK declarations on Notes worth documenting but not changingProgress-bar callback semantic inconsistency. Initial call passes
Migrated DBs get Orphan SparkCoin rows on blockHash shift.
In pathological scenarios (multiple aborted syncs at different blockHashes) several incomplete rows could coexist. My query returns Testing asks for the reviewerThree things this environment couldn't run:
Happy to address any of the above findings (e.g., enabling WAL, tightening the 🤖 Self-review generated with Claude Code |
748e6a3 to
016fb4b
Compare
016fb4b to
bb300cd
Compare
Problem
runFetchAndUpdateSparkAnonSetCacheForGroupIdaccumulates every sector of a group's anonymity set in an in-memory DartListand only writes to SQLite after the full delta has been fetched. A network drop, app kill, or power loss mid-download discards the in-memory buffer and leaves no persisted progress, so the next sync attempt recomputes the samemeta.size - prevSizedelta and re-downloads everything. For a large first-sync this burns the whole set repeatedly.Additionally, a related design — persisting partial sectors directly to
SparkSetwith acompleteflag but not filtering reader queries on it — has a second failure mode: a spend initiated while a sync is running can observe a half-populated anonymity set and hand libspark asetHash-mismatched set, failing the membership proof.Fix
Per-sector commits against an in-progress
SparkSetrow that stays invisible to readers (complete = 0) until a strict integrity check passes, at which point a single atomicUPDATEflips it tocomplete = 1. Partial data is never observable by readers, and the next sync resumes from the count of already-linked coins.Design
SparkSet.complete(integrity gate) andSparkSetCoins.orderKey(server-side delta index, for ordering preservation), plus aUNIQUE INDEXonSparkSetCoins(setId, coinId)so per-sectorINSERT OR IGNOREdedupes correctly under crash-recovery replay.PRAGMA table_info/sqlite_masterpresence checks rather thantry/catcharoundALTER(so unrelated SQLite errors don't get silently swallowed). Defensive dedup of any legacy duplicate(setId, coinId)rows before creating theUNIQUEindex. ExistingSparkSetrows default tocomplete = 1(the pre-fix writer was all-or-nothing, so they represent finalized syncs); existingSparkSetCoins.orderKeydefaults to 0 and the reader'sssc.id ASCtiebreaker reproduces the pre-fix layout byte-for-byte.COUNT(SparkSetCoins) == expectedDelta), and delete-incomplete (for blockHash shift / corruption reset). Each runs in its own SQLite transaction.WHERE ss.complete = 1. Coin ordering reconstructed viaORDER BY ss.id ASC, ssc.orderKey DESC, ssc.id ASC— matches pre-fix end-to-end behavior for both pre-migration rows (orderKey tied at 0, PK tiebreaker) and post-migration rows (orderKey meaningful).blockHashandsetHashmatch the current server meta, resumes atCOUNT(SparkSetCoins)if they do, discards and restarts if they don't. Sector-size cross-check refuses to persist a partial/over-full server response. Reorg-shrink and blockHash-advanced-without-new-coins cases are handled explicitly to avoid corrupting finalized state.Integrity guarantees
COUNT(SparkSetCoins).complete=1flipped or not — no intermediate. Either way, next sync resumes correctly.complete=0. Next sync observes the over-linked row and resets.complete=1filter keeps in-progress data invisible. Spend sees the last finalized state.Upgrade path
Fully automatic — no manual cache clear, no wallet rescan, no user action. The migration runs on first open of the new version:
ALTER TABLE SparkSet ADD COLUMN complete INTEGER NOT NULL DEFAULT 1(legacy rows are finalized).ALTER TABLE SparkSetCoins ADD COLUMN orderKey INTEGER NOT NULL DEFAULT 0.CREATE UNIQUE INDEX.Verified against a realistic pre-migration DB (two finalized syncs, five link rows): reader output is byte-for-byte identical before and after migration; a subsequent post-migration incremental sync produces the expected hybrid layout.
Review strategy
The change is split into two commits with clear separation of concerns, so a reviewer can verify them independently:
1.
1d543d8— schema + migration (+126 −5, 2 files)Purely additive. Existing reader/writer/coordinator are untouched, so behavior for anyone running only this commit is unchanged. Verify the schema design and migration correctness in isolation before looking at any logic.
2.
90cd308— behavioral fix (+424 −77, 4 files)Writer, worker, reader filter/ordering, and coordinator rewrite. Public
FiroCacheCoordinatorsignatures are preserved, so no callers infiro_wallet.dart,spark_interface.dart, or UI views need changes.Test plan
Environment didn't have the Flutter/Dart toolchain available during development, so I verified by replaying the writer's exact SQL against an in-memory
sqlite3CLI instance across 18 scenarios:(setId, coinId)dedup keeps MIN(id) per group beforeCREATE UNIQUE INDEXfiro_wallet.dart,spark_interface.dart, UI) use unchanged public signaturesflutter analyze/dart analyzepass🤖 Generated with Claude Code