Skip to content

fix(dig-wallet): report which tier answered a wallet balance read - #189

Open
MichaelTaylor3d wants to merge 3 commits into
mainfrom
fix/2233-tier-source
Open

fix(dig-wallet): report which tier answered a wallet balance read#189
MichaelTaylor3d wants to merge 3 commits into
mainfrom
fix/2233-tier-source

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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, the Source::Fallback arm, read synced and peak_height outside the tier decision:

Ok(WalletBalanceResult { balance, pending, synced: db_synced, peak_height })

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: true with a non-null peak_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

  1. source: "db" | "fallback" on WalletBalanceResult and on the control.wallet.balance wire result. Additive per §5.1 — a consumer that ignores it parses unchanged, asserted by a test.
  2. synced and peak_height moved inside the tier arms. The Fallback arm reports false / null, because that is the truth about the answer it just gave. The Db arm reads the peak inside its own arm, so the query is not even issued on the fallback path.
  3. A tracing event at the routing branch carrying tier=db|fallback, so dig-node.jsonl records the same tier the wire reports. No eprintln! — a Windows service discards stderr.
  4. SPEC §18.7b states the contract normatively, including why acceptance for any sync work must name the tier and not the flag.
  5. S0 doc fix: sage/service.rs cited §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

synced and peak_height change value on the fallback path: previously the DB's values, now always false / 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 on synced will see different values from the same node state. balance and pending are 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, new WalletReadSource enum), 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 a source defined only in dig-node would become a second literal for dig-app to mirror by hand: exactly the drift #2214 documents for the -3204x error 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 — gained Serialize + as_wire(). Purely additive; route()'s signature, axes and behaviour are untouched.
  • Cross-repo: dig-app reads {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 for upsert_derivation. Noted while in the file, left alone as instructed: rpc.rs:646 and rpc.rs:780 pass a literal true for the scoped axis while the balance path passes the real value. This change does not make that harder to fix — route keeps its exact signature, and the new source value is simply whatever route already returned.

Evidence

cargo fmt --all --check clean. cargo clippy -p dig-wallet -p dig-node-service --all-targets -- -D warnings clean. cargo check --workspace --all-targets clean. 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 checkout revert is destructive on uncommitted work).

# Mutation Result
MUT1 Fallback arm restored to synced: db_synced + the DB peak (the original defect) KILLEDa_fallback_served_read_never_reports_the_dbs_sync_state, on the synced assertion
MUT2 Fallback arm keeps synced: false but restores the DB peak KILLED — same test, on peak_height: left: Some(9000000), right: None
MUT3 Db arm reports source: Fallback KILLEDa_db_served_read_reports_the_db_tier_and_the_replicas_peak: left: Fallback, right: Db
MUT4 balance_wire drops the source key KILLED — both balance_wire_discloses_the_answering_tier_additively and the golden-shape test

All 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_state uses a DB that is set_initial_sync_complete(true) with peak 9_000_000, while querying an unscoped address so routing still picks Fallback. 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_peak asserts the arm, not an end-to-end path. The scoped_to_wallet axis is db.derivation_exists, and upsert_derivation has no production caller — every call site is inside #[cfg(test)]. So on a shipped node the derivations table is empty, scoped is always false, and balance_for_address never reaches the Db arm 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.40.100.0 (workspace/binary) and dig-wallet 0.12.20.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 --shortstat and --ignore-cr-at-eol agree (248/50) — no CRLF flip.


Rebase onto main (2026-08-07) — conflict resolution

The PR had gone DIRTY. The conflict was purely mechanical and confined to Cargo.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/main reported conflicts in exactly those two files and nothing else. Resolution took main's Cargo.toml and Cargo.lock wholesale (git checkout origin/main -- Cargo.toml Cargo.lock), then re-applied only the two version facts this PR owns. The lock was regenerated with cargo update --workspace --offline, not hand-merged — offline so the resolution could not silently pull unrelated dependency movement, and it reported Locking 2 packages, exactly the two below.

The resulting diff against main for those two files is only:

main here
[workspace.package].version 0.100.1 0.101.0
dig-wallet 0.12.2 0.13.0

Version 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.0 rather than the 0.100.0 written above.

Not superseded. git log 4aa61bb..origin/main -- crates/dig-wallet/ crates/dig-node-service/src/control.rs is empty — nothing landed on main in 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 main is byte-for-byte the same 8-file shape as before (275 insertions, 50 deletions). Confirmed disjoint from the two concurrent lanes: nothing under crates/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-service322 passed, 0 failed across all targets.
  • cargo fmt --all --check — clean.
  • cargo clippy --workspace --all-targets -- -D warnings — clean.
  • mergeStateStatus moved DIRTYMERGEABLE, and the check run's headSha matches the pushed head, so the checks below genuinely ran against this content rather than being a stale list carried over from a conflicting state.

