Uh oh!
There was an error while loading. Please reload this page.
fix(wallet): stop reporting confident answers the replica has not earned - #246
Conversation
…r covered Two surfaces reported a confident answer the node had not earned. **Identity-scoped Sage reads (dig_ecosystem#2878).** `get_sync_status` and `wallet_coins` routed on the bare `initial_sync_complete` flag, which records THAT a catch-up finished and never WHICH addresses it covered. A connected client whose puzzle hashes that catch-up never ran over was answered from an empty replica: `selectable_balance: 0` with `synced_coins == total_coins`, and an empty coin list. Both now ask the containment question `replica_covers` already poses for the address-scoped reads, against the CLIENT's scope rather than the node's followed set — a node under §908 follows nothing, so every recording contains its followed set and that predicate would have been vacuous here. **Refused writers (dig_ecosystem#2666).** A session corroboration refused has every frame dropped before any DB write, including the peak, so the replica falls behind by an unbounded and invisible amount. The `Synced` arm required only a latched flag and an attached peer, so such a node reported `synced` indefinitely. It now also requires `session_may_write`, and reports `syncing` otherwise. No new phase token, so no control-interface contract change. `replica_is_authoritative` is refactored to delegate to the extracted `replica_covers`; the expression is identical, so the address-scoped reads are unchanged. Refs: DIG-Network/dig_ecosystem#2878, DIG-Network/dig_ecosystem#2666 Refs: DIG-Network/dig_ecosystem#2760
853c220 to
7deb27bCompareMichaelTaylor3d
commented
Aug 19, 2026
loop-security — interim finding 1 (audit in progress)Audited head: Verified so far (no defect)
FINDING 1 — a confident zero balance is still reachable, on a surface with nothing to qualify it
Scenario (this PR's own fixture, one method over): the
This is pre-existing, not introduced by this diff —
Secondary note, same file: Continuing: reproducing the vacuity claim, judging the three refusals, and checking the four fixture repairs. |
MichaelTaylor3d
commented
Aug 19, 2026
loop-security — interim: EXECUTED PROBE RESULTS (head |
MichaelTaylor3d
commented
Aug 19, 2026
loop-security VERDICT: PASSAudited head: No security defect in this diff. Every probe is recorded in the two comments above, executed in my own detached worktree Areas checked, and why each is clear
The claims I was asked to verify hardest
The question you asked: can the wallet still report a confident figure it has not earned?Yes, but not through anything this diff touches. This diff makes the situation better and internally inconsistent at the same time: for the uncovered funded client, One consequence that makes FINDING 1 permanent rather than transient: Judging the three refusals
Findings, ranked
Why PASS rather than CHANGES-REQUIRED on FINDING 1Because blocking this PR does not fix it and makes things worse. Two conditions, asserted rather than requested, since they are the orchestrator to enforce:
Nothing was handed to Copilot. Money path throughout, per instruction. |
Uh oh!
There was an error while loading. Please reload this page.
DO NOT MERGE — gate round not yet run.
Stage 3 of the MVP feature path (epic DIG-Network/dig_ecosystem#2760): the wallet must not lie about money.
Closes nothing in this repo — both tickets live on the superproject and must be closed manually:
What a caller saw, and sees now
dig_ecosystem#2878 — identity-scoped reads routed on the bare sync flag
A client logs in with its puzzle hashes and holds 1.599 XCH on chain. The node's catch-up
completed over a different address, so the replica was never asked to follow this client.
get_sync_statusselectable_balance: 0,synced_coins: 0,total_coins: 0— every client renders this as "synced, balance 0"synced_coins: 0,total_coins: 1— renders as syncing, never a settled zeroget_coins[]— reads downstream as "a chain was consulted and this wallet is empty"1_599_000_000_000mojosget_sync_statusandwallet_coinsnow callreplica_covers_client_scope(&identity), which asksthe same containment question
replica_is_authoritativealready asks, but against the connectedclient's scope rather than the node's followed set.
The scope choice is the fix, not an incidental. Under §908 a node holds no custody and may hold
no registrations, so its followed set is EMPTY, every recording trivially contains it, and routing
these reads through
replica_is_authoritativewould have been vacuous — serving the uncoveredclient a synced zero exactly as before. See the revert-proof below.
dig_ecosystem#2666 — a refused writer reported
syncedA node whose session corroboration refused (
PeerTrust::Discovered) has every frame dropped beforeany DB write, including the peak. Its replica falls behind by an unbounded, invisible amount.
{phase: "synced", peak_height: <frozen>}, indefinitely, provided a catch-up hadcompleted in any earlier run. A user watching a node refused by every quorum round — a partition,
or a hostile peer set — was told everything was fine.
{phase: "syncing"}. TheSyncedarm now also requiresobserved.session_may_write.No new phase token, so
WalletSyncPhaseindig-node-control-interfaceis unchanged and noconsumer's whole-response parse can break. That was the explicit trade the ticket asked for, and it
avoids repeating dig_ecosystem#2609.
Blast radius checked (gitnexus, per-worktree index @ 5006593)
syncedwallet_coins,coins_by_ids,require_authoritative_coinswallet_coins) was re-pointed.coins_by_ids(arbitrary ids, covered by #227) andrequire_authoritative_coins(spend inputs) still route on it, deliberately.replica_is_authoritativebalance_for_address,coins_for_address,replica_coin_by_idreplica_covers(&self.followed_set()). The expression is identical, so all three address-scoped reads are behaviour-unchanged.get_sync_statusfollowed_setreplica_is_authoritativeonlyscoped_identitySyncHandle::statusSyncedarm.WARNING — HIGH/CRITICAL risk.
replica_is_authoritativeandsyncedboth sit on the money readpath. The change to the former is a pure extraction; the latter was left alone precisely because of
its blast radius.
Diff scope confirmed against
origin/main: two new private helpers, two routing call sites, oneladder condition, tests, SPEC, version. No other symbol touched.
Fixture design — why these tests can see the defect
uncovered_but_funded_clientneeds three properties simultaneously, and dropping any one makes it blind:db.is_synced() == true) — asserted in the fixture. Without it thepre-fix code already reports not-synced and the test proves nothing.
bb..) — an honest control. A fixturerecording no coverage would also pass against an implementation that merely demanded some
coverage exist, so the control is what forces the question to be asked about the client's scope.
"zero because unscoped" from "zero because empty" — which is the entire defect.
For #2666 the fixture is
a_completed_catch_up_still_reports_synced_while_watching_addresseswithexactly one input varied (
set_trust(false)); that control stays green, so the pair isolates therefusal itself. The watched set is deliberately non-empty so the fixture sits on the path that
actually reaches
Synced, and the peer tier isUNOBSERVABLEsois_followinganswerstrueandcannot be the thing that fails the assertion.
Both fixes also carry a positive control, so neither is satisfied by an implementation that simply
never reports synced.
Revert-proof (committed first, then reverted — never
git checkouton live work)replica_covers_client_scope(&identity)toreplica_is_authoritative()— the nearest wrong fix — turns both new tests red(
synced=0 total=0again). The tests pin the placement, not just the outcome.4acf298(committest(wallet): red ..., pushed before the implementation).left: Synced, right: Syncedon theassert_ne!.Fixtures corrected (they were under-specified, not wrong)
Four pre-existing fixtures latched a flag without stating what it meant, and the new predicates
correctly fail closed on them:
synced_get_coins_reads_from_db_not_fallbackandidentity_scoped_reads_return_client_balance_never_other_walletssetinitial_sync_completewithno recorded coverage; they now call
record_coveragefor the scope they intend to be covered.phase_is_syncing_when_caught_up_but_no_peerandphase_ladder_not_started_syncing_syncedattacha peer without resolving its trust; they now call
set_trust(true).Verification (run locally — the badge is not the evidence)
cargo test -p dig-wallet --lib→ 600 passed, 0 failed (on the squashed tree).cargo test -p dig-node-service→ all suites green, 0 failed.cargo clippy --all-targets --all-features -- -D warnings→ clean.cargo fmt --all→ applied.cargo llvm-cov -p dig-wallet --lib --summary-only→ 81.67% lines (gate:--fail-under-lines 80).Not run against a real chain. Every fixture here is
WalletDb::open_in_memory()plusMockFallback. The claim proven is that the routing no longer answers from an uncovered scope; theclaim NOT proven is that a live catch-up records coverage matching a real client's login scope on
mainnet. That needs a node with a chain source and a logged-in dig-app.
Version
0.126.1→0.126.2(patch). Two behaviour-correcting fixes; no API added, removed, or renamed,and no wire/contract token changed.
CHANGELOG.mdis left to git-cliff.SPEC
SPEC.md§18.6f gains the identity-scope containment invariant (replacing the "does NOT yet hold /tracked as #2878" sentence) and §18.6d gains the refused-writer invariant.
Not attempted in this batch, and why
three separate broadcast call sites in
rpc.rs(:2707,:2808with a caller-suppliedbroadcaster,
:3130), plus a release lifecycle and resubmission. A reservation that hooks some ofthem is a partial guard on the money path, which is worse than none.
status:paused.get_pending_transactionshardcoded empty) — the ticket itself makes itdepend on #2763. The alternative honest answer (an error instead of a false empty list) is a
behaviour change on a polled surface and deserves its own decision, not a drive-by.
substantial (custody derivation + subscription set + import scan-ahead).
status:paused.Epic #2760 must stay open: three of its five named children are unlanded.