fix(wallet): the coins reads say when their answer is not current (#490) - #492
Conversation
Stub commit so the lane is resumable. WIP. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # Cargo.lock # Cargo.toml
4c8ab1a to
1697b5a
Compare
Co-Authored-By: Claude <noreply@anthropic.com>
Lane resumed — both merge blockers clearedHead: 1.
|
Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # Cargo.lock # Cargo.toml
…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>
Closes #490
#454taughtcontrol.wallet.balanceto say when its answer is not current. Its siblings answerfrom the same tier and said nothing, so the two states
#416exists to separate — there isnothing 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:
complete: trueis a POSITIVE claim, emitted in the same breath aspeak_height: null. A bare0merely fails to qualify itself;completeasserts that nothing was left out.What changed
Wire (
control.rs).coins_wire,coin_by_id_wire,coins_by_parent_wire,coin_spend_wireandarrivals_wireeach gainednetwork_peak_heightandstale_by, computedby the SAME
stale_by()helperbalanceuses — not a second contract.stale_by: 0andstale_by: nullremain opposite claims: zero says level with the network, absence saysnothing bounds this.
arrivalshad no tier fields at all, because it reads a local ledger. It now carriessynced,peak_height,network_peak_heightandstale_bydescribing the chain replica that WRITES thatledger, via a new
replica_tier().syncedthere is true only in thesyncedsync phase — thephase that licenses serving wallet-scoped reads from the replica.
The per-handler inline peak read is now one
held_peers_peak()helper;wallet_balanceadoptsit, so there is one place that decides what "the network's peak" means.
Rendering (
control_cli.rs).balance_freshnessis renamedanswer_freshnessand nowserves five reads — it only ever read
synced/peak_height/stale_by, which describe the TIERrather than the subject, so one renderer serves them all.
page_suffixno longer prints a bare· completefrom a tier that cannot bound its height; itprints
· complete for what this node can see, followed by the freshness clause. The flag isstill reported — it is true, and a pager needs it — but it is scoped to what the node saw.
coinByIdreservesno such coin on chain— an assertion about the CHAIN — for an answer whosetier can bound its own height. An unbounded tier says
this node has no record of that coin.coinSpendgets the same treatment, being the fifth sibling of the identical defect.coins,coinsByParent,arrivals,coinByIdandcoinSpendall append the freshness clause.coinSpendwas not in the ticket's scope and is included anyway: it is the same one-linechange 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
balance_for_address's doc listedsynced = trueonthe db bullet and
synced = falseon the fallback bullet, as ifsyncedwere a routingoutcome. 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 stateproduction produces, about precisely the answer the most useful CLI line renders.
syncStatuspointer is re-homed, not vanished.control.wallet.syncStatuscan stillemit
{phase: "synced", peak_height: null}. Both of its former trackers are closed, so it isnow 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, sothis was measured by grep + direct read rather than by
impact, and that is stated ratherthan 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_freshness→answer_freshness— private tocontrol_cli.rs, one caller.page_suffix— private tocontrol_cli.rs, two callers, both updated.dig-node-control-interfaceisunchanged: 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
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 afilter that matched nothing.
cargo test -p dig-node-service --lib).stale_byforced to0in all five wire functions → the wire test fails on the named gap.stale_bycomputed asnetwork_peak - answer_height.unwrap_or(0)— the exact wrongimplementation that collapses
nullinto a number, and the one that passes afields-are-present test → fails on
assert_eq!(unbounded["stale_by"], null).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.implementation that scare-marks everything.
cargo fmt -p dig-node-service -p dig-wallet -- --checkclean (run unpiped, exit read directly).cargo clippy -p dig-node-service -p dig-wallet --all-targets -- -D warningsclean.origin/mainmerged in (git merge, never rebase); version re-read from disk afterwards.Not yet verified: the live
dign wallet coinsrun on a real machine. The ticket's acceptancebar 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/mainwas at 0.235.0 when this branch merged it; the version was re-read fromCargo.tomlon 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
completescopes the PAGE and never the chain, and that a consumer MUST NOT presentcoin: null/spend: nullas a statement about the chain whilestale_byisnull.