Skip to content

fix(wallet): the coins reads say when their answer is not current (#490) - #492

Merged
MichaelTaylor3d merged 9 commits into
mainfrom
loop/490-stale-siblings
Sep 2, 2026
Merged

fix(wallet): the coins reads say when their answer is not current (#490)#492
MichaelTaylor3d merged 9 commits into
mainfrom
loop/490-stale-siblings

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #490

#454 taught control.wallet.balance to say when its answer is not current. Its siblings answer
from the same tier and said nothing, so the two states #416 exists to separate — there is
nothing there
and this node cannot see — were again indistinguishable one method over.

The reading the ticket was filed on, on installed node 0.206.0:

0 unspent coin(s) - complete
{"coins":[],"complete":true,"cursor":null,"source":"fallback","synced":false,"peak_height":null}

complete: true is a POSITIVE claim, emitted in the same breath as peak_height: null. A bare
0 merely fails to qualify itself; complete asserts that nothing was left out.

What changed

Wire (control.rs). coins_wire, coin_by_id_wire, coins_by_parent_wire,
coin_spend_wire and arrivals_wire each gained network_peak_height and stale_by, computed
by the SAME stale_by() helper balance uses — not a second contract. stale_by: 0 and
stale_by: null remain opposite claims: zero says level with the network, absence says
nothing bounds this.

arrivals had no tier fields at all, because it reads a local ledger. It now carries synced,
peak_height, network_peak_height and stale_by describing the chain replica that WRITES that
ledger, via a new replica_tier(). synced there is true only in the synced sync phase — the
phase that licenses serving wallet-scoped reads from the replica.

The per-handler inline peak read is now one held_peers_peak() helper; wallet_balance adopts
it, so there is one place that decides what "the network's peak" means.

Rendering (control_cli.rs). balance_freshness is renamed answer_freshness and now
serves five reads — it only ever read synced/peak_height/stale_by, which describe the TIER
rather than the subject, so one renderer serves them all.

  • page_suffix no longer prints a bare · complete from a tier that cannot bound its height; it
    prints · complete for what this node can see, followed by the freshness clause. The flag is
    still reported — it is true, and a pager needs it — but it is scoped to what the node saw.
  • coinById reserves no such coin on chain — an assertion about the CHAIN — for an answer whose
    tier can bound its own height. An unbounded tier says this node has no record of that coin.
    coinSpend gets the same treatment, being the fifth sibling of the identical defect.
  • coins, coinsByParent, arrivals, coinById and coinSpend all append the freshness clause.

coinSpend was not in the ticket's scope and is included anyway: it is the same one-line
change in the same two functions, and leaving it would reproduce exactly the "its siblings were
not taught" shape this ticket exists to close.

The two things the ticket asked to record

  1. The contradicted rustdoc is fixed. balance_for_address's doc listed synced = true on
    the db bullet and synced = false on the fallback bullet, as if synced were a routing
    outcome. It is computed separately, from replica_answer_is_current — a CURRENCY test — so
    {source: "db", synced: false} is real, reachable and common. The doc denied a state
    production produces, about precisely the answer the most useful CLI line renders.
  2. The syncStatus pointer is re-homed, not vanished. control.wallet.syncStatus can still
    emit {phase: "synced", peak_height: null}. Both of its former trackers are closed, so it is
    now control.wallet.syncStatus can still emit {phase: "synced", peak_height: null} — the pairing every money read now abolishes #495.

Blast radius

gitnexus's dig-node index is ~301 commits stale and returns a false-safe impactedCount: 0, so
this was measured by grep + direct read rather than by impact, and that is stated rather
than implied.

  • coins_wire / coin_by_id_wire / coins_by_parent_wire / coin_spend_wire / arrivals_wire
    — private to control.rs, one production call site each, all updated; the rest are tests.
  • balance_freshnessanswer_freshness — private to control_cli.rs, one caller.
  • page_suffix — private to control_cli.rs, two callers, both updated.
  • No public API, no crate boundary, no cross-repo contract. dig-node-control-interface is
    unchanged: all wire changes are ADDITIVE (§5.1), and the golden shape tests were updated rather
    than replaced so an old consumer's fields all still decode.

How verified

  • RED first, at the decision. Five new CLI tests were written against the unmodified renderer
    and failed on the real pre-fix output: 0 unspent coin(s) · complete, no such coin on chain,
    0 arrival(s) · cursor 0. Run: 37 passed, 5 failed, 710 filtered out — a real count, not a
    filter that matched nothing.
  • GREEN: 753 passed, 0 failed, 0 filtered out (cargo test -p dig-node-service --lib).
  • Two mutations, both killed, run against a committed tree so no work was at risk:
    • stale_by forced to 0 in all five wire functions → the wire test fails on the named gap.
    • stale_by computed as network_peak - answer_height.unwrap_or(0) — the exact wrong
      implementation that collapses null into a number, and the one that passes a
      fields-are-present test → fails on assert_eq!(unbounded["stale_by"], null).
  • The fixtures use the ticket's own measured heights (9_211_798 / 9_220_177 / gap 8_379), not
    round numbers, so a fixture that lost a digit would not still arithmetic out. Each read is
    exercised at all four points — level, behind, no answer height, no network peak — with an
    explicit assert_ne! between level and unbounded, because that pair IS the contract.
  • Each CLI test keeps a synced control that must NOT be marked, so none can be satisfied by an
    implementation that scare-marks everything.
  • cargo fmt -p dig-node-service -p dig-wallet -- --check clean (run unpiped, exit read directly).
  • cargo clippy -p dig-node-service -p dig-wallet --all-targets -- -D warnings clean.
  • origin/main merged in (git merge, never rebase); version re-read from disk afterwards.

Not yet verified: the live dign wallet coins run on a real machine. The ticket's acceptance
bar names one, and this PR does not carry it — the change is a pure rendering-and-wire change with
no runtime path of its own, but that is an argument, not evidence. Called out rather than glossed.

SemVer — minor, 0.236.0

Five result shapes gain additive fields and five CLI summaries gain a clause. Nothing is removed,
renamed or repurposed, and no existing field changes meaning, so this is a compatible new
capability. origin/main was at 0.235.0 when this branch merged it; the version was re-read from
Cargo.toml on disk after the merge, not from the commit log.

SPEC.md

The five affected rows in the control-method table now state the null-versus-zero rule normatively,
that complete scopes the PAGE and never the chain, and that a consumer MUST NOT present
coin: null / spend: null as a statement about the chain while stale_by is null.

Stub commit so the lane is resumable. WIP.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Comment thread crates/dig-node-service/src/control_cli.rs Dismissed
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane resumed — both merge blockers cleared

Head: 76f7464 on loop/490-stale-siblings (was 4c8ab1a9; force-pushed once, see below)
Version: 0.240.0 (root [workspace.package].version + Cargo.lock). Moved off 0.236.0, which collided with PR #467; 0.2360.239 are claimed by #467/#494/#456/#489.

1. Lint commit messages: FAILURE — cleared

Two commits carried headers that are not Conventional-Commit types, and commitlint lints every commit in the PR:

  • 17bfce7c wip(wallet): …a34ddd6 feat(wallet): add staleness fields + rendering for the coin reads (#490)
  • 21b583fb green: …e1c7598 feat(wallet): mark staleness on all coin reads (#490)

Rewritten with git commit-tree, preserving every tree object across the main merge commit — git diff 4c8ab1a 1697b5a was empty, so the reword changed message text only. Force-pushed with --force-with-lease against the exact prior remote SHA (a pre-merge lane branch, the one sanctioned case).

2. CodeQL: FAILURE + 13 unresolved GHAS threads — cleared as false positives

All 13 alerts (234246, rust/cleartext-logging, severity high) point at crates/dig-node-service/src/control_cli.rs lines 2850–2981. Every one of those lines is an assert! failure message inside #[cfg(test)] mod tests (the module opens at control_cli.rs:1717).

The interpolated value in each case is the output of summarize() over a hand-written json! fixture holding coin counts, block heights and staleness markers — the CLI's own user-requested output, not secret material. I read each alert individually: no key, seed, mnemonic, password or token is in scope on any of the 13 lines, and the code does not execute outside cargo test. Nothing here is a real defect, so no code fix was warranted.

Disposition: 13 justified as false positives, 0 fixed. Each alert was dismissed in the security tab (false positive, with the reasoning above), and each review thread received the same justification as a reply before being resolved — reply first, then resolve, so the audit trail survives.

Blast radius

No production code was edited in this resume. The reword touched commit metadata only (trees byte-identical), and the version bump touches Cargo.toml + Cargo.lock alone. The staleness rendering itself is unchanged from the gated diff.

State

CI re-running on 76f7464. Not undrafted, not merged — that is the orchestrator's call.

READY FOR GATES (reviewer + security) once checks land green.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 03:36
@MichaelTaylor3d
MichaelTaylor3d merged commit adf03d8 into main Sep 2, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/490-stale-siblings branch September 2, 2026 05:12
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
…to 0.249.0

Brings origin/main (0.245.0, including #467, #489, #492, #497) onto the
branch and sets the workspace version to the pre-assigned 0.249.0.

Conflicts and how they were resolved:

- `Cargo.toml` — a pure version collision (branch 0.242.0 vs main
  0.245.0). Every other main-side hunk was already applied by the
  auto-merge; the only difference from `origin/main` in this file is the
  version line, now 0.249.0.
- `Cargo.lock` — taken wholesale from `origin/main`, then re-locked with
  `cargo update -w`, which re-points the two workspace members whose
  manifests moved (`dig-node-service` 0.245.0 -> 0.249.0, `dig-wallet`
  0.47.0 -> 0.48.0). Nothing in the tree still reads 0.242.0.
- `crates/dig-wallet/src/sage/rpc.rs` — reported as a conflict by an
  earlier attempt; on this merge git resolved it textually because the
  two sides touch disjoint regions of the file. The result was read
  against BOTH parents rather than accepted on git's word:

  * MAIN's hunks are intact. `is_definitive_rejection` keeps the #497
    narrowing — a refusal frees inputs only when its stated reason is
    bundle-intrinsic (`super::chain::refusal_is_bundle_intrinsic`), with
    a HOLD default — and the #492 doc block stating that `synced` is a
    CURRENCY test computed independently of the routing tier, so
    `{source: "db", synced: false}` is a reachable state.
  * THE BRANCH's hunk is intact. `replica_answer_is_current` still
    delegates to `sync_supervisor::FollowingEvidence::measure`, which
    withholds the evidence when EITHER the replica or the peer height is
    unmeasured, so a `synced` phase cannot be emitted without the peak
    height that bounds it (#495).
  * No rival implementation survives the merge. The pre-#495
    `is_following` predicate is gone from the tree; `FollowingEvidence`
    is the single producer consumed by both the money reads
    (`rpc.rs:1085`) and the status endpoint
    (`sync_supervisor.rs:490`), which is what makes the
    `{phase: "synced", peak_height: null}` pairing unrepresentable
    rather than merely unlikely.

No behaviour was chosen over the other side: both guards are load-bearing
on different questions — one on whether a refusal may free inputs, the
other on whether a currency claim may be made at all.

dig-wallet: 772 passed, 0 failed, 1 ignored.
dig-node-service: 774 passed, 0 failed.

Note: `cargo test` on this Windows host needs RUST_MIN_STACK raised
(default hits a rustc STATUS_STACK_BUFFER_OVERRUN ICE while encoding
dig-node-service metadata) — an environment limit, not a code fault.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants