diff --git a/Cargo.lock b/Cargo.lock index 878a0995..10176d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.64" +version = "0.254.70" 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 c503ad35..bf882d96 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.64" +version = "0.254.70" # 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/SPEC.md b/SPEC.md index f7b18907..4cfc23c7 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/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." 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. ///