Uh oh!
There was an error while loading. Please reload this page.
fix(wallet): a key enrolled after catch-up no longer reads an empty replica - #223
Conversation
…eplica `initial_sync_complete` records that a catch-up finished over the puzzle-hash set resolved AT SESSION START. `watchlist_follows` asks whether a key is in the registry RIGHT NOW. Nothing ordered the two, so enrolling a second key after a catch-up had completed made the very first read of its address take `db_synced = true` and `scoped = true`, query the replica for a scope it had never followed, and answer `balance: 0, pending: 0, source: "db", synced: true` for a funded address. `coins_for_address` answered `coins: []` identically — and its own doc reads that as "a chain WAS consulted", so a spend built on it refuses with a shortfall that is not real. No attacker and no operator configuration: enrolling a second profile is enough, and the flag is persisted, so a restart does not clear it. The invariant now held: a `Source::Db` answer may only be produced when the completed catch-up actually covered the queried address. Enrolment goes through `WalletBackend::watch_keys`, the single door onto the registry, which clears `initial_sync_complete` when — and only when — the followed set genuinely WIDENED. Reads then fall to the chain tier until the next catch-up completes, for up to about one session lifetime. That cost is accepted: the oracle answers truthfully, and failing to the oracle is the correct direction where failing to a dated zero is not. `watch` is idempotent and clients re-announce their whole account on every unlock, so invalidating on a re-announcement that added nothing would have invented a permanent outage; the regression test for that is `re_announcing_a_known_key_leaves_the_replica_ authoritative`. Variant 1b, the permanent form: `refresh_tracked_coins` fetched coins for CUSTODY's puzzle hashes only and then latched the global flag, which declared the replica authoritative for every externally enrolled address whose coins it had never requested — with nothing to ever clear it. It now latches only when the fetched set covers every enrolled address. Closes dig_ecosystem#2871 Co-Authored-By: Claude <noreply@anthropic.com>
Formatting only, in a test helper. No logic, no assertion and no production line changes, so any gate verdict taken against 4b5c3be still holds. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
commented
Aug 13, 2026
Gate-head equivalence, verified by the orchestratorThe correctness and security gates were dispatched against The entire change is a three-line rewrap of Recorded because "the delta was only formatting" is exactly the claim that should never be accepted from the party that made it. |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED - reviewed at 4b5c3bebb0c28626be9f0e6c0af9622821951fab.
The fix is the right shape and closes the sequence the ticket describes. But the invariant as stated - a Source::Db answer may only be produced when the completed catch-up actually covered the queried address - is not held on every path. Two routes still reach the exact money lie #2871 exists to stop, both with no attacker and no operator configuration, and both ending with initial_sync_complete = true over a set the catch-up never covered.
GATING
crates/dig-wallet/src/sage/sync.rs:992- a catch-up already in flight latches unconditionally over the set it was handed at session start, overwriting the clearwatch_keysjust made. Enrol during a catch-up and the shipped defect is unfixed.crates/dig-wallet/src/sage/rpc.rs:709-712- the registry write (persisted) is committed BEFORE the invalidation, andwatchis idempotent, so a failed or interrupted invalidation can never be retried: the retry adds 0 and never clears.
NON-GATING: nothing pins that control.wallet.watch routes through watch_keys; watchlist() still exposes WatchRegistry::watch publicly; the new watch_keys doc asserts a property finding 1 disproves.
Checked and found CORRECT: watchlist_is_covered_by normalises identically to watchlist_follows (both normalize_ph(hex::encode(puzzle_hash_for(pk)))), with no always-true path beyond the correct empty-registry case, and refresh_tracked_coins passes lowercase hex so the comparison is real; no lock is held across an await in watch_keys (the RwLock guard is scoped inside WatchRegistry::watch); watch is additive-only so a membership change without growth cannot arise, and unwatch correctly needs no invalidation because narrowing is safe; the fixture in a_key_enrolled_after_the_catch_up_is_not_answered_from_the_replica genuinely latches BEFORE enrolling K2 and would be vacuous reversed; the 1b test exercises refresh_tracked_coins itself and carries a real control; registry.watch has exactly one production caller.
Both gating findings are money-path concurrency and write-ordering on the very defect the stopped release is waiting on - not handed to Copilot; they need an implementer holding the threat model.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d
commented
Aug 13, 2026
Gate returned CHANGES-REQUIRED. Reversing the shape I decided on the ticket.Both gating findings share one root cause:
Ordering was never going to hold this. So the fix stops maintaining the flag and makes the write self-describing: Both findings then become impossible by construction:
It also deletes code rather than adding a guard, which is the better sign. What I kept from the previous round, because the gate verified it independently: Accepted cost is unchanged from the original decision: after enrolling a key, reads fall to the oracle until the next catch-up. The oracle answers truthfully. Per-address coverage — so enrolling K2 need not also blind K1 — is filed as a follow-up rather than built here. The release stays stopped until this lands. |
MichaelTaylor3d
commented
Aug 13, 2026
Security gate: PASS — but it found the same two defects, rated differently. Here is why I am not merging on it.Two independent gates, fresh contexts, reached opposite gating verdicts on the identical two defects. That disagreement is worth recording rather than quietly resolving. The security gate's argument for non-gating is genuinely strong: this PR is "a strict narrowing of a defect that is on It rests on a premise that no longer holds. It assumes Two further reasons the correctness gate's GATING call is the one to follow:
What the security gate cleared, recorded so it is not re-derived
Most useful for the next reader: Two new findings, forwarded to the lane
|
…nced flag WIP. The completion write now carries the puzzle-hash set it ran over, and routing asks whether that recording still contains the followed set.
MichaelTaylor3d
commented
Aug 13, 2026
Both GATING findings are addressed by removing the shape that caused them, not by re-ordering it. F1 ( F2 ( One deviation from the decided shape, stated in the PR body: coverage is asked as CONTAINMENT over a stored set rather than equality over a fingerprint, because equality would also invalidate on |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES-REQUIRED (recorded as a comment review - GitHub refuses REQUEST_CHANGES on a self-authored PR, HTTP 422).
CHANGES-REQUIRED — reviewed at e1c9522a613ed71c99509ed744fc6fa5ee456c58.
The restructure is sound where it was asked to be. F1 and F2 are genuinely impossible by construction, not merely unlikely, and I verified that rather than taking it:
- F1 —
CatchUpReplay.coveredis built insidefinished_atfrom the verypuzzle_hashesvectorinitial_sync_with_authoritypasses torequest_puzzle_state(sync.rs:960/:1002), it is a required constructor argument, the field ispub(super)with no setter, andcomplete_catch_upbinds it in the sameUPDATEaspeak_height/header_hash/initial_sync_complete. A completion cannot describe an address its own subscription did not contain, at any ordering. - F2 —
watch_keysperforms exactly one mutation (registry.watch) and returns. There is no second write, noadded > 0guard and no fallible/asyncinvalidation left to fail. Verified there is no other door:WatchRegistry::watchispub(crate),control.rsis a different crate, so re-pointing the handler at the registry is a compile error — that is structural, not conventional, and nothing needed the wider visibility (registered/unwatch/is_emptystaypub, and narrowing is the safe direction). - Encoding — every constructor funnels through
from_hex, which normalises via the router's ownnormalize_ph, sorts and dedups; the field is private, socovers'binary_searchcan never see an unsorted receiver. Round-trip is lossless,from_storage("")is the empty set, and the empty set covers only the empty set. Members only ever originate fromhex::encode, so the comma separator is unambiguous in practice. - Containment direction —
recorded ⊇ followed, correct way round. An empty recorded set cannot vacuously cover a non-empty followed set; andSome(empty)is unreachable in production (sync.rs:931refuses an empty subscription;refresh_tracked_coinsreturns early on emptyphs). - Router/subscriber union —
service.rs:233and:255-260hand the samecustodyandwatchlisthandles toUnionPuzzleHashSourceand to the backend, andUnionPuzzleHashSource::puzzle_hashesnow isfollowed_puzzle_hashes. No second copy of the union. - Migration — additive,
ALTERerrors swallowed (idempotent), NULL reads asNonereads as covers-nothing, andinitial_sync_complete's meaning is untouched foris_synced, the phase logic and the arrival baseline. - Fixture check (the place a weakening would hide) —
db_with_owned_derivationrecords coverage over exactlyowned_ph(), not blanket coverage. Those tests still require the flag, and had their followed set been wider they would now FAIL rather than pass. Not a rubber stamp. The three new tests discriminate: mutating the predicate back tois_synced()flips exactly the three that expectFallbackwhilere_announcing…andderegistering…(which expectDb) stay green — that is structurally forced by the fixtures, so the claim holds.
What blocks it: one gating finding. This PR adds a normative, unconditional SPEC MUST — "a read MUST be served from the local replica only while that recording CONTAINS the set the node currently follows" — and two money reads on the Sage-parity RPC surface do not obey it. They still route on the bare initial_sync_complete, with a client-supplied identity, and produce the same balance 0 / looks synced signature this release is stopped for. Details inline.
Non-gating findings are posted separately and resolved by me so they cannot block the merge.
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Non-gating notes (N1-N3), posted as comment threads and resolved by me so they cannot bar merge under required_conversation_resolution.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: MichaelTaylor3d <5665004+MichaelTaylor3d@users.noreply.github.com>
The enrolment log message was written as one long string literal wrapped across source lines, which embeds the source indentation into the message: it rendered `the replica answers for none of it`. A run of spaces inside a rendered string is the same defect class caught on the app side — the text is assembled by accident rather than written. `concat!` joins the pieces at compile time, so the wrap is a source-layout choice again and cannot reach the operator reading the log. Co-Authored-By: Claude <noreply@anthropic.com>
The scoping added in 5cd6b96 correctly narrowed the covered-set containment invariant to the address-scoped money reads, and named the two identity-scoped Sage-parity reads that still route on the bare `initial_sync_complete` flag. It left them "tracked separately" without saying where, which leaves a reimplementer reading this section with a named gap and no way to follow it. Cite dig_ecosystem#2878, and say plainly that the invariant does not yet hold on that surface rather than implying the tracking is a formality. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
commented
Aug 14, 2026
Addressed at Copilot's Completed here:
The containment invariant is unchanged and the routing at both Sage-parity sites is untouched, per the instruction. Also in this push, unrelated and found in the worktree: |
Uh oh!
There was an error while loading. Please reload this page.
DO NOT MERGE — gate round in progress.
Closes dig_ecosystem#2871.
What changed, and why the shape changed
The previous round fixed the symptom by clearing
initial_sync_completeat the enrolment boundary. Two gating findings showed that shape cannot be made correct, because it requires two mutations to land in the right order across a process boundary:{K1}callscomplete_catch_upunconditionally when the peer answersis_finished, re-latching the flag over a set that never containedK2. A first catch-up replays from genesis over many batches, so an enrolment lands squarely inside that window.registry.watchpersists BEFORE the invalidation runs. If that second write fails (or the process dies), the key is enrolled with the flag stilltrue, and the client's retry reportsadded = 0, so anadded > 0guard never fires. Unrecoverable by retry.So the flag stopped being maintained and the write became self-describing: a completed sync records the puzzle-hash SET it ran over, and routing asks about coverage rather than about a global flag.
The predicate, at both money read sites (
balance_for_address,coins_for_address, via the singleWalletBackend::replica_is_authoritative):currently_followed_setcomes fromsync_supervisor::followed_puzzle_hashes— the SAME union (custody ∪ watch registry) the supervisor subscribes, now one definition consumed by both sides, so the router and the subscriber cannot disagree about which addresses are followed.Why containment, not the equality-on-a-fingerprint the brief specified
Equality invalidates on NARROWING too:
control.wallet.unwatchwould stop matching and force a needless full resync, sending every read to the oracle for its duration — a self-inflicted outage on a correct operation. A sync over the wider set genuinely covers what remains, so the question has to be containment. It is stored as the canonical set (CoveredSet, comma-joined lowercase hex) rather than a hash, which is what makes containment expressible. This is the only deviation from the decided shape; everything else follows it.F1 and F2 are now impossible by construction
CatchUpReplayCARRIES the covered set (built from the subscription's ownpuzzle_hashesvector), andcomplete_catch_upwrites it in the SAME transaction aspeak_height/header_hash/initial_sync_complete. A completion cannot describe addresses its own subscription did not contain, whenever it happens to land. Ordering is irrelevant: the late writer records{K1}, the followed set is{K1,K2}, containment fails.watch_keysregisters and returns; the clear, theif added > 0guard and the widening detection are deleted, and it is no longerasyncor fallible (theWalletReadFailedbranch incontrol.rsis gone with them). There is nothing left to fail between the two mutations because there is one mutation.Also in this round
WatchRegistry::watchis nowpub(crate), so "the single door onto enrolment" is a compiler guarantee rather than a convention. This replaces the control-plane door test the brief asked for: the narrowing makes re-pointing the handler at the registry fail to compile, which is strictly stronger than a test.rpc.rs:688— the doc claiming reads fall back "until the next catch-up completes over the widened set" is gone; F1 disproved it and the restructure makes it obsolete.refresh_tracked_coins(variant 1b) —watchlist_is_covered_byand its guard are KEPT unchanged (the gate verified both), and the path now RECORDS the set it fetched alongside latching the flag. Without that it would latch a flag with stale or absent coverage and buy itself nothing, since routing asks about coverage.Schema
Additive:
sync_state.covered_puzzle_hashes TEXT, added byCREATE TABLEfor fresh DBs and by an idempotentALTER TABLE … ADD COLUMNfor existing ones (§5.1). An existing replica's column arrives NULL, which reads as covers nothing — fail closed: reads fall to the chain oracle, which answers truthfully, until the next sync records a set.initial_sync_complete's own meaning is unchanged; its other consumers (the phase logic,await_puzzle_hashes, the arrival baseline) read exactly what they read before.The lever this does NOT remove — read this
control.wallet.watchremains an unbounded lever that can hold the node in permanent oracle fallback, and the fingerprint/coverage scheme inherits it: enrolling junk keys widens the followed set, so coverage fails and every read falls back just the same. A holder of a PAIRED token can call it (is_pairing_admin_methodexcludes it); there is no control-plane rate limit and no bound on key count or registry size. Bounded in the right direction —fallback_ratecaps egress and returnsRateLimitedrather than a fabricated figure, so the worst case is a read DoS, never a wrong number. Filed as dig_ecosystem#2877; deliberately not fixed here.Tests — what each catches
a_key_enrolled_after_the_catch_up_is_not_answered_from_the_replicaa_catch_up_in_flight_cannot_vouch_for_a_key_enrolled_while_it_ran(new, F1)a_repeated_enrolment_that_adds_nothing_still_leaves_the_new_key_uncovered(new, F2)added > 0/ on a second write landing. The secondwatchreturnsadded = 0and IS the client's retry.re_announcing_a_known_key_leaves_the_replica_authoritative(kept, now asserts ROUTING)watch— which strands a healthy node in permanent fallback while dig-app re-announces on every unlock.deregistering_a_key_leaves_the_remaining_ones_covered(new)unwatchforces a needless resync.a_custody_only_refresh_does_not_vouch_for_enrolled_addresses+ its control (kept, 1b)coverage::tests×5{aa,bb}vs{aabb}), equality-instead-of-containment, and a lossy storage round-trip.Red proven by mutation, not assumed (committed first, reverted by file copy):
replica_is_authoritative→self.db.is_synced()(the defect): exactly the three F1/F2/enrolment tests fail; every control passes.CoveredSet::covers→==(the equality shape):a_narrowed_followed_set_stays_coveredandderegistering_a_key_leaves_the_remaining_ones_coveredfail, plus five superset fixtures.Evidence
cargo test -p dig-wallet --lib— 532 passed, 0 failed.cargo test --workspace— all suites green (dig-node-service 815, dig-node-core 351, …), 0 failed.cargo clippy --workspace --all-targetsclean;cargo fmt --allapplied.Blast radius checked
gitnexus was NOT used: per CLAUDE.md §2.0 bound (2), blast radius was established by exhaustive
rgover every symbol touched plus a full-workspacecargo check --all-targets, which for Rust closes the call graph mechanically — every caller of a changed signature is a compile error, and there were none left. Symbols changed and their full caller sets:CatchUpReplay::finished_at(+covered) — 1 production caller (sync::initial_sync_with_authority), 20 test call sites.WalletDb::complete_catch_up/sync_state/SyncState— signature unchanged; new field/column.WalletBackend::watch_keys(async→sync,Result<Option<_>>→Option<_>) — 1 production caller (control.rs::wallet_watch), 4 test call sites.WatchRegistry::watch(pub→pub(crate)) — no out-of-crate callers existed.balance_for_address/coins_for_address— bodies only; wire shape unchanged.UnionPuzzleHashSource::puzzle_hashes— now delegates tofollowed_puzzle_hashes; behaviour identical (same BTreeSet union, same order).No HIGH/CRITICAL-risk symbol was edited blind, and no wire/RPC contract changed:
control.wallet.watchreturns the same{added, watched}shape.Version
dig-wallet0.21.1 → 0.22.0 — minor, becauseWatchRegistry::watchnarrowed topub(crate)andwatch_keyschanged signature; both are breaking for a 0.x library crate. Workspace (the releaseddig-nodebinary) stays 0.117.1: a behaviour fix with no user-visible API change, already ahead of the latest tag.