From a30ba1c99f18dac8c807401ed74c1d5d2e3198c7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 10:49:18 -0700 Subject: [PATCH 1/5] fix(wallet): discipline reservation liveness against a monotonic clock Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock readings. #528 closes the case where the clock is already wrong at the moment a reservation is FIRST written. It does not close the general form (#532): a wall clock stepped FORWARD while a reservation is already live, mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock -- produces no self-contradiction for #528's check to catch, yet the very next prune reads the jump as elapsed time and can retire a bundle's hold while it is still genuinely in flight, with no bound on how far forward the step goes (the #348/#497 double-spend direction). Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a monotonic clock says real time has actually elapsed. A forward wall-clock jump is absorbed rather than trusted and the disciplined clock simply runs behind until real time catches up, at which point it resumes tracking the wall clock with no special unfreeze step. A backward step is passed straight through unclamped, since it can only lengthen a hold, never shorten one -- the safe direction #502/#528 already accept elsewhere. The governor lives for the process's lifetime and is not persisted: a restart re-seeds it from the wall clock at that moment, so a clock already wrong at boot remains #528's write-time contradiction check's problem, not this one's. Closes #532 Co-Authored-By: Claude --- Cargo.lock | 2 +- SPEC.md | 17 ++ crates/dig-wallet/Cargo.toml | 2 +- crates/dig-wallet/src/sage/custody.rs | 281 ++++++++++++++++++++++++++ crates/dig-wallet/src/sage/rpc.rs | 34 +++- 5 files changed, 327 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac592cab..b32abf7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.48.0" +version = "0.48.1" dependencies = [ "async-trait", "axum", diff --git a/SPEC.md b/SPEC.md index 1cb5b188..17c5b74d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5820,6 +5820,23 @@ MUST increment the attempt count, and MUST NOT rewrite the anchor. A re-push MUS recorded deadline EARLIER, so that a clock which steps backwards cannot shorten a hold that is already live. +Every reading above is a raw wall-clock reading, and a wall clock can be stepped by the environment +at any time — an NTP correction, a VM pause/resume, an operator setting the clock — independent of +any push. A wall clock stepped FORWARD while a reservation is already live, mid-hold, produces no +value that is individually implausible: the anchor is unchanged and the jumped reading looks like an +ordinary "now". Left unguarded, the very next liveness check reads the jump as elapsed time and can +retire a bundle's hold while it is genuinely still in flight, with no bound on how far forward the +step goes — the double-spend direction this section exists to prevent, and strictly worse than the +bounded residue a bad reading AT THE FIRST PUSH leaves behind (dig-node#525). A node MUST therefore +discipline its "now" for reservation bookkeeping against a MONOTONIC clock: the disciplined value +MUST NOT advance, between two observations, by more than a steady clock reports has actually +elapsed in that interval. A wall clock that steps BACKWARD MUST be let through undisciplined — +that can only lengthen a hold, never shorten one, and disciplining it would mean judging a hold +expired sooner than either clock claims. This monotonic discipline is a property of the RUNNING +PROCESS only and is NOT itself persisted; across a restart it is re-seeded from the wall clock read +at that moment, so a clock that is already wrong AT BOOT remains the first-push anchor's problem, +governed by the FIRST-PUSH bound above, not by this discipline. + This bound is on CONTINUOUS hold. Once the deadline passes, the reservation and its coin claims are released and the inputs become selectable again; a subsequent push of the same transaction is a new reservation with a new anchor and MAY hold the inputs for a further full period. A node MUST NOT diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 94bb501d..5479a310 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.48.0" +version = "0.48.1" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." diff --git a/crates/dig-wallet/src/sage/custody.rs b/crates/dig-wallet/src/sage/custody.rs index 20841849..bdc42168 100644 --- a/crates/dig-wallet/src/sage/custody.rs +++ b/crates/dig-wallet/src/sage/custody.rs @@ -445,6 +445,82 @@ pub(super) fn now_ms() -> u64 { .unwrap_or(0) } +/// A disciplined view of [`now_ms`] that cannot advance faster than real time actually elapses. +/// +/// dig-node#502/#525/#528 anchor a reservation's lifetime (`RESERVATION_TTL_MS`, +/// `MAX_RESERVATION_HOLD_MS`) entirely on raw wall-clock readings — `submitted_at`/`expires_at` +/// are written from [`now_ms`], and every prune compares them against a fresh [`now_ms`] read. +/// #528 closes the case where the clock is ALREADY wrong at the instant a reservation is first +/// written (a self-contradiction between a row's own two columns). It does not close the general +/// form (dig-node#532): a wall clock that steps FORWARD while a reservation is already +/// live — an NTP step correction, a VM pause/resume, an operator setting the clock — produces no +/// such contradiction (the anchor and the jumped reading are each, in isolation, perfectly +/// ordinary), yet the very next prune reads the jumped clock as "this much time has passed" and +/// can retire a bundle's hold while it is still genuinely in flight. Unlike #528's residual, this +/// has NO bound at all: a clock stepped forward by a day retires every hold in the database on +/// the next read — the #348/#497 double-spend direction, with no ceiling. +/// +/// `ClockGovernor` closes it by refusing to let its reported "now" advance faster than a +/// **monotonic** clock says real time has elapsed since the last reading. A wall-clock jump +/// forward is absorbed rather than trusted: the reported value keeps pace with real time and +/// simply runs behind the wall clock until real time genuinely catches up, at which point it +/// resumes tracking the wall clock exactly as before. There is no permanent freeze here — once +/// real elapsed time reaches the jumped value, the clamp stops binding on its own +/// (`the_clamp_releases_itself_once_real_time_catches_up`). +/// +/// **The one direction this governor deliberately does NOT correct:** a wall clock that steps +/// BACKWARD is passed straight through, unclamped. That can only make a hold last LONGER than +/// intended, never shorter — the safe direction, and the one #502/#528 already accept elsewhere. +/// A governor that also clamped backward steps would be deciding a hold expired sooner than +/// either clock claims, which is exactly the failure this exists to close. +/// +/// Lives for the process's lifetime and is NOT persisted: a restart re-seeds it from whatever the +/// wall clock reads at that moment (`WalletBackend::new`). A clock that is already wrong AT BOOT +/// is therefore unguarded by this governor — that is `now_ms` written directly into a fresh +/// reservation, and #528's write-time contradiction check is what catches it. This governor's job +/// starts the instant after boot: a clock that reads fine at startup and jumps forward LATER, mid +/// process lifetime, mid hold. +pub(super) struct ClockGovernor { + /// The most recent reading this governor has vouched for. + disciplined_ms: i64, + /// The monotonic instant `disciplined_ms` was recorded at, so the NEXT reading is checked + /// against how much real time a steady clock says has elapsed since then. + anchor: std::time::Instant, +} + +impl ClockGovernor { + /// Seed the governor with the wall clock's current reading. Called once, at `WalletBackend` + /// construction — never mid-lifetime, or every re-seed would re-trust whatever the wall clock + /// says at that moment and defeat the discipline. + pub(super) fn new(wall_now_ms: i64) -> Self { + Self { + disciplined_ms: wall_now_ms, + anchor: std::time::Instant::now(), + } + } + + /// Accept a fresh wall-clock reading and return the disciplined value to use as "now" for + /// reservation bookkeeping. + pub(super) fn observe(&mut self, wall_now_ms: i64) -> i64 { + self.observe_at(wall_now_ms, std::time::Instant::now()) + } + + /// The pure decision, with the monotonic instant taken as an explicit parameter so tests can + /// construct two readings a known duration apart deterministically — `Instant + Duration` is a + /// real, valid `Instant`, so this needs no sleep and no fake-clock trait to be exact. + fn observe_at(&mut self, wall_now_ms: i64, at: std::time::Instant) -> i64 { + let elapsed_ms: i64 = at + .saturating_duration_since(self.anchor) + .as_millis() + .min(i64::MAX as u128) as i64; + let ceiling = self.disciplined_ms.saturating_add(elapsed_ms); + let disciplined = wall_now_ms.min(ceiling); + self.disciplined_ms = disciplined; + self.anchor = at; + disciplined + } +} + /// Restrict a file to owner read/write on Unix (`0600`); best-effort defense-in-depth (loopback-only /// + at-rest encryption are the primary controls). No-op on non-Unix. #[cfg(unix)] @@ -454,3 +530,208 @@ fn restrict_permissions(path: &Path) { } #[cfg(not(unix))] fn restrict_permissions(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + // ---- ClockGovernor (dig-node#532) ------------------------------------- + // + // Every test below drives `observe_at` with EXPLICIT `Instant` values rather than sleeping — + // `Instant::now() + Duration` is a real, valid instant a fixed distance from another, so the + // "real elapsed time" side of the decision is exact and the test is not flaky under load. + + #[test] + fn a_wall_clock_matching_real_time_passes_through_unclamped() { + let i0 = std::time::Instant::now(); + let mut gov = ClockGovernor { + disciplined_ms: 1_000, + anchor: i0, + }; + + // Five real seconds pass, and the wall clock agrees: it also reads five seconds later. + let disciplined = gov.observe_at(6_000, i0 + Duration::from_secs(5)); + + assert_eq!( + disciplined, 6_000, + "a wall clock that agrees with real elapsed time is never clamped" + ); + } + + #[test] + fn a_forward_jump_mid_hold_is_clamped_to_real_elapsed_time() { + let i0 = std::time::Instant::now(); + let mut gov = ClockGovernor { + disciplined_ms: 1_000, + anchor: i0, + }; + + // Only 100 real ms pass, but the wall clock jumps forward a full hour (an NTP step, a VM + // resume, an operator setting the clock) — exactly the case #528's write-time check + // cannot see, because nothing about the reading itself is self-contradictory. + let jumped_wall_clock = 1_000 + 3_600_000; + let disciplined = gov.observe_at(jumped_wall_clock, i0 + Duration::from_millis(100)); + + assert_eq!( + disciplined, 1_100, + "the jump is absorbed: the disciplined clock advances only by the real 100ms elapsed, \ + never by the hour the wall clock claims" + ); + } + + #[test] + fn a_backward_step_passes_through_unclamped() { + let i0 = std::time::Instant::now(); + let mut gov = ClockGovernor { + disciplined_ms: 1_000, + anchor: i0, + }; + + // The wall clock steps BACKWARD to 500 (before the anchor). This governor's one job is + // closing the EARLY-release direction; a backward step can only extend a hold, so it is + // let through exactly as #502/#528 already accept elsewhere. + let disciplined = gov.observe_at(500, i0 + Duration::from_millis(100)); + + assert_eq!( + disciplined, 500, + "a backward wall-clock step is never clamped -- it can only make a hold last longer" + ); + } + + #[test] + fn the_clamp_releases_itself_once_real_time_catches_up() { + let i0 = std::time::Instant::now(); + let mut gov = ClockGovernor { + disciplined_ms: 0, + anchor: i0, + }; + + // The wall clock jumps forward by one hour and then STAYS THERE (a one-time step, not a + // runaway clock) while real time keeps advancing normally underneath it. + let jumped = 3_600_000; + + // Immediately after the jump: almost no real time has passed, so the clamp binds hard. + let d1 = gov.observe_at(jumped, i0 + Duration::from_millis(1)); + assert_eq!( + d1, 1, + "right after the jump, real elapsed time still governs" + ); + + // Real time keeps advancing while the wall clock holds steady at `jumped`. + let d2 = gov.observe_at(jumped, i0 + Duration::from_secs(1800)); + assert_eq!( + d2, 1_800_000, + "the disciplined clock keeps tracking REAL elapsed time, still behind the jump" + ); + + // Once real elapsed time actually reaches the jumped value, the clamp stops binding on + // its own -- no special unfreeze step, no repair to run, unlike #525/#528's lockout. + let d3 = gov.observe_at(jumped, i0 + Duration::from_millis(3_600_000)); + assert_eq!( + d3, jumped, + "once real time catches up to the jumped wall clock, tracking resumes normally" + ); + + // And from here it tracks the wall clock again, exactly as if no jump had ever happened. + let d4 = gov.observe_at(jumped + 60_000, i0 + Duration::from_millis(3_660_000)); + assert_eq!( + d4, + jumped + 60_000, + "tracking is fully restored after the catch-up" + ); + } + + // ---- the money property this governor protects ------------------------ + // + // Built from the REAL `WalletDb` reservation API, not a hand-placed row -- the same shape + // #528's own regression tests use, and for the same reason: a fixture starting in a state + // production cannot reach hides the bug it is meant to catch. + + fn coin(id: &str) -> super::super::db::CoinRow { + super::super::db::CoinRow { + coin_id: id.into(), + parent_coin_info: "pp".into(), + puzzle_hash: "ph".into(), + amount: "100".into(), + created_height: Some(10), + spent_height: None, + asset_id: None, + hint: None, + created_timestamp: None, + spent_timestamp: None, + } + } + + fn reservation( + tx: &str, + coin_ids: &[&str], + submitted_at: i64, + expires_at: i64, + ) -> super::super::db::PendingTransactionRow { + super::super::db::PendingTransactionRow { + transaction_id: tx.into(), + bundle_hex: format!("bundle-of-{tx}"), + fee: Some("10".into()), + submitted_at, + expires_at, + attempts: 1, + reserved_coin_ids: coin_ids.iter().map(|c| (*c).to_string()).collect(), + } + } + + #[tokio::test] + async fn a_forward_clock_jump_mid_hold_no_longer_releases_a_live_reservation_early() { + let db = super::super::db::WalletDb::open_in_memory().await.unwrap(); + db.upsert_coin(&coin("c1")).await.unwrap(); + + let ttl = super::super::rpc::RESERVATION_TTL_MS; + let i0 = std::time::Instant::now(); + let mut gov = ClockGovernor { + disciplined_ms: 0, + anchor: i0, + }; + + // The bundle is pushed through the governed clock, at t=0. Real elapsed so far: none. + let submitted = gov.observe_at(0, i0); + db.reserve_spend(&reservation("tx1", &["c1"], submitted, submitted + ttl)) + .await + .unwrap(); + + // Two real minutes later, the wall clock is stepped forward by a full TTL's worth of time + // in one jump (an NTP step) -- comfortably enough, read raw, to look like the reservation + // has already lapsed, even though only two real minutes have actually passed. + let raw_jumped_wall_clock = ttl + 1; + let governed_now = gov.observe_at(raw_jumped_wall_clock, i0 + Duration::from_secs(120)); + assert!( + governed_now < ttl, + "the governed reading must stay well short of the reservation's real deadline -- a \ + raw read of {raw_jumped_wall_clock} would already exceed it" + ); + + // Pruning against the DISCIPLINED reading must not retire the still-live reservation. + db.prune_reservations(governed_now).await.unwrap(); + assert!( + db.unreserved_unspent_coins(None).await.unwrap().is_empty(), + "a mid-hold forward clock jump must not release a coin whose bundle may still be \ + genuinely in flight -- the #348/#497 double-spend direction" + ); + + // The coin still returns once its true (governed) deadline is actually reached. + let past_deadline = gov.observe_at( + raw_jumped_wall_clock, + i0 + Duration::from_millis(ttl as u64 + 1), + ); + db.prune_reservations(past_deadline).await.unwrap(); + assert_eq!( + db.unreserved_unspent_coins(None) + .await + .unwrap() + .into_iter() + .map(|c| c.coin_id) + .collect::>(), + vec!["c1".to_string()], + "the coin is released once real elapsed time actually reaches the reservation's TTL" + ); + } +} diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 641138d5..861a23e1 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -13,7 +13,7 @@ //! the plain-text message. use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle}; use serde::Serialize; @@ -689,6 +689,13 @@ pub struct WalletBackend { /// fallback is a cheap amplification/oracle surface, so its aggregate call rate is capped /// here. Shared across `Clone`s so one bucket governs the whole backend, not per-connection. fallback_rate: Arc, + /// Disciplines every reservation-lifecycle "now" reading against a monotonic clock, so a + /// wall-clock jump occurring MID-HOLD cannot retire a live reservation early (dig-node#532) — + /// see [`super::custody::ClockGovernor`] for why this is a different failure than #525/#528's + /// write-time contradiction check. Shared across every `Clone` of this backend, like + /// `custodied_public_keys`, so every writer and reader observes ONE continuous disciplined + /// timeline rather than each clone keeping its own. + reservation_clock: Arc>, } /// The connected client's PUBLIC identity for a session (#407). Scoping data only — no key. @@ -732,9 +739,22 @@ impl WalletBackend { DEFAULT_FALLBACK_BURST, DEFAULT_FALLBACK_REFILL_PER_SEC, )), + reservation_clock: Arc::new(Mutex::new(super::custody::ClockGovernor::new( + super::custody::now_ms() as i64, + ))), } } + /// The disciplined "now" for reservation-lifecycle bookkeeping (dig-node#532). Every write and + /// read in the reservation lifecycle (`reserve_coins`, `reserve_pushed_bundle`, + /// `prune_reservations`) MUST read the clock through here rather than calling + /// [`super::custody::now_ms`] directly, or it steps outside the discipline this exists to + /// provide — see [`super::custody::ClockGovernor`] for the reasoning and the failure it closes. + fn reservation_now_ms(&self) -> i64 { + let wall_now_ms = super::custody::now_ms() as i64; + self.reservation_clock.lock().unwrap().observe(wall_now_ms) + } + /// Override the coinset-fallback rate bound (#1957) — primarily for tests that want a small, /// deterministic pool. `capacity` is the immediate burst allowance; `refill_per_sec` the /// sustained rate (`0.0` = a fixed, non-replenishing pool). @@ -872,7 +892,7 @@ impl WalletBackend { /// caller to spend and "I cannot tell you" must stop it, and collapsing the two restores the /// double-select the set exists to prevent. pub async fn reservations_held(&self) -> sqlx::Result<(Vec, i64)> { - let now_ms = super::custody::now_ms() as i64; + let now_ms = self.reservation_now_ms(); // Retire what has lapsed before reporting, so a hold that is already over is never shown // to a caller as something to wait for. self.db.prune_reservations(now_ms).await?; @@ -892,7 +912,7 @@ impl WalletBackend { coin_ids: &[String], ttl_secs: Option, ) -> std::result::Result { - let now_ms = super::custody::now_ms() as i64; + let now_ms = self.reservation_now_ms(); self.db.prune_reservations(now_ms).await?; // `saturating_mul` rather than a cast: a caller naming a TTL near `u64::MAX` must not wrap // into a negative lifetime, which would produce a hold already expired at birth and read @@ -2326,7 +2346,7 @@ impl WalletBackend { /// legitimate — the mempool has already accepted it. An uncomputable fee is stored as `None` /// and reported as `null`, never as zero. async fn reserve_pushed_bundle(&self, bundle: &SpendBundle) -> Result<()> { - let now = super::custody::now_ms() as i64; + let now = self.reservation_now_ms(); let row = super::db::PendingTransactionRow { transaction_id: hex::encode(bundle.name()), bundle_hex: super::chain::encode_signed_bundle(bundle)?, @@ -2961,7 +2981,7 @@ impl WalletBackend { /// "nothing is in flight" — and this surface must never make one it cannot support. async fn get_pending_transactions(&self) -> Result { self.db - .prune_reservations(super::custody::now_ms() as i64) + .prune_reservations(self.reservation_now_ms()) .await?; let mut transactions = Vec::new(); for t in self.db.pending_transactions().await? { @@ -3228,7 +3248,7 @@ impl WalletBackend { async fn spendable_coins(&self, asset_id: Option<&str>) -> Result> { self.require_authoritative_coins().await?; self.db - .prune_reservations(super::custody::now_ms() as i64) + .prune_reservations(self.reservation_now_ms()) .await?; let rows = self.db.unreserved_unspent_coins(asset_id).await?; rows.iter().map(singleton::coin_from_row).collect() @@ -3695,7 +3715,7 @@ impl WalletBackend { // The unreserved set, for the same reason as `spendable_coins` (dig_ecosystem#2763): a CAT // coin committed to an in-flight bundle must not be selected into a second one. self.db - .prune_reservations(super::custody::now_ms() as i64) + .prune_reservations(self.reservation_now_ms()) .await?; let rows = select_cat_rows( self.db.unreserved_unspent_coins(Some(asset_id)).await?, From 079d24186c3e18ae3f1bcd917d479257e907ee2d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 11:04:12 -0700 Subject: [PATCH 2/5] chore(release): bump workspace version to 0.254.20 Root workspace version, per the main lane -- the minor field's version scheme is being fixed separately under #521/#522; this is the interim number to carry PR #539 (dig-node#532) through the version-increment gate. Cargo.lock refreshed in the same commit (cargo update -w --offline) so dig-node-service's locked entry matches -- every CI job runs --locked, and a manifest-only bump here fails Clippy/Test+coverage/all three package builds together on a change that cannot otherwise break a build. 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 b2357a87..7c5bbe7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.1" +version = "0.254.20" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index bff7d546..db7b4d3e 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.254.1" +version = "0.254.20" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From 849846ad082d11521ae7e2fcb7d37e1608e29206 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 12:43:33 -0700 Subject: [PATCH 3/5] fix(wallet): route wallet_reset_coin_db's now through ClockGovernor wallet_reset_coin_db read its now_ms from a fresh, undisciplined SystemTime::now() rather than WalletBackend::reservation_now_ms(), so a wall-clock jump mid-hold (an NTP correction, a VM pause/resume) could make its in-flight-spend check see a still-live reservation as already expired and let the reset proceed -- the #348/#497 double-spend direction, no attacker required. reservation_now_ms() is now pub so the control plane (a different crate) can route through it, sharing the same ClockGovernor clamp state every other reservation call site (reserve_coins, prune_reservations) already uses. Swept every reservation-touching path in dig-wallet and dig-node-service for a direct SystemTime::now() read; this was the only production one. Closes #541 Co-Authored-By: Claude --- SPEC.md | 2 +- crates/dig-node-service/src/control.rs | 34 +++++++++---- crates/dig-wallet/src/sage/rpc.rs | 68 ++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/SPEC.md b/SPEC.md index 36bc9255..44ec19b1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1706,7 +1706,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `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.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. That instant MUST be the SAME monotonic-disciplined clock every other reservation-bookkeeping read uses (dig-node#532, above), not an independent fresh wall-clock read: the two can disagree the instant the wall clock jumps forward mid-hold, and only the disciplined one keeps this refusal honest (dig-node#541). 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). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 7a8a0bdd..84fe49d1 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -2585,6 +2585,18 @@ fn operator_wallet_answer(ctx: &ControlCtx) -> WalletOperatorAddressResult { /// Key material. Every table it clears is chain-derived and reproduced by syncing; a seed is not. /// See [`dig_wallet::sage::db::WalletDb::reset_chain_cache`] for the table list, for why the /// authoritative flag is cleared in the SAME transaction, and for the in-flight-spend refusal. +/// +/// # Its clock is the disciplined one (dig-node#541) +/// +/// The `now_ms` fed into the in-flight-spend check comes from +/// [`dig_wallet::sage::rpc::WalletBackend::reservation_now_ms`] — the same +/// `ClockGovernor`-disciplined timeline `reserve_coins`/`prune_reservations` use — never a fresh, +/// independent `SystemTime::now()` read. This method lives in a different crate than the rest of +/// the reservation lifecycle, so it was (dig-node#541) the one call site that could silently drift +/// onto its own clock: a wall clock stepped forward mid-hold (an NTP correction, a VM +/// pause/resume) would make this reset's `now_ms` disagree with the reading that established the +/// hold, letting the `SpendInFlight` refusal below be bypassed — the #348/#497 double-spend +/// direction, since the reservation reads as already-expired though real time never moved. async fn wallet_reset_coin_db(ctx: &ControlCtx, id: Value, params: &Value) -> Value { if params.get("confirm").and_then(Value::as_bool) != Some(true) { return control_error( @@ -2598,16 +2610,18 @@ async fn wallet_reset_coin_db(ctx: &ControlCtx, id: Value, params: &Value) -> Va ); } - // The node's own clock. A caller-supplied instant would be a lapse oracle: a far-future value - // makes every live spend reservation read as expired, which is exactly the guard being asked - // to stand down. - let now_ms = i64::try_from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0), - ) - .unwrap_or(i64::MAX); + // The node's own clock, read through the SAME jump-disciplined timeline every other + // reservation call site uses (dig-node#532/#541) — never a fresh, undisciplined + // `SystemTime::now()` read. A caller-supplied instant would be a lapse oracle: a far-future + // value makes every live spend reservation read as expired, which is exactly the guard being + // asked to stand down. An UNDISCIPLINED node-local read has the same effect by accident: a + // wall clock stepped forward mid-hold (an NTP correction, a VM pause/resume) would make this + // reset's `now_ms` disagree with the reading `reserve_coins`/`prune_reservations` used to + // establish the hold, letting a still-live reservation's `SpendInFlight` refusal be bypassed — + // the #348/#497 double-spend direction. `reservation_now_ms()` shares its clamp state with + // every other reservation call site precisely so this one cannot see a different "now" than + // they do. + let now_ms = ctx.wallet.reservation_now_ms(); match ctx.wallet.reset_coin_db(now_ms).await { Ok(Ok(report)) => control_ok( diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index ffd932b4..685fa376 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -747,10 +747,17 @@ impl WalletBackend { /// The disciplined "now" for reservation-lifecycle bookkeeping (dig-node#532). Every write and /// read in the reservation lifecycle (`reserve_coins`, `reserve_pushed_bundle`, - /// `prune_reservations`) MUST read the clock through here rather than calling - /// [`super::custody::now_ms`] directly, or it steps outside the discipline this exists to - /// provide — see [`super::custody::ClockGovernor`] for the reasoning and the failure it closes. - fn reservation_now_ms(&self) -> i64 { + /// `prune_reservations`, and the control plane's `wallet_reset_coin_db`, dig-node#541) MUST + /// read the clock through here rather than calling [`super::custody::now_ms`] directly, or it + /// steps outside the discipline this exists to provide — see + /// [`super::custody::ClockGovernor`] for the reasoning and the failure it closes. + /// + /// `pub` because the reset control method lives in `dig-node-service`, a different crate: the + /// reservation lifecycle it prunes before resetting (see + /// [`WalletDb::reset_chain_cache`](super::db::WalletDb::reset_chain_cache)'s `SpendInFlight` + /// refusal) must be judged against the SAME shared, jump-disciplined timeline every other + /// reservation call site uses, never a second, independent wall-clock read. + pub fn reservation_now_ms(&self) -> i64 { let wall_now_ms = super::custody::now_ms() as i64; self.reservation_clock.lock().unwrap().observe(wall_now_ms) } @@ -11091,6 +11098,59 @@ mod tests { "reserving a coin must not remove it from what the wallet owns" ); } + + /// **The decision point dig-node#541 fixes.** `control.wallet.resetCoinDb` + /// (`dig-node-service::control::wallet_reset_coin_db`) must read its `now_ms` through + /// [`WalletBackend::reservation_now_ms`], never through an independent `SystemTime::now()` + /// read — the two disagree the instant a wall clock jumps forward mid-hold, and only the + /// disciplined one keeps a live reservation's `SpendInFlight` refusal honest. + /// + /// Built from a REAL `reserve_coins` call — dig-node#528's own lesson was that a hand-placed + /// row in a state production cannot reach passes under the defect — so the reservation's + /// `expires_at_ms` is exactly what the disciplined clock itself would have written. + #[tokio::test] + async fn reset_coin_db_now_must_come_from_the_disciplined_clock_not_a_fresh_read() { + let be = backend_with(vec![], true).await; + + // A real 60s hold, established through the disciplined path exactly as + // `control.wallet.reservations.reserve` would create one. + be.reserve_coins(&["c1".to_string()], Some(60)) + .await + .expect("reserving a fresh coin id never clashes"); + + // The wall clock jumps two minutes forward — an NTP step, a VM pause/resume — while + // barely any REAL time has elapsed since the reservation was written. `observe` is the + // exact production method `reservation_now_ms()` calls; only the wall reading is + // fabricated, so the jump is deterministic instead of waiting on a real clock. + let jumped_wall_ms = super::super::custody::now_ms() as i64 + 120_000; + let disciplined_after_jump = be.reservation_clock.lock().unwrap().observe(jumped_wall_ms); + + // THE FIX: reading through the disciplined clock, the reservation is still judged live — + // almost no real time passed, so the clamp refuses to let "now" run ahead of it. + let fixed = be + .db + .reset_chain_cache(disciplined_after_jump) + .await + .unwrap(); + assert!( + matches!( + fixed, + Err(super::super::db::ResetRefusal::SpendInFlight { .. }) + ), + "the disciplined clock must still see the 60s hold as live seconds after it was taken, jumped wall clock notwithstanding — got {fixed:?}" + ); + + // THE DEFECT this ticket closes: had `wallet_reset_coin_db` instead fed the raw, jumped + // wall reading straight in — exactly what `SystemTime::now()` would have returned at this + // same real instant, pre-#541 — the still-live hold reads as already expired and the + // reset proceeds: the #348/#497 double-spend direction, no attacker required, just an + // ordinary clock step. + let undisciplined = be.db.reset_chain_cache(jumped_wall_ms).await.unwrap(); + assert!( + undisciplined.is_ok(), + "sanity: an undisciplined jumped reading DOES bypass the refusal, which is exactly why the control-plane call site must never use one" + ); + } /// A bundle spending exactly the coin `spendable_row(id_byte, amount)` describes, in the hex /// form the wire carries — returned alongside the ids the production path will derive from it. /// From c54c4deba67dd01d9670f7f9744f30fbcdd36d16 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 12:47:47 -0700 Subject: [PATCH 4/5] chore(release): bump dig-node 0.254.42 / dig-wallet 0.49.0 dig-wallet: minor -- reservation_now_ms is now a public API surface (dig-node-service routes through it, dig-node#541). dig-node: patch -- behaviour fix, no breaking change. Co-Authored-By: Claude --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/dig-wallet/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4954f03..d8eb82fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.41" +version = "0.254.42" dependencies = [ "async-trait", "axum", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.48.1" +version = "0.49.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index c7276050..c240d628 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.254.41" +version = "0.254.42" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 5479a310..0641f8ea 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.48.1" +version = "0.49.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." From a1ffc66985e0ae7e81248b5d03646ef35f30b40a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 12:54:07 -0700 Subject: [PATCH 5/5] chore(release): re-bump to 0.254.44 -- coordinator-assigned to avoid collision with #542 #542 keeps 0.254.42 (urgent required-CI-gate PR, merges first); 0.254.43 is reserved for #539, which this branch sits on top of. Version assignment across concurrent PRs is the coordinator's per CLAUDE.md section 1.4. 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 d8eb82fc..27868640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.42" +version = "0.254.44" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index c240d628..1b8e5ef2 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.254.42" +version = "0.254.44" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over