From 0a27f18660ea2107dcec399514e4072816f1e425 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 15:44:03 -0700 Subject: [PATCH 1/5] =?UTF-8?q?chore(wallet):=20open=20#490=20=E2=80=94=20?= =?UTF-8?q?teach=20the=20coins=20reads=20their=20own=20staleness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stub commit so the lane is resumable. WIP. Co-Authored-By: Claude --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d146f3cf..effdd8e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.234.0" +version = "0.236.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From a34ddd6c521049746f31ae7e048718b81e1c0946 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 18:35:59 -0700 Subject: [PATCH 2/5] feat(wallet): add staleness fields + rendering for the coin reads (#490) Co-Authored-By: Claude --- Cargo.lock | 2 +- crates/dig-node-service/src/control.rs | 121 ++++++++-- crates/dig-node-service/src/control_cli.rs | 268 +++++++++++++++++++-- 3 files changed, 356 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f772b2a9..27c86856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.234.0" +version = "0.236.0" dependencies = [ "async-trait", "axum", diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index e2c04de1..a528315a 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1670,6 +1670,55 @@ fn stale_by(answer_height: Option, network_peak: Option) -> Option Option { + ctx.wallet + .wallet_sync_status() + .await + .ok() + .and_then(|s| s.chia_peer_peak_height) +} + +/// The freshness of the tier that produced an answer, for the reads that do not carry one of +/// their own (dig-node#490 — today, `control.wallet.arrivals`). +/// +/// The chain reads get these three values from the wallet backend's own routing decision, which +/// knows which tier actually answered. `arrivals` reads a LOCAL ledger and so has no routing +/// decision to report; its freshness is the chain replica's, taken from the sync status. +#[derive(Debug, Clone, Copy)] +struct AnswerTier { + /// Whether the replica is caught up AND following the chain for the enrolled wallet. + synced: bool, + /// The replica's own peak. `None` is UNKNOWN, never height zero. + peak_height: Option, + /// What the held Chia peers say the chain's peak is. + network_peak_height: Option, +} + +/// Read the chain replica's freshness for a read that has no tier of its own. +/// +/// A status this node cannot read at all degrades to *nothing is known* — `synced: false` with +/// both heights absent — which [`stale_by`] turns into a `null` gap and the CLI renders as +/// NOT CURRENT. That is the truthful reading: a node that cannot describe its own replica cannot +/// bound an answer drawn from it. +async fn replica_tier(ctx: &ControlCtx) -> AnswerTier { + let status = ctx.wallet.wallet_sync_status().await.ok(); + AnswerTier { + // Only `Synced` licenses serving wallet-scoped reads from the replica, so only `Synced` + // may claim a wallet-scoped answer is current. Every other phase — including the + // all-clear `NoWalletEnrolled` — is making a different claim, or none. + synced: status + .as_ref() + .is_some_and(|s| matches!(s.phase, dig_wallet::sage::sync_supervisor::SyncPhase::Synced)), + peak_height: status.as_ref().and_then(|s| s.peak_height), + network_peak_height: status.and_then(|s| s.chia_peer_peak_height), + } +} + /// `control.wallet.balance` (#1851) — the READ-ONLY balance of a PUBLIC address, for XCH or /// $DIG. An OPEN read (no token gate, [`is_open_control_read`]): it needs only an address, never /// a seed or signing key, so it carries zero custody risk. It reuses the wallet backend's B.6 @@ -1707,15 +1756,7 @@ async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { Err(e) => return e, }; - // The peers' announced peak is read SEPARATELY and is allowed to fail: it makes the answer's - // staleness legible, and losing it must degrade the answer to "cannot say how stale" rather - // than failing a balance read that otherwise succeeded. - let network_peak = ctx - .wallet - .wallet_sync_status() - .await - .ok() - .and_then(|s| s.chia_peer_peak_height); + let network_peak = held_peers_peak(ctx).await; match ctx.wallet.balance_for_address(address, asset).await { Ok(r) => control_ok(id, balance_wire(&r, network_peak)), @@ -2073,12 +2114,13 @@ async fn wallet_coins(ctx: &ControlCtx, id: Value, params: &Value) -> Value { Ok(parsed) => parsed, Err(response) => return response, }; + let network_peak = held_peers_peak(ctx).await; match ctx .wallet .coins_for_address(&address, asset, after_coin_id.as_deref(), limit) .await { - Ok(r) => control_ok(id, coins_wire(&r, asset)), + Ok(r) => control_ok(id, coins_wire(&r, asset, network_peak)), Err(e) => wallet_read_error(METHOD, id, &address, e), } } @@ -2105,8 +2147,9 @@ async fn wallet_coin_by_id(ctx: &ControlCtx, id: Value, params: &Value) -> Value Ok(parsed) => parsed, Err(response) => return response, }; + let network_peak = held_peers_peak(ctx).await; match ctx.wallet.coin_by_id(&coin_id).await { - Ok(r) => control_ok(id, coin_by_id_wire(&r)), + Ok(r) => control_ok(id, coin_by_id_wire(&r, network_peak)), Err(e) => wallet_read_error("control.wallet.coinById", id, &coin_id, e), } } @@ -2136,8 +2179,9 @@ async fn wallet_coin_spend(ctx: &ControlCtx, id: Value, params: &Value) -> Value Ok(parsed) => parsed, Err(response) => return response, }; + let network_peak = held_peers_peak(ctx).await; match ctx.wallet.coin_spend(&coin_id).await { - Ok(r) => control_ok(id, coin_spend_wire(&r)), + Ok(r) => control_ok(id, coin_spend_wire(&r, network_peak)), Err(e) => wallet_read_error(METHOD, id, &coin_id, e), } } @@ -2168,6 +2212,7 @@ async fn wallet_coins_by_parent(ctx: &ControlCtx, id: Value, params: &Value) -> // `effective_limit` resolves an omitted page size using the CONTRACT's default, so a node and a // client can never disagree about where an unspecified page ends. let limit = request.effective_limit(); + let network_peak = held_peers_peak(ctx).await; match ctx .wallet .coins_by_parent( @@ -2177,7 +2222,7 @@ async fn wallet_coins_by_parent(ctx: &ControlCtx, id: Value, params: &Value) -> ) .await { - Ok(r) => control_ok(id, coins_by_parent_wire(&r)), + Ok(r) => control_ok(id, coins_by_parent_wire(&r, network_peak)), Err(e) => wallet_read_error(METHOD, id, &request.parent_coin_id, e), } } @@ -2241,12 +2286,13 @@ async fn wallet_arrivals(ctx: &ControlCtx, id: Value, params: &Value) -> Value { format!("{METHOD} after_seq must be a non-negative integer"), ); } + let tier = replica_tier(ctx).await; match ctx .wallet .wallet_arrivals(after_seq, arrivals_limit(params)) .await { - Ok((page, latest)) => control_ok(id, arrivals_wire(after_seq, &page, latest)), + Ok((page, latest)) => control_ok(id, arrivals_wire(after_seq, &page, latest, tier)), // Only the local wallet DB can fail here — there is no chain call to blame. Err(e) => control_error( id, @@ -2291,9 +2337,19 @@ fn arrivals_wire( after_seq: i64, arrivals: &[dig_wallet::sage::arrivals::Arrival], latest: i64, + tier: AnswerTier, ) -> Value { let cursor = arrivals.last().map_or(after_seq, |a| a.seq); json!({ + // The ledger is LOCAL, but it is written from the chain replica, so its freshness is the + // replica's freshness (dig-node#490). An empty page is the answer to "did I just get + // paid?", and from a replica that is not following the chain it is not evidence that + // nobody did. Spelled with the same three field names the chain reads use, because it is + // the same claim about the same tier. + "synced": tier.synced, + "peak_height": tier.peak_height, + "network_peak_height": tier.network_peak_height, + "stale_by": stale_by(tier.peak_height, tier.network_peak_height), "arrivals": arrivals.iter().map(|a| json!({ "seq": a.seq, "coin_id": a.coin_id, @@ -3256,7 +3312,11 @@ fn no_watchlist(id: Value) -> Value { /// The `asset` is echoed onto every coin because dig-app's frozen `CoinRecord` carries one and /// filters by it; the read is already scoped to a single asset, so echoing the REQUESTED one is /// exactly what the coins are. -fn coins_wire(r: &dig_wallet::sage::rpc::WalletCoinsResult, asset: BalanceAsset) -> Value { +fn coins_wire( + r: &dig_wallet::sage::rpc::WalletCoinsResult, + asset: BalanceAsset, + network_peak: Option, +) -> Value { // Serialized through the published `Asset`, so the echo is spelled exactly as the contract // spells it — `"dig"` for $DIG, `{"cat":""}` for any other CAT — and never `null`. let asset = serde_json::to_value(ControlAsset::from(asset)) @@ -3276,11 +3336,17 @@ fn coins_wire(r: &dig_wallet::sage::rpc::WalletCoinsResult, asset: BalanceAsset) })).collect::>(), // Always a concrete boolean. The contract's `null` means "a node too old to page", and // emitting it from a node that DOES page would tell a caller its cursor is meaningless. + // + // It scopes the PAGE, never the chain: `complete: true` says this node handed over + // everything it found, and `stale_by` below says how much of the chain that was + // (dig-node#490). The two must be read together, which is why they now travel together. "complete": r.complete, "cursor": r.cursor, "source": r.source.as_wire(), "synced": r.synced, "peak_height": r.peak_height, + "network_peak_height": network_peak, + "stale_by": stale_by(r.peak_height, network_peak), }) } @@ -3289,7 +3355,10 @@ fn coins_wire(r: &dig_wallet::sage::rpc::WalletCoinsResult, asset: BalanceAsset) /// `asset` is ALWAYS `null` here, unlike [`coins_wire`]. A coin id alone does not reveal whether a /// coin is XCH, a CAT or a singleton — that needs the puzzle, which this read never inspects — so /// naming one would be asserting a classification the node did not verify. -fn coin_by_id_wire(r: &dig_wallet::sage::rpc::WalletCoinByIdResult) -> Value { +fn coin_by_id_wire( + r: &dig_wallet::sage::rpc::WalletCoinByIdResult, + network_peak: Option, +) -> Value { json!({ "coin": r.coin.as_ref().map(|c| json!({ "coin_id": c.coin_id, @@ -3303,6 +3372,12 @@ fn coin_by_id_wire(r: &dig_wallet::sage::rpc::WalletCoinByIdResult) -> Value { "source": r.source.as_wire(), "synced": r.synced, "peak_height": r.peak_height, + // A `null` coin is the sharpest case these fields exist for (dig-node#490): it is a + // statement about the CHAIN, made from a replica that may never have reached the height + // the coin was created at. Without a freshness bound a caller polling a mint cannot tell + // "not seen yet" from "never happened". + "network_peak_height": network_peak, + "stale_by": stale_by(r.peak_height, network_peak), }) } @@ -3333,7 +3408,10 @@ fn unclassified_coin_wire(c: &dig_wallet::sage::rpc::WalletCoin) -> Value { /// decodes this field with `required_option`, so an absent key is a decode FAILURE on the client /// and not a verdict. That is deliberate on both sides — "no spend" must be something the node /// actually said. -fn coin_spend_wire(r: &dig_wallet::sage::rpc::WalletCoinSpendResult) -> Value { +fn coin_spend_wire( + r: &dig_wallet::sage::rpc::WalletCoinSpendResult, + network_peak: Option, +) -> Value { json!({ "spend": r.spend.as_ref().map(|s| json!({ "coin": unclassified_coin_wire(&s.coin), @@ -3343,6 +3421,8 @@ fn coin_spend_wire(r: &dig_wallet::sage::rpc::WalletCoinSpendResult) -> Value { "source": r.source.as_wire(), "synced": r.synced, "peak_height": r.peak_height, + "network_peak_height": network_peak, + "stale_by": stale_by(r.peak_height, network_peak), }) } @@ -3352,7 +3432,10 @@ fn coin_spend_wire(r: &dig_wallet::sage::rpc::WalletCoinSpendResult) -> Value { /// positively — `complete`, not `truncated` — precisely so that the reading a client falls into when /// the field is missing or defaulted is "there may be more", and it decodes `cursor` with /// `required_option` so an absent key cannot become a confident "nothing to resume from". -fn coins_by_parent_wire(r: &dig_wallet::sage::rpc::WalletCoinsByParentResult) -> Value { +fn coins_by_parent_wire( + r: &dig_wallet::sage::rpc::WalletCoinsByParentResult, + network_peak: Option, +) -> Value { json!({ "coins": r.coins.iter().map(unclassified_coin_wire).collect::>(), "complete": r.complete, @@ -3360,6 +3443,8 @@ fn coins_by_parent_wire(r: &dig_wallet::sage::rpc::WalletCoinsByParentResult) -> "source": r.source.as_wire(), "synced": r.synced, "peak_height": r.peak_height, + "network_peak_height": network_peak, + "stale_by": stale_by(r.peak_height, network_peak), }) } diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 1a6dbf76..f1f1e40a 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -758,7 +758,7 @@ fn summarize(method: &str, result: &Value) -> String { "balance {} · pending {} · {}", amount(&result["balance"]), amount(&result["pending"]), - balance_freshness(result), + answer_freshness(result), ), // `result["coin"]` yields `Null` for a missing key, but indexing the INNER map would // panic on one — so every field is read with `get`, and a coin record short of a field @@ -771,19 +771,31 @@ fn summarize(method: &str, result: &Value) -> String { amount(&result["coins_dropped"]), amount(&result["staged_dropped"]), ), + // The arrival ledger is local, but it is FED by the chain replica, so an empty page from + // a replica that is not following the chain is not evidence that nobody paid you. "control.wallet.arrivals" => { let n = result["arrivals"].as_array().map(Vec::len).unwrap_or(0); format!( - "{n} arrival(s) · cursor {}", - result["cursor"].as_i64().unwrap_or(0) + "{n} arrival(s) · cursor {} · {}", + result["cursor"].as_i64().unwrap_or(0), + answer_freshness(result), ) } + // The sharpest of the four (#490): the miss is an assertion about the CHAIN, made from a + // replica that may never have reached the height the coin was created at. A caller + // polling a mint reads `no such coin on chain` as *the mint failed*. So the definite + // wording is reserved for a tier that can bound its own answer; an unbounded tier reports + // only what it can honestly report — that IT has no record. "control.wallet.coinById" => match result["coin"].as_object() { - None => "no such coin on chain".to_string(), + None if answer_is_current(result) => "no such coin on chain".to_string(), + None => format!( + "this node has no record of that coin · {}", + answer_freshness(result) + ), Some(coin) => { let field = |key: &str| coin.get(key).unwrap_or(&Value::Null).clone(); format!( - "coin {} · {} · created {} · {}", + "coin {} · {} · created {} · {} · {}", field("coin_id").as_str().unwrap_or("?"), mojos(&field("amount")), height(&field("created_height")), @@ -791,6 +803,7 @@ fn summarize(method: &str, result: &Value) -> String { Some(h) => format!("spent at {h}"), None => "unspent".to_string(), }, + answer_freshness(result), ) } }, @@ -798,15 +811,24 @@ fn summarize(method: &str, result: &Value) -> String { // kilobytes: a human summary that scrolls a terminal off its own screen is not a summary. // `--json` carries the bytes for anything that needs them. "control.wallet.coinSpend" => match result["spend"].as_object() { - None => "no spend of that coin on chain (unspent, or unknown)".to_string(), + // The fifth sibling of the same defect, fixed here because it is the same line: an + // absent spend read from an unbounded tier is not a statement about the chain either. + None if answer_is_current(result) => { + "no spend of that coin on chain (unspent, or unknown)".to_string() + } + None => format!( + "this node has no record of a spend of that coin · {}", + answer_freshness(result) + ), Some(spend) => { let hex_len = |key: &str| spend[key].as_str().unwrap_or_default().len() / 2; format!( - "spend of {} at height {} · puzzle reveal {} bytes · solution {} bytes", + "spend of {} at height {} · puzzle reveal {} bytes · solution {} bytes · {}", spend["coin"]["coin_id"].as_str().unwrap_or("?"), height(&spend["coin"]["spent_height"]), hex_len("puzzle_reveal"), hex_len("solution"), + answer_freshness(result), ) } }, @@ -818,13 +840,18 @@ fn summarize(method: &str, result: &Value) -> String { // holdings -- which is a person deciding they cannot afford something they can. "control.wallet.coins" => { let coins = result["coins"].as_array().map(Vec::len).unwrap_or(0); - format!("{coins} unspent coin(s){}", page_suffix(result)) + format!( + "{coins} unspent coin(s){} · {}", + page_suffix(result), + answer_freshness(result) + ) } "control.wallet.coinsByParent" => { let coins = result["coins"].as_array().map(Vec::len).unwrap_or(0); format!( - "{coins} direct child coin(s) — one hop, not a lineage{}", - page_suffix(result) + "{coins} direct child coin(s) — one hop, not a lineage{} · {}", + page_suffix(result), + answer_freshness(result) ) } "control.collateral.requirement" => summarize_collateral_requirement(result), @@ -851,6 +878,16 @@ fn summarize(method: &str, result: &Value) -> String { /// tell it apart from a node that measured and found the set complete. fn page_suffix(result: &Value) -> String { match (result["complete"].as_bool(), result["cursor"].as_str()) { + // `complete` is a claim about the PAGE — that the node handed over everything IT found. + // Printed bare beside a tier that has just said it cannot bound its own answer's height, + // a reader takes it for a claim about the CHAIN: *nothing was left out*. That is the + // reading dig-node#490 was filed on, from a page that said `complete: true` alongside + // `synced: false, peak_height: null`. The flag is still reported — it is true, and a + // pager needs it — but it is scoped to what this node can see, and the freshness clause + // that follows says how much that is. + (Some(true), _) if !answer_is_current(result) => { + " · complete for what this node can see".to_string() + } (Some(true), _) => " · complete".to_string(), (_, Some(cursor)) => format!(" · MORE remain — resume after {cursor}"), _ => " · completeness unknown (a node too old to say)".to_string(), @@ -1456,7 +1493,7 @@ fn amount(v: &Value) -> String { } } -/// How much a rendered balance can be trusted (dig-node#416). +/// How much a rendered ANSWER can be trusted (dig-node#416, extended to the coin reads by #490). /// /// # The defect this exists to remove /// @@ -1478,9 +1515,16 @@ fn amount(v: &Value) -> String { /// Nothing bounds the figure at all, so it is not evidence of anything, least of all emptiness. /// /// Every non-current case is prefixed `NOT CURRENT` so the qualifier cannot be missed beside the -/// digit, and the last one says outright that the figure may not reflect the wallet — because -/// that is the case in which a reader is most likely to conclude they own nothing. -fn balance_freshness(result: &Value) -> String { +/// digit, and the last one says outright that the answer is not evidence — because that is the +/// case in which a reader is most likely to conclude there is nothing there. +/// +/// # Why it is subject-neutral (#490) +/// +/// It reads only `synced`, `peak_height` and `stale_by`, which every wallet read that touches +/// the chain replica now emits. The same four claims are the same four claims about a balance, a +/// coins page, a child page, a coin lookup and a spend lookup — they all describe the TIER, not +/// the subject — so one renderer serves all of them and there is no second contract to drift. +fn answer_freshness(result: &Value) -> String { if result["synced"].as_bool().unwrap_or(false) { return match result["peak_height"].as_u64() { Some(h) => format!("current as of height {h}"), @@ -1496,13 +1540,21 @@ fn balance_freshness(result: &Value) -> String { format!("NOT CURRENT — as of height {h}, distance from the network unknown") } (None, _) => concat!( - "NOT CURRENT — this node cannot say what height this reflects; the figure may ", - "not reflect the wallet" + "NOT CURRENT — this node cannot say what height this answer reflects; it is not ", + "evidence that there is nothing there" ) .to_string(), } } +/// Whether an answer's own tier says it is current. PURE. +/// +/// A missing `synced` reads as NOT current, deliberately: a response short of the field is a +/// node that did not say, and "did not say" must never resolve toward the reassuring claim. +fn answer_is_current(result: &Value) -> bool { + result["synced"].as_bool().unwrap_or(false) +} + /// A coin amount for a human line: `N mojos`, or `amount unknown` when the field is missing or is /// not a number. /// @@ -2720,6 +2772,190 @@ mod tests { assert!(s.contains("amount unknown"), "got: {s}"); } + // ---- dig-node#490: the four sibling reads must bound their own answers ------------------ + // + // #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. + + /// An unspent-coins page from a tier that cannot say what height it reflects must not + /// assert completeness. + /// + /// # The property, and the input that distinguishes it + /// + /// `complete: true` is a POSITIVE claim -- *nothing was left out*. A bare `0` merely fails + /// to qualify itself; `complete` asserts. The measured reading is a page that says + /// `complete: true` in the same breath as `synced: false, peak_height: null`, i.e. that it + /// cannot bound its own answer's height. + /// + /// The nearest wrong implementation is one that appends a freshness clause to every coins + /// line and leaves the completeness clause untouched -- it satisfies "the stale line warns" + /// while the assertion a reader acts on is still unqualified. So this asserts on the + /// COMPLETENESS clause specifically (`· complete ·`, the unqualified form) and keeps a + /// synced control that must still carry it. + #[test] + fn a_page_that_cannot_bound_its_height_never_asserts_completeness() { + // The ticket's measured reading: `0 unspent coin(s) - complete`, from a fallback tier. + let unbounded = summarize( + "control.wallet.coins", + &json!({ + "coins": [], "complete": true, "cursor": null, + "source": "fallback", "synced": false, + "peak_height": null, "network_peak_height": null, "stale_by": null, + }), + ); + // The honest empty address: a synced replica saying it holds nothing. + let current = summarize( + "control.wallet.coins", + &json!({ + "coins": [], "complete": true, "cursor": null, + "source": "db", "synced": true, + "peak_height": 9_220_177u64, "network_peak_height": 9_220_177u64, "stale_by": 0, + }), + ); + + assert_ne!( + unbounded, current, + "an unbounded empty page must not read like an empty address" + ); + assert!( + unbounded.contains("NOT CURRENT"), + "an unbounded page must be marked not current: {unbounded}" + ); + assert!( + !unbounded.contains(" · complete ·") && !unbounded.ends_with(" · complete"), + "an unbounded page must not assert bare completeness: {unbounded}" + ); + // The control, which a blanket-qualifier implementation cannot satisfy. + assert!( + current.contains(" · complete"), + "a current page still states completeness plainly: {current}" + ); + assert!( + !current.contains("NOT CURRENT"), + "a current page must NOT be scare-marked: {current}" + ); + } + + /// `coinsByParent` is the same page shape one hop over, and drifted the same way. + #[test] + fn a_children_page_that_cannot_bound_its_height_never_asserts_completeness() { + let unbounded = summarize( + "control.wallet.coinsByParent", + &json!({ + "coins": [], "complete": true, "cursor": null, + "source": "fallback", "synced": false, + "peak_height": null, "network_peak_height": null, "stale_by": null, + }), + ); + assert!(unbounded.contains("NOT CURRENT"), "got: {unbounded}"); + assert!( + !unbounded.contains(" · complete ·") && !unbounded.ends_with(" · complete"), + "an unbounded children page must not assert completeness: {unbounded}" + ); + } + + /// A missing coin read from a tier that may never have reached the coin's creation height is + /// NOT a statement about the chain. + /// + /// The old line was `no such coin on chain` -- an assertion about the CHAIN, rendered from a + /// replica that cannot say what height it reflects. A caller polling a mint reads that as + /// *the mint failed*. The synced control is what makes this load-bearing: the definite + /// wording must survive where the node CAN bound its answer, so this cannot be satisfied by + /// deleting the sentence. + #[test] + fn a_missing_coin_from_an_unbounded_tier_is_not_a_claim_about_the_chain() { + let unbounded = summarize( + "control.wallet.coinById", + &json!({ + "coin": null, "source": "fallback", "synced": false, + "peak_height": null, "network_peak_height": null, "stale_by": null, + }), + ); + let current = summarize( + "control.wallet.coinById", + &json!({ + "coin": null, "source": "db", "synced": true, + "peak_height": 9_220_177u64, "network_peak_height": 9_220_177u64, "stale_by": 0, + }), + ); + + assert!( + !unbounded.contains("on chain"), + "an unbounded miss must not assert anything about the chain: {unbounded}" + ); + assert!( + unbounded.contains("NOT CURRENT"), + "an unbounded miss must be marked not current: {unbounded}" + ); + assert!( + current.contains("no such coin on chain"), + "a bounded miss keeps its definite wording: {current}" + ); + assert_ne!(unbounded, current); + } + + /// A bounded-but-behind answer is a THIRD line: usable, and it names the gap. + /// + /// `stale_by: 0` and `stale_by: null` are OPPOSITE claims -- zero says *level with the + /// network*, absence says *nothing bounds this*. A renderer that collapsed them would pass + /// the unbounded tests above by treating every non-synced answer alike. + #[test] + fn a_coins_page_behind_the_network_names_its_gap() { + let behind = summarize( + "control.wallet.coins", + &json!({ + "coins": [], "complete": true, "cursor": null, + "source": "db", "synced": false, + "peak_height": 9_211_798u64, "network_peak_height": 9_220_177u64, + "stale_by": 8_379, + }), + ); + let unbounded = summarize( + "control.wallet.coins", + &json!({ + "coins": [], "complete": true, "cursor": null, + "source": "fallback", "synced": false, + "peak_height": null, "network_peak_height": null, "stale_by": null, + }), + ); + assert!(behind.contains("8379"), "the gap must be named: {behind}"); + assert!( + behind.contains("9211798"), + "the as-of height must be named: {behind}" + ); + assert_ne!( + behind, unbounded, + "a bounded stale page differs from an unbounded one" + ); + } + + /// `arrivals` reads a LOCAL ledger, but that ledger is fed by the replica, so an empty page + /// from a replica that is not following the chain is not evidence nobody paid you. + #[test] + fn an_arrivals_page_from_an_unbounded_tier_says_so() { + let unbounded = summarize( + "control.wallet.arrivals", + &json!({ + "arrivals": [], "cursor": 0, "latest": 0, "synced": false, + "peak_height": null, "network_peak_height": null, "stale_by": null, + }), + ); + let current = summarize( + "control.wallet.arrivals", + &json!({ + "arrivals": [], "cursor": 0, "latest": 0, "synced": true, + "peak_height": 9_220_177u64, "network_peak_height": 9_220_177u64, "stale_by": 0, + }), + ); + assert!(unbounded.contains("NOT CURRENT"), "got: {unbounded}"); + assert!( + !current.contains("NOT CURRENT"), + "a current empty page is a real answer: {current}" + ); + } + #[test] fn updater_status_summary_handles_not_installed() { let s = summarize("control.updater.status", &json!({ "installed": false })); From e1c7598b6c813f3432b3d3a5a3dbbf4ab3bbdeab Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 18:36:02 -0700 Subject: [PATCH 3/5] feat(wallet): mark staleness on all coin reads (#490) Co-Authored-By: Claude --- crates/dig-node-service/src/control.rs | 152 ++++++++++++++++++++----- crates/dig-wallet/src/sage/rpc.rs | 12 +- 2 files changed, 136 insertions(+), 28 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index a528315a..030110e6 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -4780,8 +4780,7 @@ mod tests { synced: true, peak_height: Some(5_000_000), }, - BalanceAsset::DIG, - ); + BalanceAsset::DIG, None); assert_eq!( wire, @@ -4799,7 +4798,7 @@ mod tests { } ], "complete": true, "cursor": "dd".repeat(32), - "source": "db", "synced": true, "peak_height": 5_000_000 + "source": "db", "synced": true, "peak_height": 5_000_000, "network_peak_height": null, "stale_by": null }) ); } @@ -4843,8 +4842,7 @@ mod tests { synced: false, peak_height: None, }, - asset, - ); + asset, None); assert_eq!( wire["coins"][0]["asset"], json!({ "cat": id }), @@ -4905,8 +4903,7 @@ mod tests { synced: false, peak_height: None, }, - BalanceAsset::Xch, - ); + BalanceAsset::Xch, None); assert_eq!( wire["coins"][0]["spent_height"], @@ -4930,6 +4927,110 @@ mod tests { .contains("params.address")); } + /// dig-node#490 — every coin read carries the SAME staleness bound `balance` does, and + /// `stale_by: 0` and `stale_by: null` stay OPPOSITE claims on each of them. + /// + /// # The input that distinguishes this from the nearest wrong implementation + /// + /// An implementation that merely ADDED the two keys — say, always `null` — passes any test + /// that only checks the fields are present. So each read is exercised at all three points: + /// level with the network (`0`), a real gap (`8_379`), and unbounded (`null`), with an + /// explicit `assert_ne!` between the first and the last. That pair is the whole contract: + /// zero says *this answer is as current as the network*, absence says *nothing bounds it*, + /// and a caller that cannot tell them apart is back at the reading #416 was filed on. + /// + /// The three heights are the ticket's own measured numbers, not round ones, so a fixture + /// that silently lost a digit would not still arithmetic out. + #[test] + fn every_coin_read_bounds_its_answer_with_the_null_versus_zero_contract() { + use dig_wallet::sage::routing::Source; + use dig_wallet::sage::rpc::{ + WalletCoinByIdResult, WalletCoinsByParentResult, WalletCoinsResult, + }; + + const REPLICA: u32 = 9_211_798; + const NETWORK: u32 = 9_220_177; + const GAP: u32 = 8_379; + + let coins = |peak: Option, network: Option| { + coins_wire( + &WalletCoinsResult { + coins: vec![], + complete: true, + cursor: None, + source: Source::Fallback, + synced: false, + peak_height: peak, + }, + BalanceAsset::Xch, + network, + ) + }; + let by_id = |peak: Option, network: Option| { + coin_by_id_wire( + &WalletCoinByIdResult { + coin: None, + source: Source::Fallback, + synced: false, + peak_height: peak, + }, + network, + ) + }; + let by_parent = |peak: Option, network: Option| { + coins_by_parent_wire( + &WalletCoinsByParentResult { + coins: vec![], + complete: true, + cursor: None, + source: Source::Fallback, + synced: false, + peak_height: peak, + }, + network, + ) + }; + let arrivals = |peak: Option, network: Option| { + arrivals_wire( + 0, + &[], + 0, + AnswerTier { + synced: false, + peak_height: peak, + network_peak_height: network, + }, + ) + }; + + for (name, read) in [ + ("coins", &coins as &dyn Fn(Option, Option) -> Value), + ("coinById", &by_id), + ("coinsByParent", &by_parent), + ("arrivals", &arrivals), + ] { + let behind = read(Some(REPLICA), Some(NETWORK)); + assert_eq!(behind["stale_by"], json!(GAP), "{name}: gap must be named"); + assert_eq!(behind["network_peak_height"], json!(NETWORK), "{name}"); + + let level = read(Some(NETWORK), Some(NETWORK)); + assert_eq!(level["stale_by"], json!(0), "{name}: level is a ZERO gap"); + + // No answer height at all — the ticket's measured reading. + let unbounded = read(None, Some(NETWORK)); + assert_eq!(unbounded["stale_by"], json!(null), "{name}"); + assert_ne!( + level["stale_by"], unbounded["stale_by"], + "{name}: level-with-the-network and unbounded are OPPOSITE claims" + ); + + // No held peer has announced a peak: bounded answer, unmeasurable distance. + let unmeasurable = read(Some(REPLICA), None); + assert_eq!(unmeasurable["stale_by"], json!(null), "{name}"); + assert_eq!(unmeasurable["network_peak_height"], json!(null), "{name}"); + } + } + // ---- control.wallet.arrivals (dig_ecosystem#2548) -------------------------------------- /// The amount reaches the wire as a STRING, and the asset id is carried verbatim for a CAT and @@ -4958,8 +5059,7 @@ mod tests { confirmed_height: 5_000_001, }, ], - 8, - ); + 8, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); assert_eq!(wire["arrivals"][0]["amount"], json!("18446744073709551615")); assert_eq!(wire["arrivals"][0]["asset_id"], Value::Null); assert_eq!(wire["arrivals"][0]["confirmed_height"], json!(5_000_000)); @@ -4985,7 +5085,7 @@ mod tests { confirmed_height: 100, }; // The page ends at 8; the ledger has since reached 12. - let wire = arrivals_wire(0, &[row(7), row(8)], 12); + let wire = arrivals_wire(0, &[row(7), row(8)], 12, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); assert_eq!( wire["cursor"], json!(8), @@ -4998,7 +5098,7 @@ mod tests { /// first-run client can start from NOW instead of replaying the ledger as a burst of toasts. #[test] fn an_empty_arrivals_page_holds_the_cursor_and_still_reports_latest() { - let wire = arrivals_wire(30, &[], 42); + let wire = arrivals_wire(30, &[], 42, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); assert_eq!(wire["arrivals"], json!([])); assert_eq!(wire["cursor"], json!(30)); assert_eq!(wire["latest"], json!(42)); @@ -5139,7 +5239,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, @@ -5155,7 +5255,7 @@ mod tests { }, "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); } @@ -5189,7 +5289,7 @@ mod tests { source: Source::Db, synced: true, peak_height: Some(6_000_000), - }); + }, None); assert_eq!( wire["coin"]["asset"], @@ -5226,14 +5326,14 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, json!({ "coin": null, "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); @@ -5283,7 +5383,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, @@ -5303,7 +5403,7 @@ mod tests { }, "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); } @@ -5329,14 +5429,14 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, json!({ "spend": null, "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); @@ -5530,7 +5630,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, @@ -5548,7 +5648,7 @@ mod tests { "cursor": "aa".repeat(32), "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); } @@ -5571,7 +5671,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!( wire, @@ -5581,7 +5681,7 @@ mod tests { "cursor": null, "source": "fallback", "synced": false, - "peak_height": null + "peak_height": null, "network_peak_height": null, "stale_by": null }) ); } @@ -5608,7 +5708,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!(spend["spend"]["coin"]["asset"], Value::Null); let children = coins_by_parent_wire(&WalletCoinsByParentResult { @@ -5618,7 +5718,7 @@ mod tests { source: Source::Fallback, synced: false, peak_height: None, - }); + }, None); assert_eq!(children["coins"][0]["asset"], Value::Null); } diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index e2a74219..c2679f9b 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -1393,14 +1393,22 @@ impl WalletBackend { /// /// - **Wallet-owned address, DB synced** → the local DB is authoritative: /// [`db::WalletDb::balance_scoped`] (confirmed) + [`db::WalletDb::pending_scoped`] - /// (unconfirmed); `source = "db"`, `synced = true`, `peak_height` = the node's own peak. + /// (unconfirmed); `source = "db"`, `peak_height` = the node's own peak. /// - **Otherwise** → the fallback (coinset) tier answers; `source = "fallback"`, - /// `synced = false`, `peak_height = null`. If no LIVE fallback is attached, the read + /// `peak_height = null`. If no LIVE fallback is attached, the read /// cannot honestly answer, so it returns a DISTINCT error rather than a fabricated `0`: /// [`BalanceError::NotSynced`] for the wallet's own address (the DB would answer once /// synced), [`BalanceError::NoChainSource`] for an arbitrary address (only a chain /// source could). /// + /// **`synced` is NOT one of the routing outcomes above** (dig-node#490). It is computed + /// separately, from [`replica_answer_is_current`] — a CURRENCY test — so the routing tier and + /// the currency claim are independent, and `{source: "db", synced: false}` is a real, + /// reachable, common state: the replica was eligible to answer and is behind the chain. This + /// list previously read `synced = true` on the first bullet and `synced = false` on the + /// second, which denied a state production produces — and denied it about precisely the + /// answer the most useful CLI line renders. + /// /// **Every reported state field describes the tier that answered** (#2233). Reading the /// DB's `synced` / `peak_height` on a coinset-served answer would describe the local /// replica rather than the figure returned — so once a sync loop flips that flag, a From 72a88955d2126da88b113b1df15153008fafe919 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 18:36:12 -0700 Subject: [PATCH 4/5] docs(wallet): SPEC + rustdoc for the staleness contract (#490) --- SPEC.md | 10 +- crates/dig-node-service/src/control.rs | 247 ++++++++++++++++--------- 2 files changed, 160 insertions(+), 97 deletions(-) diff --git a/SPEC.md b/SPEC.md index bea66a2a..bd9dbe96 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1673,11 +1673,11 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.sync.status` | — | `available` (always `true` — the chunked capsule download needs no identity), `method: "chunked-capsule-download-with-section-21-clone-fallback"`, `identity_loaded`, `pinned_total`, `pinned_synced`, `whole_store_trigger_supported` (`true` — a store id alone is enough) | | `control.sync.trigger` | `store` = `storeId[:rootHash]`, or `store_id` [+ `root`] — the root is OPTIONAL; without one the node resolves the store's CHAIN-ANCHORED tip and syncs that generation | `status: "synced"`, `root`, `size_bytes`, `served_root` | | `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `source` (`"db"` \| `"fallback"` — which tier produced the figure, §18.7b), `synced` (bool), `peak_height` (`u32` or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. Per §18.7b, `source`/`synced`/`peak_height` describe the TIER that answered: a `"db"` answer reports the node's own peak and reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height` rather than presenting a stale figure as current; a tier with NO observable peer height also answers `synced: false`, because nothing corroborated the figure, and so does a replica with NO peak of its OWN — `synced: true` beside `peak_height: null` would claim a reading is current while refusing to say what it is a reading of (§18.7b); a `"fallback"` answer reports `synced: false` and `peak_height: null`. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. A CAT scopes by the asset id the REQUEST named -- any CAT, not only `$DIG` -- and BOTH tiers MUST scope to that id. `"dig"` is the canonical id `digstore_chain::dig::DIG_ASSET_ID` spelled as a token, and `{"cat":""}` MUST mean the same asset. Every scoping hash a tier derives MUST be derived FROM the requested id: a filter keyed to a fixed asset answers every other CAT an EMPTY list, which is indistinguishable from holding none of it -- a silent wrong answer with nothing to observe. An `asset` that is PRESENT and does not parse is `INVALID_PARAMS`; it MUST NOT default to `"xch"`, because a mistyped asset id would then read as a balance for the wrong token. An OMITTED `asset` is the documented `"xch"` default. A hint is not an asset: the fallback tier finds CAT coins with `get_coin_records_by_hints`, which takes no asset id and answers with EVERY coin hinted to the address -- any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo -- so a `"fallback"` answer MUST keep only the coins sitting at that asset's CAT puzzle hash (`digstore_chain::cat::cat_puzzle_hash(owner_p2_hash, asset_id)`, the canonical curry), the exact equivalent of the DB tier's `hint IN (...) AND asset_id = ?`. Summing the raw hint answer reports a holding the address does not have, at the asked-for asset's scale rather than each coin's own: one hinted XCH coin of 10^8 mojos (`0.0001 XCH`) totals as `100000` at `$DIG`'s 3 decimals. Over-filtering is the same lie mirrored -- a real `$DIG` holder answered zero -- so the filter MUST key on that puzzle hash and nothing heuristic. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error (and `synced` there means MEASURED-current, never merely eligible); the read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. Additionally `network_peak_height` (`u32` or `null`) — the peak this node's own held Chia peers have ANNOUNCED — and `stale_by` (`u32` or `null`) — how many blocks behind that peak this figure is. `stale_by` MUST be `null` unless BOTH the answer's `peak_height` and `network_peak_height` are known: a zero is a positive claim that the figure is level with the network, and absence is the opposite claim, so a consumer MUST NOT render them alike. It MUST saturate at zero rather than underflow when the replica is momentarily ahead. Both fields are ADDITIVE (§5.1). They exist because `balance 0, synced false, peak_height null` — the answer a replica ~8,380 blocks behind its peers actually gave — is indistinguishable from an empty wallet, and a consumer had nothing with which to tell them apart. | -| `control.wallet.coins` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`), `after_coin_id` (OPTIONAL, 64 lowercase-hex, an `0x` prefix TOLERATED and normalized away), `limit` (OPTIONAL, `1..=1000`, default `100`) | `coins` (array of `{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}`; all hashes lowercase 64-hex unprefixed, `amount` a JSON NUMBER in base units), `complete` (bool), `cursor` (string \| `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b). ONE PAGE of the UNSPENT coins at the address for the asset, i.e. the read a caller building a spend needs; a balance is this read reduced to a sum, which is why the two take identical params. It scopes to the asset by the SAME tier-agnostic rule, for the sharper reason: a coin list is spend INPUTS, so a hinted XCH or foreign-CAT coin served as a `$DIG` coin is a spend built on inputs of the wrong asset. Coins seen only in the mempool are INCLUDED with `created_height: null`, so the caller decides what is spendable for its purpose rather than the node hiding one. `coins: []` MUST mean a chain WAS consulted and the address holds nothing; every way of failing to consult one is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER an empty list — an empty list would tell a holder of funds that they hold none, and a spend built on it refuses with an untrue shortfall. The read is PAGED, because an address's unspent-coin count is unbounded and every spend's change coin adds one — the same exposure `control.wallet.coinsByParent` carries, on a control plane with no request rate limiting. A node MUST return coins ASCENDING by `coin_id`, MUST keep that order stable across the pages of one walk, and MUST NOT page by OFFSET: an address's unspent set SHRINKS as coins are spent, so under an offset every row after a departed coin moves one position earlier and the next page begins one row late — a coin the caller never sees, on the read whose purpose is coin selection. A node MUST derive `complete` from whether rows remain BEYOND the page, never from the page LENGTH: a coin count that is an exact multiple of the page size makes the final full page indistinguishable from a truncated one, and a caller stopping there builds a spend from half an address's coins and refuses with an untrue shortfall. The scope, asset, unspent predicate and page bound MUST be applied at the SAME level: paginating a broader read and filtering afterwards cuts the page before the filter, so pages arrive short and `complete` is computed from a count that no longer describes what remains. `cursor` is the `coin_id` of the LAST record actually returned, or `null` for an empty page, and is what a caller passes back as `after_coin_id`. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped — a silently shrunk page hands back a cursor for a position the caller did not ask about. Both page params are OPTIONAL and a request naming neither is byte-identical to the pre-paging request. OPEN read, same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `address`, a bad `asset`, a malformed `after_coin_id`, or a `limit` outside `1..=1000`. | -| `control.wallet.coinById` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `coin` (`{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}` or `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b); the tier fields MUST describe WHAT ANSWERED THIS READ. Where the local replica HOLDS the named coin and is authoritative for the set it follows (the same `control.wallet.balance` eligibility test, §18.7b), the node MUST answer from the replica: `source: "db"`, `peak_height` the replica's own peak, and `synced` MEASURED against the peers' announced peak rather than assumed — a replica that completed a catch-up and then fell behind still serves the coin, with its real peak, labelled stale. A replica MISS MUST fall through to the chain tier and be reported as such (`source: "fallback"`, `synced: false`, `peak_height: null`); it MUST NEVER be served as an absence, because the replica is populated only from this node's own subscriptions, so a miss means "this node does not watch that coin", which is NOT absence. A node MUST NOT report `source: "fallback"`, `synced: false` for a coin it holds: a warrant no read can ever carry turns every consumer-side freshness guard into an unconditional refusal, which ends a mint watch in "the chain could not be reached" on a healthy node. ONE coin by its own id, SPENT OR UNSPENT — the read a caller polling a spend needs and `control.wallet.coins` structurally cannot give: a created DID coin sits at nobody's wallet address, and a spent funding coin is gone from every unspent list. `asset` in the record is ALWAYS `null`: a coin id alone does not reveal whether a coin is XCH, a CAT or a singleton — that needs the puzzle, which this read never inspects — so naming one would assert a classification the node did not verify. A returned record MUST be bound to the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a source that answers with a DIFFERENT coin is a `WALLET_READ_FAILED` (§10) — never that coin's record, and never `coin: null`. `coin: null` MUST mean a chain source ANSWERED and reported no such coin; every way of failing to get an answer is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER a `null` — a `null` for an outage would tell a caller polling a mint that its coin does not exist, so a pending mint reads as awaiting forever. A caller MUST treat `null` as "not seen yet" and keep polling, not as "never happened". OPEN read (no token), same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call — an unanswerable question and a chain that answered "no" must never wear the same shape; the well-formedness rule is `dig-node-control-interface`'s own `WalletCoinByIdParams::validated()`, consumed rather than restated. | -| `control.wallet.coinSpend` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `spend` (`{coin, puzzle_reveal, solution}` or `null`), `source`, `synced`, `peak_height` -- the tier fields carrying exactly their `control.wallet.coinById` meanings, and here always `"fallback"` / `false` / `null`: the local replica stores coin records, not spends, so it can never produce this answer. THE SPEND THAT SPENT ONE COIN, named by that coin's own id (a spend has no id of its own on chain). A coin record carries a puzzle HASH and says only that a coin is gone; the puzzle REVEAL and the solution exist only here, and they are what a caller reconstructing a lineage -- following a dig-profile's DID singleton forward -- needs. `coin` is the full record shape `control.wallet.coinById` returns, with `asset` ALWAYS `null` (this read classifies nothing) and `spent_height` ALWAYS non-null (a spend of a coin nothing calls spent is a contradiction; the node MUST fail closed rather than emit one). The node MUST verify that `puzzle_reveal` tree-hashes to `coin.puzzle_hash` and MUST refuse -- `WALLET_READ_FAILED` (§10) -- when it does not or will not parse: the reveal comes from an unauthenticated peer, a puzzle hash IS the reveal's CLVM tree hash, so the lie is locally detectable and a caller would otherwise curry a forged program into the spend it signs. The returned spend MUST be bound to the id asked for, by the same self-certifying coin-id recomputation `control.wallet.coinById` requires. `spend: null` MUST mean a chain source ANSWERED and holds no spend of that coin -- it is UNSPENT, or unknown; distinguishing those two is `control.wallet.coinById`'s job. Every way of failing to get an answer is a DISTINCT error, NEVER `null`: a caller walking a lineage reads "no spend" as *this is the tip* and stops, so a failure disguised as absence yields a spend built against a superseded singleton, and a mint poll reads it as "my funding coin is still there" and funds the same mint twice. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call; the rule is `dig-node-control-interface`'s own `WalletCoinSpendParams::validated()`, consumed rather than restated. | -| `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | -| `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | +| `control.wallet.coins` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`), `after_coin_id` (OPTIONAL, 64 lowercase-hex, an `0x` prefix TOLERATED and normalized away), `limit` (OPTIONAL, `1..=1000`, default `100`) | `coins` (array of `{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}`; all hashes lowercase 64-hex unprefixed, `amount` a JSON NUMBER in base units), `complete` (bool), `cursor` (string \| `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b). ONE PAGE of the UNSPENT coins at the address for the asset, i.e. the read a caller building a spend needs; a balance is this read reduced to a sum, which is why the two take identical params. It scopes to the asset by the SAME tier-agnostic rule, for the sharper reason: a coin list is spend INPUTS, so a hinted XCH or foreign-CAT coin served as a `$DIG` coin is a spend built on inputs of the wrong asset. Coins seen only in the mempool are INCLUDED with `created_height: null`, so the caller decides what is spendable for its purpose rather than the node hiding one. `coins: []` MUST mean a chain WAS consulted and the address holds nothing; every way of failing to consult one is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER an empty list — an empty list would tell a holder of funds that they hold none, and a spend built on it refuses with an untrue shortfall. The read is PAGED, because an address's unspent-coin count is unbounded and every spend's change coin adds one — the same exposure `control.wallet.coinsByParent` carries, on a control plane with no request rate limiting. A node MUST return coins ASCENDING by `coin_id`, MUST keep that order stable across the pages of one walk, and MUST NOT page by OFFSET: an address's unspent set SHRINKS as coins are spent, so under an offset every row after a departed coin moves one position earlier and the next page begins one row late — a coin the caller never sees, on the read whose purpose is coin selection. A node MUST derive `complete` from whether rows remain BEYOND the page, never from the page LENGTH: a coin count that is an exact multiple of the page size makes the final full page indistinguishable from a truncated one, and a caller stopping there builds a spend from half an address's coins and refuses with an untrue shortfall. The scope, asset, unspent predicate and page bound MUST be applied at the SAME level: paginating a broader read and filtering afterwards cuts the page before the filter, so pages arrive short and `complete` is computed from a count that no longer describes what remains. `cursor` is the `coin_id` of the LAST record actually returned, or `null` for an empty page, and is what a caller passes back as `after_coin_id`. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped — a silently shrunk page hands back a cursor for a position the caller did not ask about. Both page params are OPTIONAL and a request naming neither is byte-identical to the pre-paging request. OPEN read, same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `address`, a bad `asset`, a malformed `after_coin_id`, or a `limit` outside `1..=1000`. Additionally `network_peak_height` (`u32` or `null`) and `stale_by` (`u32` or `null`), carrying EXACTLY their `control.wallet.balance` meanings (this section) and bound by the SAME null-versus-zero rule: `stale_by: 0` is a POSITIVE claim that this answer is level with the network, `null` is the OPPOSITE claim that nothing bounds it at all, and a consumer MUST NOT render the two alike. `stale_by` MUST be `null` unless BOTH this answer's `peak_height` and `network_peak_height` are known, and MUST saturate at zero rather than underflow. Both fields are ADDITIVE (§5.1). `complete` scopes the PAGE and never the chain: it states that this node handed over every record IT found, while `stale_by` states how much of the chain that was. A consumer MUST NOT present `complete: true` as an unqualified claim that nothing was left out while `stale_by` is `null` — the node has just said it cannot bound its own answer's height, so the two must be read together. | +| `control.wallet.coinById` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `coin` (`{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}` or `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b); the tier fields MUST describe WHAT ANSWERED THIS READ. Where the local replica HOLDS the named coin and is authoritative for the set it follows (the same `control.wallet.balance` eligibility test, §18.7b), the node MUST answer from the replica: `source: "db"`, `peak_height` the replica's own peak, and `synced` MEASURED against the peers' announced peak rather than assumed — a replica that completed a catch-up and then fell behind still serves the coin, with its real peak, labelled stale. A replica MISS MUST fall through to the chain tier and be reported as such (`source: "fallback"`, `synced: false`, `peak_height: null`); it MUST NEVER be served as an absence, because the replica is populated only from this node's own subscriptions, so a miss means "this node does not watch that coin", which is NOT absence. A node MUST NOT report `source: "fallback"`, `synced: false` for a coin it holds: a warrant no read can ever carry turns every consumer-side freshness guard into an unconditional refusal, which ends a mint watch in "the chain could not be reached" on a healthy node. ONE coin by its own id, SPENT OR UNSPENT — the read a caller polling a spend needs and `control.wallet.coins` structurally cannot give: a created DID coin sits at nobody's wallet address, and a spent funding coin is gone from every unspent list. `asset` in the record is ALWAYS `null`: a coin id alone does not reveal whether a coin is XCH, a CAT or a singleton — that needs the puzzle, which this read never inspects — so naming one would assert a classification the node did not verify. A returned record MUST be bound to the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a source that answers with a DIFFERENT coin is a `WALLET_READ_FAILED` (§10) — never that coin's record, and never `coin: null`. `coin: null` MUST mean a chain source ANSWERED and reported no such coin; every way of failing to get an answer is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER a `null` — a `null` for an outage would tell a caller polling a mint that its coin does not exist, so a pending mint reads as awaiting forever. A caller MUST treat `null` as "not seen yet" and keep polling, not as "never happened". OPEN read (no token), same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call — an unanswerable question and a chain that answered "no" must never wear the same shape; the well-formedness rule is `dig-node-control-interface`'s own `WalletCoinByIdParams::validated()`, consumed rather than restated. Additionally `network_peak_height` (`u32` or `null`) and `stale_by` (`u32` or `null`), carrying EXACTLY their `control.wallet.balance` meanings (this section) and bound by the SAME null-versus-zero rule: `stale_by: 0` is a POSITIVE claim that this answer is level with the network, `null` is the OPPOSITE claim that nothing bounds it at all, and a consumer MUST NOT render the two alike. `stale_by` MUST be `null` unless BOTH this answer's `peak_height` and `network_peak_height` are known, and MUST saturate at zero rather than underflow. Both fields are ADDITIVE (§5.1). A consumer MUST NOT present `coin: null` as a statement about the CHAIN while `stale_by` is `null`: the replica may never have reached the height the coin was created at, so the only honest rendering is that THIS NODE has no record of the coin. The definite reading — no such coin exists on chain — is reserved for an answer whose tier can bound its own height. | +| `control.wallet.coinSpend` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `spend` (`{coin, puzzle_reveal, solution}` or `null`), `source`, `synced`, `peak_height` -- the tier fields carrying exactly their `control.wallet.coinById` meanings, and here always `"fallback"` / `false` / `null`: the local replica stores coin records, not spends, so it can never produce this answer. THE SPEND THAT SPENT ONE COIN, named by that coin's own id (a spend has no id of its own on chain). A coin record carries a puzzle HASH and says only that a coin is gone; the puzzle REVEAL and the solution exist only here, and they are what a caller reconstructing a lineage -- following a dig-profile's DID singleton forward -- needs. `coin` is the full record shape `control.wallet.coinById` returns, with `asset` ALWAYS `null` (this read classifies nothing) and `spent_height` ALWAYS non-null (a spend of a coin nothing calls spent is a contradiction; the node MUST fail closed rather than emit one). The node MUST verify that `puzzle_reveal` tree-hashes to `coin.puzzle_hash` and MUST refuse -- `WALLET_READ_FAILED` (§10) -- when it does not or will not parse: the reveal comes from an unauthenticated peer, a puzzle hash IS the reveal's CLVM tree hash, so the lie is locally detectable and a caller would otherwise curry a forged program into the spend it signs. The returned spend MUST be bound to the id asked for, by the same self-certifying coin-id recomputation `control.wallet.coinById` requires. `spend: null` MUST mean a chain source ANSWERED and holds no spend of that coin -- it is UNSPENT, or unknown; distinguishing those two is `control.wallet.coinById`'s job. Every way of failing to get an answer is a DISTINCT error, NEVER `null`: a caller walking a lineage reads "no spend" as *this is the tip* and stops, so a failure disguised as absence yields a spend built against a superseded singleton, and a mint poll reads it as "my funding coin is still there" and funds the same mint twice. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call; the rule is `dig-node-control-interface`'s own `WalletCoinSpendParams::validated()`, consumed rather than restated. Additionally `network_peak_height` (`u32` or `null`) and `stale_by` (`u32` or `null`), carrying EXACTLY their `control.wallet.balance` meanings (this section) and bound by the SAME null-versus-zero rule: `stale_by: 0` is a POSITIVE claim that this answer is level with the network, `null` is the OPPOSITE claim that nothing bounds it at all, and a consumer MUST NOT render the two alike. `stale_by` MUST be `null` unless BOTH this answer's `peak_height` and `network_peak_height` are known, and MUST saturate at zero rather than underflow. Both fields are ADDITIVE (§5.1). A consumer MUST NOT present `spend: null` as a statement about the CHAIN while `stale_by` is `null`, for the same reason `control.wallet.coinById` gives: a lineage walk reads an absent spend as *this is the tip* and stops. | +| `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. Additionally `network_peak_height` (`u32` or `null`) and `stale_by` (`u32` or `null`), carrying EXACTLY their `control.wallet.balance` meanings (this section) and bound by the SAME null-versus-zero rule: `stale_by: 0` is a POSITIVE claim that this answer is level with the network, `null` is the OPPOSITE claim that nothing bounds it at all, and a consumer MUST NOT render the two alike. `stale_by` MUST be `null` unless BOTH this answer's `peak_height` and `network_peak_height` are known, and MUST saturate at zero rather than underflow. Both fields are ADDITIVE (§5.1). `complete` scopes the PAGE and never the chain: it states that this node handed over every record IT found, while `stale_by` states how much of the chain that was. A consumer MUST NOT present `complete: true` as an unqualified claim that nothing was left out while `stale_by` is `null` — the node has just said it cannot bound its own answer's height, so the two must be read together. | +| `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. The result additionally carries `synced` (bool), `peak_height`, `network_peak_height` and `stale_by` (`u32` or `null`), which describe the CHAIN REPLICA that WRITES this ledger rather than the ledger read itself. The ledger is local and cannot fail to be current with itself; what a reader needs bounding is the replica, because an empty page from a replica that is not following the chain is not evidence that nobody paid them. `synced` MUST be true only in the `synced` sync phase — the phase that licenses serving wallet-scoped reads from the replica — and `stale_by` obeys the same null-versus-zero rule as `control.wallet.balance`: `0` claims the ledger is level with the network, `null` claims nothing bounds it. A node that cannot read its own sync status MUST report `synced: false` with both heights absent. All four fields are ADDITIVE (§5.1). | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | | `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. No sync pass that was ALREADY RUNNING when the reset landed may re-establish it. The node MUST record a reset counter that the reset increments in that same transaction; every writer of `initial_sync_complete` — the address-history catch-up and the oracle-tier point-read refresh alike — MUST observe that counter BEFORE its own first write and present it again in the statement that sets the flag, which MUST NOT take effect if the counter has moved. Without that condition the reset and the sync pass are separate transactions that nothing serialises, and the interrupted pass marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. An address-history CATCH-UP whose completion is refused this way MUST report an error rather than success, so a fresh pass runs. The oracle-tier point-read refresh MAY instead log and return success, because it re-reads on its next call and has no pass to re-run; what it MUST NOT do is set the flag. A pass that began wholly AFTER the reset is unaffected and re-establishes the flag normally. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 030110e6..6e241030 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1711,9 +1711,12 @@ async fn replica_tier(ctx: &ControlCtx) -> AnswerTier { // Only `Synced` licenses serving wallet-scoped reads from the replica, so only `Synced` // may claim a wallet-scoped answer is current. Every other phase — including the // all-clear `NoWalletEnrolled` — is making a different claim, or none. - synced: status - .as_ref() - .is_some_and(|s| matches!(s.phase, dig_wallet::sage::sync_supervisor::SyncPhase::Synced)), + synced: status.as_ref().is_some_and(|s| { + matches!( + s.phase, + dig_wallet::sage::sync_supervisor::SyncPhase::Synced + ) + }), peak_height: status.as_ref().and_then(|s| s.peak_height), network_peak_height: status.and_then(|s| s.chia_peer_peak_height), } @@ -4780,7 +4783,9 @@ mod tests { synced: true, peak_height: Some(5_000_000), }, - BalanceAsset::DIG, None); + BalanceAsset::DIG, + None, + ); assert_eq!( wire, @@ -4842,7 +4847,9 @@ mod tests { synced: false, peak_height: None, }, - asset, None); + asset, + None, + ); assert_eq!( wire["coins"][0]["asset"], json!({ "cat": id }), @@ -4903,7 +4910,9 @@ mod tests { synced: false, peak_height: None, }, - BalanceAsset::Xch, None); + BalanceAsset::Xch, + None, + ); assert_eq!( wire["coins"][0]["spent_height"], @@ -5004,7 +5013,10 @@ mod tests { }; for (name, read) in [ - ("coins", &coins as &dyn Fn(Option, Option) -> Value), + ( + "coins", + &coins as &dyn Fn(Option, Option) -> Value, + ), ("coinById", &by_id), ("coinsByParent", &by_parent), ("arrivals", &arrivals), @@ -5059,7 +5071,13 @@ mod tests { confirmed_height: 5_000_001, }, ], - 8, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); + 8, + AnswerTier { + synced: true, + peak_height: Some(9_220_177), + network_peak_height: Some(9_220_177), + }, + ); assert_eq!(wire["arrivals"][0]["amount"], json!("18446744073709551615")); assert_eq!(wire["arrivals"][0]["asset_id"], Value::Null); assert_eq!(wire["arrivals"][0]["confirmed_height"], json!(5_000_000)); @@ -5085,7 +5103,16 @@ mod tests { confirmed_height: 100, }; // The page ends at 8; the ledger has since reached 12. - let wire = arrivals_wire(0, &[row(7), row(8)], 12, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); + let wire = arrivals_wire( + 0, + &[row(7), row(8)], + 12, + AnswerTier { + synced: true, + peak_height: Some(9_220_177), + network_peak_height: Some(9_220_177), + }, + ); assert_eq!( wire["cursor"], json!(8), @@ -5098,7 +5125,16 @@ mod tests { /// first-run client can start from NOW instead of replaying the ledger as a burst of toasts. #[test] fn an_empty_arrivals_page_holds_the_cursor_and_still_reports_latest() { - let wire = arrivals_wire(30, &[], 42, AnswerTier { synced: true, peak_height: Some(9_220_177), network_peak_height: Some(9_220_177) }); + let wire = arrivals_wire( + 30, + &[], + 42, + AnswerTier { + synced: true, + peak_height: Some(9_220_177), + network_peak_height: Some(9_220_177), + }, + ); assert_eq!(wire["arrivals"], json!([])); assert_eq!(wire["cursor"], json!(30)); assert_eq!(wire["latest"], json!(42)); @@ -5227,19 +5263,22 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::{WalletCoin, WalletCoinByIdResult}; - let wire = coin_by_id_wire(&WalletCoinByIdResult { - coin: Some(WalletCoin { - coin_id: "aa".repeat(32), - parent_coin_info: "bb".repeat(32), - puzzle_hash: "cc".repeat(32), - amount: 1_000_000_000_000, - created_height: Some(5_000_000), - spent_height: Some(5_000_042), - }), - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coin_by_id_wire( + &WalletCoinByIdResult { + coin: Some(WalletCoin { + coin_id: "aa".repeat(32), + parent_coin_info: "bb".repeat(32), + puzzle_hash: "cc".repeat(32), + amount: 1_000_000_000_000, + created_height: Some(5_000_000), + spent_height: Some(5_000_042), + }), + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, @@ -5277,19 +5316,22 @@ mod tests { // A CAT-sized amount on a synced DB-tier answer: deliberately the case most likely to // tempt a classification, and the opposite tier/sync combination to the test above. - let wire = coin_by_id_wire(&WalletCoinByIdResult { - coin: Some(WalletCoin { - coin_id: "11".repeat(32), - parent_coin_info: "22".repeat(32), - puzzle_hash: "33".repeat(32), - amount: 1_000, - created_height: Some(1), - spent_height: None, - }), - source: Source::Db, - synced: true, - peak_height: Some(6_000_000), - }, None); + let wire = coin_by_id_wire( + &WalletCoinByIdResult { + coin: Some(WalletCoin { + coin_id: "11".repeat(32), + parent_coin_info: "22".repeat(32), + puzzle_hash: "33".repeat(32), + amount: 1_000, + created_height: Some(1), + spent_height: None, + }), + source: Source::Db, + synced: true, + peak_height: Some(6_000_000), + }, + None, + ); assert_eq!( wire["coin"]["asset"], @@ -5321,12 +5363,15 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletCoinByIdResult; - let wire = coin_by_id_wire(&WalletCoinByIdResult { - coin: None, - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coin_by_id_wire( + &WalletCoinByIdResult { + coin: None, + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, json!({ @@ -5374,16 +5419,19 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::{WalletCoinSpend, WalletCoinSpendResult}; - let wire = coin_spend_wire(&WalletCoinSpendResult { - spend: Some(WalletCoinSpend { - coin: a_spent_coin(), - puzzle_reveal: "ff0180".to_string(), - solution: "80".to_string(), - }), - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coin_spend_wire( + &WalletCoinSpendResult { + spend: Some(WalletCoinSpend { + coin: a_spent_coin(), + puzzle_reveal: "ff0180".to_string(), + solution: "80".to_string(), + }), + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, @@ -5424,12 +5472,15 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletCoinSpendResult; - let wire = coin_spend_wire(&WalletCoinSpendResult { - spend: None, - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coin_spend_wire( + &WalletCoinSpendResult { + spend: None, + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, json!({ @@ -5623,14 +5674,17 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletCoinsByParentResult; - let wire = coins_by_parent_wire(&WalletCoinsByParentResult { - coins: vec![a_spent_coin()], - complete: false, - cursor: Some("aa".repeat(32)), - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coins_by_parent_wire( + &WalletCoinsByParentResult { + coins: vec![a_spent_coin()], + complete: false, + cursor: Some("aa".repeat(32)), + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, @@ -5664,14 +5718,17 @@ mod tests { use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletCoinsByParentResult; - let wire = coins_by_parent_wire(&WalletCoinsByParentResult { - coins: vec![], - complete: true, - cursor: None, - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let wire = coins_by_parent_wire( + &WalletCoinsByParentResult { + coins: vec![], + complete: true, + cursor: None, + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!( wire, @@ -5699,26 +5756,32 @@ mod tests { WalletCoinSpend, WalletCoinSpendResult, WalletCoinsByParentResult, }; - let spend = coin_spend_wire(&WalletCoinSpendResult { - spend: Some(WalletCoinSpend { - coin: a_spent_coin(), - puzzle_reveal: "01".into(), - solution: "80".into(), - }), - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let spend = coin_spend_wire( + &WalletCoinSpendResult { + spend: Some(WalletCoinSpend { + coin: a_spent_coin(), + puzzle_reveal: "01".into(), + solution: "80".into(), + }), + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!(spend["spend"]["coin"]["asset"], Value::Null); - let children = coins_by_parent_wire(&WalletCoinsByParentResult { - coins: vec![a_spent_coin()], - complete: true, - cursor: Some("aa".repeat(32)), - source: Source::Fallback, - synced: false, - peak_height: None, - }, None); + let children = coins_by_parent_wire( + &WalletCoinsByParentResult { + coins: vec![a_spent_coin()], + complete: true, + cursor: Some("aa".repeat(32)), + source: Source::Fallback, + synced: false, + peak_height: None, + }, + None, + ); assert_eq!(children["coins"][0]["asset"], Value::Null); } From 76f74645b608cfad7ff8fafac855f2babc77668b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 19:09:34 -0700 Subject: [PATCH 5/5] chore(release): dig-node 0.240.0 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27c86856..6f213a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.236.0" +version = "0.240.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index effdd8e9..129c10f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.236.0" +version = "0.240.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over