fix(dig-wallet): report which tier answered a wallet balance read - #189
fix(dig-wallet): report which tier answered a wallet balance read#189MichaelTaylor3d wants to merge 3 commits into
Conversation
002a736 to
7b380b0
Compare
a180408 to
001f3aa
Compare
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES-REQUIRED (one finding) — reviewed at 001f3aa2a29cc58fe4aa614fee3bd81bced8587c
The wire half of this PR is correct and the evidence holds up. I verified, at source:
- Both arms are truthful.
db_syncedandscopedare consumed only byroute()(andscopedadditionally to discriminateNotSyncedvsNoChainSource, which is legitimate). TheFallbackarm reads no DB state at all — thesync_state()query moved inside theDbarm, so it is not even issued on the fallback path. No residual read survives. - The load-bearing-fixture claim is true by construction, not merely asserted.
arbitrary_address_uses_fallback_tieropens a default in-memory DB (unsynced, peakNone) and does not assertsyncedat all; under MUT1 (synced: db_synced+ DB peak) it producessynced:false, peak:Noneand still passes. Only theset_initial_sync_complete(true)+set_peak(9_000_000)+ unscoped-address fixture separates the fix from the defect. The claim that the pre-existing suite was measurably blind to this defect is correct, and it is the most important sentence in the PR body. - The wire-spelling placement is the right call.
git grep dig-node-control-interface -- '*.toml'on this head returns nothing: dig-node genuinely takes no dependency on the crate, so no release-first ordering applies, and defining the token in the contract crate (rather than as a second dig-node literal for dig-app to mirror) is exactly the #2214 lesson applied. - S2 boundary respected.
routing::route's signature and axes are untouched; the two literal-truecall sites survive unchanged atrpc.rs:674/rpc.rs:808; no production caller was added forupsert_derivation(all four sites remaindb.rs:448definition + three#[cfg(test)]call sites). - The non-proof disclosure is accurate. The DB-arm test is fixture-only, and it says so in its own doc comment. The fallback-side assertions do cover the path every shipped node takes today.
- S0 folded in correctly (
service.rsnow cites §18.6 / §18.12a).--shortstatand--ignore-cr-at-eolagree at 248/50.Refs, not a qualifiedCloses.
The one gating item is the operator-visibility leg, inline below: the disclosure event is emitted at debug! while the node's baked-in default filter is info, so SPEC §18.7b's normative sentence is false on every default install — and #2232's acceptance criterion is specifically "evidence from dig-node.jsonl". Cheap either way: raise the level, or state the level in the SPEC.
A second, non-gating note is attached and resolved by me.
|
Fixed in You caught that SPEC §18.7b's normative sentence was false on every default install — Split by level rather than promoting both, weighing the amplification point you raised:
SPEC §18.7b now states the level and the reasoning, so a later reader does not restore Two of your verifications are worth repeating because they change what we know: The pre-existing suite genuinely could not have caught this. You confirmed MUT5's survival was diagnosed correctly. The back-compat property lives in Interface #5 and docs #68 are unblocked on your verdicts; merging those now. |
`balance_for_address` read `synced` and `peak_height` OUTSIDE the tier decision, so a balance served by the coinset HTTP fallback reported the local DB's sync state -- which describes nothing about where the figure came from. It is honest today only by accident, because the DB is also unsynced. Once the SPEC 18.6 sync loop sets `initial_sync_complete`, the same call would report a synced local read while a third-party oracle, and the address disclosure it entails, remained fully in the path. Add an additive `source: "db" | "fallback"` to the result and move `synced`/`peak_height` inside the tier arms. The fallback arm now reports `false`/`null`, because that is the truth about the answer it just gave. Emit `tier=db|fallback` through `tracing` at the routing branch so `dig-node.jsonl` records the same tier the wire reports. `synced` and `peak_height` change value on the fallback path. Both were wrong -- they described a source that did not produce the figure -- so this is a fix rather than a break, but it is a visible behaviour change for a consumer keying on `synced`. `balance` and `pending` are unchanged. This is the instrument the rest of the sync plan is measured with. The acceptance criterion originally written for the sync loop -- "`synced: true` with a non-null `peak_height`" -- is satisfiable with the goal completely unmet, because a flag can flip while the oracle keeps answering. A success criterion phrased as a flag value rather than as the path taken is a false-green generator; `source` is what makes a falsifiable acceptance test possible. Also corrects `sage/service.rs`, which cited SPEC 18.12 for the sync loop. 18.12 is the shipped live broadcaster; the loop is 18.6, deferred by 18.12a. That mis-citation sent three separate readers to the wrong clause and made the sync work look like a wiring job. The wire spelling is defined in `dig-node-control-interface` rather than ad hoc here, so dig-app has one symbol to consume instead of a second hand-mirrored literal. Refs DIG-Network/dig_ecosystem#2233 -- deliberately NOT an auto-closing keyword: the ticket closes on release + pointer bump, not on merge. Co-Authored-By: Claude <noreply@anthropic.com>
…mits The tier disclosure was `tracing::debug!`, but dig-logging's baked-in default is `info` (dig-logging filter.rs:21) and a stock install sets none of the overrides. So the event was invisible on every default node -- and SPEC 18.7b's normative "dig-node.jsonl records the same tier the wire reports" was false in the field. That lands on exactly the leg this change exists to provide: #2232's acceptance criterion is evidence from dig-node.jsonl, so an acceptance run at default level would find nothing and could read the absence as "no fallback occurred". A false-green in the acceptance test itself. Split by level rather than promoting both. FALLBACK is `info`: it is the exceptional path, it means the read was disclosed to a third-party oracle, and it is the evidence the acceptance depends on. DB stays `debug`: once the sync loop lands that is the ordinary path, and logging every local read at `info` would amplify an OPEN unauthenticated loopback endpoint into a log-volume lever. SPEC 18.7b now states the level and why, so the next reader does not restore a `debug!` for tidiness. Co-Authored-By: Claude <noreply@anthropic.com>
298758b to
3439cca
Compare
# Conflicts: # Cargo.lock # Cargo.toml
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Correctness gate: PASS (head 8a90de285c91032a756ea9037307c73cdb6eb29e).
Verified, not taken on trust:
- The reported tier is the tier that answered.
routing::route()is called ONCE intosource, and that same value both drives thematchand is written into the result. There are exactly twoWalletBalanceResultconstruction sites in production (rpc.rs:572,rpc.rs:616), each inside its own arm; the struct has noDefaultand all fields are required, so no partial init /..Default::default()path exists. No cache, no earlyOk. The label cannot drift from the arm by construction. synced/peak_heightin the fallback arm. Hard-codedfalse/None; the DBsync_state()read moved INSIDE theDbarm anddb_syncedno longer reaches the fallback result.- Falsifiability (probed in a scratch worktree, restored). Mutating the fallback arm to
source: Source::Db,synced: db_synced,peak_height: <db peak>turns 2 tests RED:a_fallback_served_read_never_reports_the_dbs_sync_stateandarbitrary_address_uses_fallback_tier. Not circular: the same test pinsbalance == 42(the coinset figure), which discriminates the arm independently of the label. - Fixture discriminates. The instrument test uses a synced DB with peak
9_000_000and an unscoped address — the fixture the pre-fix code answers wrongly. An unsynced-DB fixture would have been honest by coincidence. - Log-level fix holds against the real default.
dig-logging/src/filter.rs:22DEFAULT_DIRECTIVE = "info,hyper=warn,...", resolved as the fallthrough when no persisted/DIG_LOG/RUST_LOGvalue is set. Fallback atinfo!is therefore emitted on a stock node, so SPEC §18.7b's operator-visibility sentence is true. DB atdebug!is a defensible open-endpoint log-amplification trade and is documented as such. - Wire compatibility.
#[serde(rename_all = "lowercase")]onSource, pinned literally bytier_serializes_and_logs_as_the_same_lowercase_wire_string;balance_wire_discloses_the_answering_tier_additivelyproves an unaware consumer still deserializes.dig-wallet 0.12.2 → 0.13.0is the correct bump — adding a pub field to a pub-field struct with no#[non_exhaustive]is breaking, and on 0.x the minor slot IS the breaking slot.
Three non-blocking notes posted inline / below; one of them is a family obligation the orchestrator should not lose.
Refs DIG-Network/dig_ecosystem#2233 (deliberately NOT an auto-closing keyword — per CLAUDE.md §1.3 the ticket closes on release + pointer bump, not on merge). S1 of the #2232 sync plan — the instrument every later stage's acceptance test depends on.
The defect
crates/dig-wallet/src/sage/rpc.rs, theSource::Fallbackarm, readsyncedandpeak_heightoutside the tier decision:So a balance served by the coinset HTTP fallback reported the local DB's sync state — which describes nothing about where the figure came from. It is honest today only by accident, because the DB is also unsynced. The moment the §18.6 sync loop sets
initial_sync_complete, this call returns{"synced": true, "peak_height": <n>}while still answering from a third-party oracle, with the network round trip and the address disclosure fully intact.That is not a cosmetic issue: it makes #2232's stated acceptance criterion ("
synced: truewith a non-nullpeak_height") satisfiable with the goal completely unmet. A success criterion phrased as a flag value rather than as the path taken is a false-green generator.What changed
source: "db" | "fallback"onWalletBalanceResultand on thecontrol.wallet.balancewire result. Additive per §5.1 — a consumer that ignores it parses unchanged, asserted by a test.syncedandpeak_heightmoved inside the tier arms. TheFallbackarm reportsfalse/null, because that is the truth about the answer it just gave. TheDbarm reads the peak inside its own arm, so the query is not even issued on the fallback path.tracingevent at the routing branch carryingtier=db|fallback, sodig-node.jsonlrecords the same tier the wire reports. Noeprintln!— a Windows service discards stderr.sage/service.rscited §18.12 for the sync loop. §18.12 is the shipped live broadcaster; the loop is §18.6, deferred by §18.12a. That one mis-citation sent three separate readers to the wrong clause and made #2232 look like a wiring job. Corrected, with a note so it does not regress.Behaviour change, stated explicitly
syncedandpeak_heightchange value on the fallback path: previously the DB's values, now alwaysfalse/null. Both were wrong — they described a source that did not produce the figure — so this is a fix, not a break. It is called out because a consumer keying onsyncedwill see different values from the same node state.balanceandpendingare unchanged.Wire spelling — decided against the contract crate, not ad hoc
The field is defined in
dig-node-control-interface(DIG-Network/dig-node-control-interface#5, 0.4.0 → 0.5.0, newWalletReadSourceenum), not invented here. dig-node does not currently depend on that crate — it hand-rolls the balance JSON — but dig-app does consume it, and asourcedefined only in dig-node would become a second literal for dig-app to mirror by hand: exactly the drift #2214 documents for the-3204xerror codes. The two spellings are identical lowercase tokens, each pinned by a test in its own repo. No release-first ordering is required here, because dig-node takes no dependency on the crate in this PR.Blast radius checked
gitnexus is disabled per the standing §2.0 override; radius established by exhaustive grep + direct read.
WalletBalanceResult— every construction and consumption site:rpc.rs(the two arms),control.rs:balance_wire(the only wire mapper), and three#[cfg(test)]sites. All updated. No other crate constructs it.routing::Source— gainedSerialize+as_wire(). Purely additive;route()'s signature, axes and behaviour are untouched.{balance}only, so it is unaffected until it opts in. docs.dig.net documented the old field meanings — fixed in docs(run-a-node): document which source answered a wallet balance read docs.dig.net#68, same pass.Risk: LOW. No custody path, no signing path, no spend path, no peer-facing wire. One open loopback read, whose numeric outputs are unchanged.
Deliberately NOT touched (S2 / #2234)
routing::route's axes are unchanged, and no caller was added forupsert_derivation. Noted while in the file, left alone as instructed:rpc.rs:646andrpc.rs:780pass a literaltruefor the scoped axis while the balance path passes the real value. This change does not make that harder to fix —routekeeps its exact signature, and the newsourcevalue is simply whateverroutealready returned.Evidence
cargo fmt --all --checkclean.cargo clippy -p dig-wallet -p dig-node-service --all-targets -- -D warningsclean.cargo check --workspace --all-targetsclean. dig-wallet 308 + 22 tests green; dig-node-service 322 + 119 tests green.Every new test is pure logic with no
#[cfg(...)]gate, no filesystem, no clock and no network, so none is vacuous on the ubuntu-only gating runner.Mutation battery — 4 mutations, 4 killed by assertion, none hung
Each mutation was read back off disk before running, and the branch was committed first (a
git checkoutrevert is destructive on uncommitted work).synced: db_synced+ the DB peak (the original defect)a_fallback_served_read_never_reports_the_dbs_sync_state, on thesyncedassertionsynced: falsebut restores the DB peakpeak_height:left: Some(9000000), right: Nonesource: Fallbacka_db_served_read_reports_the_db_tier_and_the_replicas_peak:left: Fallback, right: Dbbalance_wiredrops thesourcekeybalance_wire_discloses_the_answering_tier_additivelyand the golden-shape testAll four failed on an assertion with a value diff, not a timeout. No hanging mutants.
The fixture is the load-bearing part, and it is proven so
a_fallback_served_read_never_reports_the_dbs_sync_stateuses a DB that isset_initial_sync_complete(true)with peak9_000_000, while querying an unscoped address so routing still picksFallback. That combination is what distinguishes the fix from the nearest wrong implementation.Proven, not asserted: under MUT1, the neighbouring test
arbitrary_address_uses_fallback_tier— which uses the ordinary unsynced-DB fixture, as every pre-existing balance test did — still passes. The narrow fixture is measurably blind to the exact defect this PR fixes. That blindness is how the defect survived: the shipped DB is unsynced, so the wrong code and the right code agree on every fixture anyone had built.What is NOT proven — the DB-side assertion is fixture-only
a_db_served_read_reports_the_db_tier_and_the_replicas_peakasserts the arm, not an end-to-end path. Thescoped_to_walletaxis isdb.derivation_exists, andupsert_derivationhas no production caller — every call site is inside#[cfg(test)]. So on a shipped node thederivationstable is empty,scopedis alwaysfalse, andbalance_for_addressnever reaches theDbarm at all. The test writes the derivation directly through the fixture helper.This is stated in the test's own doc comment as well as here, so it cannot later be mistaken for end-to-end coverage. It becomes reachable when #2234 replaces the routing axis with a production-written subscription watermark. The fallback-side assertions, by contrast, cover the path every shipped node actually takes today.
Version
0.99.4→0.100.0(workspace/binary) anddig-wallet0.12.2→0.13.0. Minor: an additive wire field is a compatible new capability, and the public struct gaining a field is breaking for Rust struct-literal construction under 0.x.git diff --shortstatand--ignore-cr-at-eolagree (248/50) — no CRLF flip.Rebase onto
main(2026-08-07) — conflict resolutionThe PR had gone
DIRTY. The conflict was purely mechanical and confined toCargo.toml+Cargo.lock— every substantive file (SPEC.md,control.rs,rpc.rs,routing.rs,service.rs) auto-merged with no manual edit.git merge origin/mainreported conflicts in exactly those two files and nothing else. Resolution tookmain'sCargo.tomlandCargo.lockwholesale (git checkout origin/main -- Cargo.toml Cargo.lock), then re-applied only the two version facts this PR owns. The lock was regenerated withcargo update --workspace --offline, not hand-merged — offline so the resolution could not silently pull unrelated dependency movement, and it reportedLocking 2 packages, exactly the two below.The resulting diff against
mainfor those two files is only:[workspace.package].version0.100.10.101.0dig-wallet0.12.20.13.0Version restated for the new base. The bump rationale is unchanged (additive wire field + a public struct gains a field ⇒ minor); only the base moved, so the target is now
0.101.0rather than the0.100.0written above.Not superseded.
git log 4aa61bb..origin/main -- crates/dig-wallet/ crates/dig-node-service/src/control.rsis empty — nothing landed onmainin this PR's area while it sat.Blast radius checked
The merge changed no code. The pre-rebase blast radius therefore stands unaltered, and the post-rebase diff against
mainis byte-for-byte the same 8-file shape as before (275 insertions, 50 deletions). Confirmed disjoint from the two concurrent lanes: nothing undercrates/dig-node-core(PR #197) and nothing under.github/workflows/(the #2290 P0 lane).Verified after the rebase
cargo test -p dig-wallet -p dig-node-service— 322 passed, 0 failed across all targets.cargo fmt --all --check— clean.cargo clippy --workspace --all-targets -- -D warnings— clean.mergeStateStatusmovedDIRTY→MERGEABLE, and the check run'sheadShamatches the pushed head, so the checks below genuinely ran against this content rather than being a stale list carried over from a conflicting state.