@MichaelTaylor3d MichaelTaylor3d changed the title fix(dig-wallet): report which tier answered a wallet read fix(dig-wallet): report which tier answered a wallet balance read Aug 6, 2026
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 6, 2026 05:43
@MichaelTaylor3d
MichaelTaylor3d force-pushed the fix/2233-tier-source branch 4 times, most recently from a180408 to 001f3aa Compare August 6, 2026 06:33

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_synced and scoped are consumed only by route() (and scoped additionally to discriminate NotSynced vs NoChainSource, which is legitimate). The Fallback arm reads no DB state at all — the sync_state() query moved inside the Db arm, 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_tier opens a default in-memory DB (unsynced, peak None) and does not assert synced at all; under MUT1 (synced: db_synced + DB peak) it produces synced:false, peak:None and still passes. Only the set_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-true call sites survive unchanged at rpc.rs:674 / rpc.rs:808; no production caller was added for upsert_derivation (all four sites remain db.rs:448 definition + 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.rs now cites §18.6 / §18.12a). --shortstat and --ignore-cr-at-eol agree at 248/50. Refs, not a qualified Closes.

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.

Comment thread crates/dig-wallet/src/sage/rpc.rs Outdated
Comment thread SPEC.md
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Fixed in 298758b, and the finding was sharper than a log level.

You caught that SPEC §18.7b's normative sentence was false on every default installdig-logging's baked-in default is info, a stock node sets none of the overrides, so the debug! never emitted. And it lands on precisely the leg this PR exists to provide: #2232's acceptance criterion is evidence from dig-node.jsonl, so an acceptance run at default level finds nothing and could read the silence as "no fallback occurred." A false-green inside the acceptance test itself — the exact failure this whole stage was meant to make impossible.

Split by level rather than promoting both, weighing the amplification point you raised:

  • Fallback → info. The exceptional path, it means the read was disclosed to a third-party oracle, and it is the evidence the acceptance depends on.
  • DB → debug. Once the sync loop lands that is the ordinary path, and logging every local read at info on an OPEN unauthenticated loopback endpoint is a log-volume lever.

SPEC §18.7b now states the level and the reasoning, so a later reader does not restore debug! for tidiness — which is how the original would have come back.

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 arbitrary_address_uses_fallback_tier uses a default unsynced DB and never asserts synced, so under MUT1 it passes. Only the synced-DB-plus-unscoped-address fixture discriminates. That is why it survived, and it means the old test asserting synced == db state was encoding the defect — deleting it rather than keeping it was right.

MUT5's survival was diagnosed correctly. The back-compat property lives in Option, not in #[serde(default)]. A guard whose removal survives a mutation is decoration, and leaving it in would have misdirected the next reader about where the property actually lives.

Interface #5 and docs #68 are unblocked on your verdicts; merging those now.

MichaelTaylor3d and others added 2 commits August 6, 2026 07:22
`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>

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness gate: PASS (head 8a90de285c91032a756ea9037307c73cdb6eb29e).

Verified, not taken on trust:

  1. The reported tier is the tier that answered. routing::route() is called ONCE into source, and that same value both drives the match and is written into the result. There are exactly two WalletBalanceResult construction sites in production (rpc.rs:572, rpc.rs:616), each inside its own arm; the struct has no Default and all fields are required, so no partial init / ..Default::default() path exists. No cache, no early Ok. The label cannot drift from the arm by construction.
  2. synced/peak_height in the fallback arm. Hard-coded false/None; the DB sync_state() read moved INSIDE the Db arm and db_synced no longer reaches the fallback result.
  3. 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_state and arbitrary_address_uses_fallback_tier. Not circular: the same test pins balance == 42 (the coinset figure), which discriminates the arm independently of the label.
  4. Fixture discriminates. The instrument test uses a synced DB with peak 9_000_000 and an unscoped address — the fixture the pre-fix code answers wrongly. An unsynced-DB fixture would have been honest by coincidence.
  5. Log-level fix holds against the real default. dig-logging/src/filter.rs:22 DEFAULT_DIRECTIVE = "info,hyper=warn,...", resolved as the fallthrough when no persisted/DIG_LOG/RUST_LOG value is set. Fallback at info! is therefore emitted on a stock node, so SPEC §18.7b's operator-visibility sentence is true. DB at debug! is a defensible open-endpoint log-amplification trade and is documented as such.
  6. Wire compatibility. #[serde(rename_all = "lowercase")] on Source, pinned literally by tier_serializes_and_logs_as_the_same_lowercase_wire_string; balance_wire_discloses_the_answering_tier_additively proves an unaware consumer still deserializes. dig-wallet 0.12.2 → 0.13.0 is 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.

Comment thread crates/dig-wallet/src/sage/rpc.rs
Comment thread crates/dig-node-service/src/control.rs
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.

1 participant