From b244c8be3bdffdd4ff31047e2be9a46aac07ab9f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 26 Aug 2026 23:56:04 -0700 Subject: [PATCH 01/10] chore(mirror-lifecycle): open the #377 lane --- crates/dig-node-service/src/mirror_lifecycle.rs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 crates/dig-node-service/src/mirror_lifecycle.rs diff --git a/crates/dig-node-service/src/mirror_lifecycle.rs b/crates/dig-node-service/src/mirror_lifecycle.rs new file mode 100644 index 00000000..44c2f145 --- /dev/null +++ b/crates/dig-node-service/src/mirror_lifecycle.rs @@ -0,0 +1,4 @@ +//! The mirror-coin lifecycle (dig-node#377) — WIP scaffold. +//! +//! Presence of a `.dig` file on disk is the trigger: a store+root this node serves gets an +//! on-chain mirror coin locking 20 $DIG, and a coin whose `.dig` is gone gets reclaimed. From 6e98036a8b8e66eb08f6ab1c00b06dcc3a5706d6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 27 Aug 2026 07:16:18 -0700 Subject: [PATCH 02/10] feat(mirror): salvage the lifecycle module from a capped lane (UNVERIFIED, not wired in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from a lane that hit the weekly usage cap mid-implementation. Pushed so the work is durable, NOT because it is finished. State, stated honestly so the next lane does not mistake this for working code: * `mod mirror;` is NOT declared anywhere, so the module is inert. It is not compiled as part of the crate, and a green CI on this commit says nothing about it. * It has never been compiled or tested. Treat every signature as a proposal. * The lane's own last message said it was about to start "the two structural pieces: the scoped key and the MirrorSpends newtype" — so the signing path is the part most likely to be missing or wrong. What is here: mod.rs (the design rationale), plan.rs, presence.rs, spends.rs. The design decisions recorded in mod.rs are worth keeping even if the code is rewritten — particularly that reclaims run FIRST in every pass and are NEVER gated on funds, because a reclaim returns collateral that may fund the creates behind it, and a wallet at zero must not be unable to recover what it already locked. Refs https://github.com/DIG-Network/dig-node/issues/377 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/mod.rs | 53 ++ crates/dig-node-service/src/mirror/plan.rs | 573 ++++++++++++++++++ .../dig-node-service/src/mirror/presence.rs | 297 +++++++++ crates/dig-node-service/src/mirror/spends.rs | 141 +++++ 4 files changed, 1064 insertions(+) create mode 100644 crates/dig-node-service/src/mirror/mod.rs create mode 100644 crates/dig-node-service/src/mirror/plan.rs create mode 100644 crates/dig-node-service/src/mirror/presence.rs create mode 100644 crates/dig-node-service/src/mirror/spends.rs diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs new file mode 100644 index 00000000..fb5eaa4f --- /dev/null +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -0,0 +1,53 @@ +//! The mirror-coin lifecycle (dig-node#377) — presence of a `.dig` on disk, made true on chain. +//! +//! A **mirror coin** locks 20 $DIG to advertise that this node serves one `(store, root)` for one +//! epoch. This module is what keeps that advertisement honest: a capsule this node holds and is +//! willing to serve gets a coin, and a coin whose capsule is gone gets reclaimed. +//! +//! # Reclaim is loss avoidance, not cleanup +//! +//! A live coin advertising a capsule the node cannot serve is **penalised later**. So the reclaim +//! path is not tidying up after the interesting work — it is the half where money is at stake, and +//! it is held to a higher standard than the create path: +//! +//! * **Reclaims run first in every pass.** A reclaim returns collateral, which may fund the creates +//! behind it, and a reclaim withheld because the wallet is short is the legacy defect where a +//! wallet at zero could neither advertise nor recover what it had already locked. +//! * **Reclaims are never gated on funds.** [`plan::split_by_funds`] never sees them. +//! * **The start-up reconcile is the reliable path, not the file watcher.** A watcher's event is +//! exactly what a crash loses; a scan at start-up re-derives the whole answer from two +//! observations that survive anything. +//! +//! # The node signs these itself — a carve-out scoped by a separate key, not by a permission +//! +//! §908 says the node signs nothing on the user's behalf, and dig-node holds no user spend key at +//! all (the `wallet.*`/`auth.*` custody surface is retired, dig_ecosystem#1701). The carve-out is +//! therefore not a relaxation of that: mirror coins are spent by a **dedicated operating key** this +//! node derives for itself ([`key`]), which the user funds. The node signs its OWN wallet, and that +//! key controls nothing the user owns. +//! +//! Scope is then held by a type rather than a runtime check. [`spends::MirrorSpends`] has no public +//! constructor; the only producers wrap `dig_mirror_coin::create` and `::reclaim`, and the signer's +//! only entry point takes one. There is no method anywhere on this path that accepts an arbitrary +//! `CoinSpend`, so the reachable spend shapes are mirror-coin create and mirror-coin reclaim by +//! construction. +//! +//! # Accountability is what pays for it +//! +//! Because the user cannot approve each spend, they are owed a complete account of every spend made +//! without asking. The signer takes a +//! [`RecordedSpend`](crate::spend_audit::RecordedSpend) (dig-node#376), whose only source is +//! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin) — so recording is the SHAPE of +//! the call rather than a convention a later producer can forget. +//! +//! # Nothing here re-derives the epoch or the hint +//! +//! The epoch comes from `dig_constants::mirror_epoch_at_unix_ms` and the hint from +//! `dig_mirror_coin::mirror_hint`. Both are canonical, and a locally computed version of either +//! would put coins under a value no verifier queries — collateral that is genuinely locked and +//! genuinely invisible. + +pub mod key; +pub mod plan; +pub mod presence; +pub mod spends; diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs new file mode 100644 index 00000000..e594dfa7 --- /dev/null +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -0,0 +1,573 @@ +//! The reconcile PLAN — what the chain must be made to look like, given what is on disk. +//! +//! This module is pure. It takes two snapshots — the capsules this node holds on disk, and the +//! mirror coins the chain says this node owns — and returns the work that would make them agree. It +//! reads no file, opens no socket, holds no key and consults no clock: the epoch is a parameter. +//! +//! Keeping it pure is what makes the hostile cases testable at all. Every interesting situation here +//! is a disagreement between two observations — a coin whose `.dig` is gone, a `.dig` whose coin was +//! never created, both epochs live across a rollover — and each of those is a two-line fixture +//! against a function, rather than a filesystem and a chain that must be induced into a state. +//! +//! # Local bookkeeping is never a third source of truth +//! +//! The legacy implementation kept an authoritative `.json` and stranded the money when it was lost +//! (measured on dig-node#377). There is no equivalent here, by construction: the planner's inputs are +//! the disk and the chain, and nothing it writes feeds back into what it reads. Handed a stale view +//! of either, it produces a plan that is wrong for that call and correct again on the next pass. + +use std::collections::BTreeSet; + +/// A `(store, root)` pair this node holds and is willing to advertise — one prospective mirror. +/// +/// "Willing to advertise" is not the same as "present on disk". A capsule pulled on a stranger's +/// behalf is marked `Relayed` and is deliberately never advertised (dig-node#276), so it is not a +/// bond: staking 20 $DIG on it would be paying for an advertisement that is never published. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Bond { + /// Store launcher id, lowercase 64-hex. + pub store_id: String, + /// Generation root hash, lowercase 64-hex. + pub root: String, +} + +impl Bond { + /// A bond over two ids. + pub fn new(store_id: impl Into, root: impl Into) -> Self { + Bond { + store_id: store_id.into(), + root: root.into(), + } + } +} + +/// One mirror coin the chain says this node owns. +/// +/// Produced from `dig_mirror_coin::list`, whose ownership comes from the coin's lineage proof rather +/// than from a hint — so a coin appearing here is one this node can actually spend, not one a +/// stranger hinted at it for the price of a dust coin. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct HeldMirror { + /// The coin id, for the audit record and for the reclaim. + pub coin_id: String, + /// The store this coin declares it bonds. + pub store_id: String, + /// The root this coin declares it bonds. + pub root: String, + /// The epoch this coin declares it bonds. + pub epoch: i64, + /// The $DIG locked, in CAT mojos. + pub collateral_cat_mojos: u64, +} + +impl HeldMirror { + /// The bond this coin claims to cover. + fn bond(&self) -> Bond { + Bond::new(&self.store_id, &self.root) + } +} + +/// Why a held coin is being reclaimed. Recorded because the two reasons are very different +/// situations, and an operator reading the audit record needs to know which one they are looking at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ReclaimReason { + /// The `.dig` this coin bonds is no longer on disk, and the coin is for the CURRENT epoch. + /// + /// **This is the penalised state**, and it is why reclaim is loss avoidance rather than cleanup: + /// a live coin advertising a capsule the node cannot serve is penalised later. It is also the + /// one reclaim a crash can leave behind, because the file event that would have triggered it is + /// exactly what a crash loses — which is why the start-up reconcile matters more than the + /// watcher does. + NoLongerHeld, + /// The coin bonds an epoch that has already ended. + /// + /// The legacy had this as an operational step a human ran, and dig-node has no operator — so + /// leaving it manual would strand 20 $DIG per store per epoch, forever, with nobody to notice. + EpochEnded, +} + +/// What must happen to make the chain agree with the disk. +/// +/// Reclaims are listed first and executed first, deliberately: a reclaim RETURNS money and may fund +/// the creates behind it, and a reclaim withheld because the wallet is short is the legacy defect +/// where a wallet at zero could neither advertise nor recover what it had already locked. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MirrorPlan { + /// Coins to spend back to the owner, with the reason each is being released. + pub reclaim: Vec<(HeldMirror, ReclaimReason)>, + /// Bonds with no coin for the current epoch, to be created. + pub create: Vec, +} + +impl MirrorPlan { + /// Is there nothing to do? The steady state on a node whose disk has not changed within an epoch. + pub fn is_empty(&self) -> bool { + self.reclaim.is_empty() && self.create.is_empty() + } +} + +/// Diff the disk against the chain for `current_epoch`. +/// +/// `held` is what this node is willing to advertise; `on_chain` is what it actually owns. +/// +/// | held coin | on disk | action | +/// |---|---|---| +/// | `epoch == current` | yes | keep | +/// | `epoch == current` | no | reclaim ([`ReclaimReason::NoLongerHeld`]) | +/// | `epoch < current` | either | reclaim ([`ReclaimReason::EpochEnded`]) | +/// | `epoch > current` | either | **keep** | +/// +/// The last row is a decision, not a gap. The epoch clock is wall-clock with no chain input +/// (`dig_constants::mirror_epoch_at_unix_ms`), so a node whose clock runs slow sees a legitimately +/// created next-epoch coin as belonging to the future. Reclaiming on that reading would burn a fee +/// and destroy a valid bond on the strength of this machine's clock being wrong — and the coin +/// becomes ordinary at the next tick anyway. Keeping is the direction whose failure is recoverable. +/// +/// Duplicate coins for one `(store, root, epoch)` are ALL kept while the bond is still held. Only +/// one is needed, but choosing which of two valid bonds to destroy is a decision this function does +/// not have the information to make, and destroying the wrong one costs a real advertisement. They +/// stop being duplicates at the next rollover, when both are reclaimed as `EpochEnded`. +pub fn plan(held: &[Bond], on_chain: &[HeldMirror], current_epoch: i64) -> MirrorPlan { + let held_set: BTreeSet<&Bond> = held.iter().collect(); + + let mut reclaim = Vec::new(); + let mut covered: BTreeSet = BTreeSet::new(); + + for coin in on_chain { + match coin.epoch.cmp(¤t_epoch) { + std::cmp::Ordering::Less => { + reclaim.push((coin.clone(), ReclaimReason::EpochEnded)); + } + std::cmp::Ordering::Equal => { + let bond = coin.bond(); + if held_set.contains(&bond) { + covered.insert(bond); + } else { + reclaim.push((coin.clone(), ReclaimReason::NoLongerHeld)); + } + } + // A coin bonding a FUTURE epoch. Left alone — see the doc comment. + std::cmp::Ordering::Greater => {} + } + } + + let mut create: Vec = held + .iter() + .filter(|b| !covered.contains(*b)) + .cloned() + .collect(); + + // Deterministic order, so a partially funded pass creates the same prefix on every run rather + // than a different arbitrary subset each time — which would make an out-of-funds node's + // behaviour unreproducible exactly when someone is trying to understand it. + create.sort(); + create.dedup(); + reclaim.sort(); + + MirrorPlan { reclaim, create } +} + +/// How far down [`MirrorPlan::create`] the wallet can pay, and what is left short. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingSplit { + /// The creates the wallet can pay for, in plan order. + pub affordable: Vec, + /// The creates it cannot — what the node must report as uncollateralised. + pub short: Vec, + /// How much more $DIG, in CAT mojos, would cover [`Self::short`] entirely. + pub shortfall_cat_mojos: u64, +} + +impl FundingSplit { + /// Is anything going uncollateralised for want of funds? + pub fn is_funded(&self) -> bool { + self.short.is_empty() + } +} + +/// Split `create` at the point the balance runs out. +/// +/// Creates STOP at the first unaffordable one rather than skipping it to take a cheaper later one. A +/// mirror coin is all-or-nothing at 20 $DIG — there is no partial collateralisation — so there is no +/// cheaper one to find, and hunting for an affordable subset would only make which stores get +/// advertised depend on the balance in a way nobody could predict or explain. +/// +/// Funds are deliberately NOT consulted for reclaims. A reclaim returns collateral; gating it on the +/// balance is the legacy defect where a wallet at zero could neither advertise nor recover what it +/// had already locked. +/// +/// `per_coin` is `dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS` — CAT mojos, never whole $DIG, +/// which is 1,000x smaller and would make everything look affordable. +pub fn split_by_funds(create: &[Bond], balance_cat_mojos: u64, per_coin: u64) -> FundingSplit { + // A zero per-coin collateral would make every bond free and the split meaningless. The crate + // refuses a zero-collateral mirror anyway, so treat it as "nothing is affordable" rather than + // dividing by it and reporting infinite capacity. + let affordable_count = if per_coin == 0 { + 0 + } else { + ((balance_cat_mojos / per_coin) as usize).min(create.len()) + }; + + let (affordable, short) = create.split_at(affordable_count); + FundingSplit { + affordable: affordable.to_vec(), + short: short.to_vec(), + shortfall_cat_mojos: (short.len() as u64).saturating_mul(per_coin), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS; + + /// A distinguishable 64-hex id. Real ids are opaque, and tests that use short strings hide + /// length assumptions in the path builders that consume them. + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) + } + + fn coin(tag: &str, store: &str, root: &str, epoch: i64) -> HeldMirror { + HeldMirror { + coin_id: id(tag), + store_id: id(store), + root: id(root), + epoch, + collateral_cat_mojos: MIRROR_COIN_COLLATERAL_CAT_MOJOS, + } + } + + /// The epoch every fixture below is "now" in. Pinned rather than computed from the wall clock: + /// a fixture whose epoch comes from `SystemTime::now()` exercises whichever branch today + /// happens to select, and passes for the wrong reason on most days. + const NOW_EPOCH: i64 = 100; + + #[test] + fn a_held_capsule_with_no_coin_is_created() { + let plan = plan(&[bond("aa", "11")], &[], NOW_EPOCH); + assert_eq!(plan.create, vec![bond("aa", "11")]); + assert!(plan.reclaim.is_empty()); + } + + #[test] + fn a_coin_covering_a_held_capsule_this_epoch_is_left_alone() { + let plan = plan( + &[bond("aa", "11")], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + ); + assert!(plan.is_empty(), "steady state must be a no-op: {plan:?}"); + } + + /// The penalised state, and the case the whole feature exists to avoid. + /// + /// The fixture keeps a SECOND store that is still held, and asserts it is untouched. Without + /// that control an implementation that reclaims every coin it sees passes identically — the + /// "strongest" fixture, one where nothing is held, is the one that cannot see the difference. + #[test] + fn a_coin_whose_capsule_is_gone_is_reclaimed_and_the_still_held_one_is_not() { + let plan = plan( + &[bond("aa", "11")], + &[ + coin("c1", "aa", "11", NOW_EPOCH), + coin("c2", "bb", "22", NOW_EPOCH), + ], + NOW_EPOCH, + ); + + assert_eq!( + plan.reclaim, + vec![(coin("c2", "bb", "22", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + "only the coin whose capsule is gone may be reclaimed" + ); + assert!( + plan.create.is_empty(), + "the still-held capsule is already covered" + ); + } + + /// The automatic `meltOutdatedEpochs` the legacy left to a human. + /// + /// The previous epoch's coin bonds a capsule that is STILL on disk, so a planner that only + /// reclaimed no-longer-held coins would leave it stranded forever. That is the actual legacy + /// defect, and a fixture where the capsule had also been deleted could not tell the two rules + /// apart. + #[test] + fn last_epochs_coin_is_reclaimed_even_though_its_capsule_is_still_held() { + let plan = plan( + &[bond("aa", "11")], + &[coin("old", "aa", "11", NOW_EPOCH - 1)], + NOW_EPOCH, + ); + + assert_eq!( + plan.reclaim, + vec![(coin("old", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)] + ); + assert_eq!( + plan.create, + vec![bond("aa", "11")], + "the still-held capsule needs a coin for the CURRENT epoch" + ); + } + + /// Rollover with both epochs live: the previous coin comes back and the new one goes out, in + /// the same pass. A node that did one but not the other is either uncollateralised or paying + /// twice. + #[test] + fn an_epoch_rollover_reclaims_the_old_coin_and_creates_the_new_one() { + let plan = plan( + &[bond("aa", "11")], + &[ + coin("old", "aa", "11", NOW_EPOCH - 1), + coin("new", "aa", "11", NOW_EPOCH), + ], + NOW_EPOCH, + ); + + assert_eq!( + plan.reclaim, + vec![(coin("old", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)] + ); + assert!( + plan.create.is_empty(), + "the current epoch is already covered — creating again would pay twice" + ); + } + + /// A coin bonding a FUTURE epoch is kept. + /// + /// The control matters here: the same fixture carries a coin one epoch in the PAST, which must + /// be reclaimed. A planner that simply ignored every epoch mismatch would pass a + /// future-only fixture and strand the past coin, which is the expensive direction. + #[test] + fn a_future_epoch_coin_is_kept_while_a_past_epoch_coin_is_reclaimed() { + let plan = plan( + &[bond("aa", "11")], + &[ + coin("future", "aa", "11", NOW_EPOCH + 1), + coin("past", "aa", "11", NOW_EPOCH - 1), + ], + NOW_EPOCH, + ); + + assert_eq!( + plan.reclaim, + vec![(coin("past", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)], + "a coin from the future must survive a slow local clock" + ); + assert_eq!( + plan.create, + vec![bond("aa", "11")], + "the future coin does not cover the current epoch" + ); + } + + /// Two roots of ONE store are two independent bonds — the per-store-plus-root shape. A coin for + /// the old root does not cover the new one, and the publisher may fund either. + #[test] + fn two_roots_of_one_store_are_two_independent_bonds() { + let plan = plan( + &[bond("aa", "11"), bond("aa", "22")], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + ); + + assert_eq!( + plan.create, + vec![bond("aa", "22")], + "the second root needs its own coin" + ); + assert!( + plan.reclaim.is_empty(), + "the first root's coin still covers a held capsule" + ); + } + + /// A store whose root MOVED: the old root's capsule is gone, the new root's is held. Both + /// halves must happen, and a planner keyed on the store alone — the legacy shape — would see no + /// change at all. + #[test] + fn a_root_that_moved_reclaims_the_old_bond_and_creates_the_new_one() { + let plan = plan( + &[bond("aa", "22")], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + ); + + assert_eq!( + plan.reclaim, + vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)] + ); + assert_eq!(plan.create, vec![bond("aa", "22")]); + } + + /// Duplicate coins for one bond are both kept while it is held — and both reclaimed once the + /// capsule goes, rather than one being silently abandoned. + #[test] + fn duplicate_coins_for_one_bond_are_kept_while_held_and_both_reclaimed_when_it_goes() { + let held = plan( + &[bond("aa", "11")], + &[ + coin("c1", "aa", "11", NOW_EPOCH), + coin("c2", "aa", "11", NOW_EPOCH), + ], + NOW_EPOCH, + ); + assert!(held.is_empty(), "neither duplicate is destroyed while held"); + + let gone = plan( + &[], + &[ + coin("c1", "aa", "11", NOW_EPOCH), + coin("c2", "aa", "11", NOW_EPOCH), + ], + NOW_EPOCH, + ); + assert_eq!( + gone.reclaim.len(), + 2, + "both duplicates come back, or one is stranded: {gone:?}" + ); + } + + /// A duplicated bond on disk yields ONE create, not two. A capsule listed twice by a scan that + /// saw both a legacy and a migrated artifact must not pay 40 $DIG for one advertisement. + #[test] + fn a_bond_listed_twice_on_disk_is_created_once() { + let plan = plan(&[bond("aa", "11"), bond("aa", "11")], &[], NOW_EPOCH); + assert_eq!(plan.create, vec![bond("aa", "11")]); + } + + #[test] + fn creates_are_ordered_deterministically_regardless_of_scan_order() { + let forward = plan( + &[bond("aa", "11"), bond("bb", "22"), bond("cc", "33")], + &[], + NOW_EPOCH, + ); + let reversed = plan( + &[bond("cc", "33"), bond("bb", "22"), bond("aa", "11")], + &[], + NOW_EPOCH, + ); + assert_eq!( + forward.create, reversed.create, + "a partially funded pass must fund the same prefix on every run" + ); + } + + #[test] + fn a_fully_funded_wallet_creates_everything_and_is_short_nothing() { + let creates = vec![bond("aa", "11"), bond("bb", "22")]; + let split = split_by_funds( + &creates, + 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + ); + + assert!(split.is_funded()); + assert_eq!(split.affordable, creates); + assert_eq!(split.shortfall_cat_mojos, 0); + } + + /// The bound from BOTH sides. Exactly one coin's worth funds exactly one coin, and one mojo + /// under funds none — a split tested only from above would pass while silently rounding up. + #[test] + fn the_collateral_bound_is_exact_in_both_directions() { + let creates = vec![bond("aa", "11")]; + + let at_bound = split_by_funds( + &creates, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + ); + assert!(at_bound.is_funded(), "exactly 20 $DIG funds exactly one coin"); + + let one_under = split_by_funds( + &creates, + MIRROR_COIN_COLLATERAL_CAT_MOJOS - 1, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + ); + assert!( + !one_under.is_funded(), + "one mojo short must not collateralise anything" + ); + assert_eq!(one_under.shortfall_cat_mojos, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + } + + /// The wallet that reads as rich because someone used whole $DIG where CAT mojos were meant. + /// 20 is the whole-$DIG figure and it must fund NOTHING; the 1,000x confusion is the specific + /// mistake `dig-constants` pins two constants to prevent. + #[test] + fn a_balance_of_twenty_whole_dig_expressed_as_mojos_funds_nothing() { + let creates = vec![bond("aa", "11")]; + let split = split_by_funds( + &creates, + dig_constants::MIRROR_COIN_COLLATERAL_DIG, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + ); + assert!( + !split.is_funded(), + "20 CAT mojos is 0.02 $DIG and collateralises nothing" + ); + } + + /// Partial funding stops at the first unaffordable create and reports the rest, rather than + /// skipping ahead. The fixture has THREE bonds and funds for two, so a "take everything you can + /// afford in any order" implementation and a "stop at the first miss" one are distinguishable. + #[test] + fn a_partially_funded_wallet_stops_at_the_first_unaffordable_create() { + let creates = vec![bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; + let split = split_by_funds( + &creates, + 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS, + MIRROR_COIN_COLLATERAL_CAT_MOJOS, + ); + + assert_eq!(split.affordable, vec![bond("aa", "11"), bond("bb", "22")]); + assert_eq!(split.short, vec![bond("cc", "33")]); + assert_eq!(split.shortfall_cat_mojos, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + } + + /// An empty wallet is short everything and creates nothing — and, crucially, this says nothing + /// about reclaims: [`split_by_funds`] never sees them, which is how a wallet at zero still + /// recovers what it has already locked. + #[test] + fn an_empty_wallet_creates_nothing_and_reports_the_whole_shortfall() { + let creates = vec![bond("aa", "11"), bond("bb", "22")]; + let split = split_by_funds(&creates, 0, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + + assert!(split.affordable.is_empty()); + assert_eq!(split.short, creates); + assert_eq!( + split.shortfall_cat_mojos, + 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS + ); + } + + /// An empty wallet with coins to reclaim still reclaims them. This is the assertion that makes + /// the funds/reclaim independence load-bearing rather than incidental: the legacy could neither + /// advertise nor recover at zero balance, and that is what stranded the money. + #[test] + fn a_wallet_at_zero_still_reclaims_what_it_already_locked() { + let plan = plan(&[], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH); + let split = split_by_funds(&plan.create, 0, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + + assert_eq!( + plan.reclaim, + vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + "reclaim must not be gated on the balance" + ); + assert!(split.affordable.is_empty()); + } +} diff --git a/crates/dig-node-service/src/mirror/presence.rs b/crates/dig-node-service/src/mirror/presence.rs new file mode 100644 index 00000000..3f21d5b5 --- /dev/null +++ b/crates/dig-node-service/src/mirror/presence.rs @@ -0,0 +1,297 @@ +//! Debounce — deciding when a `.dig` on disk has been there long enough to be worth 20 $DIG. +//! +//! # What this does NOT guard against +//! +//! A capsule this node pulls for itself is never observed half-written: it stages under the +//! downloads directory as `-.dig` and is only renamed into +//! `/modules//.dig` after it verifies. The inventory scan reads the second +//! location, so the node's own writes are already atomic from the scan's point of view. +//! +//! The case that remains is the one a person creates: a `.dig` copied in by hand, moved across a +//! filesystem boundary, or written by an unrelated tool — and, symmetrically, one deleted and +//! restored while a pass was mid-flight. For those, a file that exists is not yet a file that is +//! being served. +//! +//! # Stability, not a timer after an event +//! +//! The rule is that a bond must be observed in the SAME state across a settling window before that +//! state is acted on. That is deliberately not "wait N seconds after the last event": an event-timer +//! is reset by each new event, so a directory being rewritten repeatedly never settles, and it also +//! cannot see a change that produced no event at all — which is every change that happened while the +//! process was not running. +//! +//! Observing state instead means a restart re-derives everything it needs from two scans, and a +//! capsule that appears and vanishes within the window is never acted on in either direction: it was +//! never stable, so neither the create nor the reclaim it would have implied is reached. +//! +//! # The asymmetry is deliberate +//! +//! Both directions are debounced, but they are not equally dangerous, and the window makes that +//! explicit. Acting early on an APPEARANCE locks 20 $DIG against a capsule that may be gone in a +//! second. Acting late on a DISAPPEARANCE leaves a coin live without its `.dig`, which is the +//! penalised state. So the settling window is a floor on how long a bond must be stable, and the +//! start-up reconcile — which has no window, because a scan at start-up IS the settled state — +//! remains the reliable path for the direction that costs money. + +use std::collections::BTreeMap; + +use super::plan::Bond; + +/// How long a bond must hold a state before that state is acted on. +/// +/// Chosen to be comfortably longer than a hand-copy of a large `.dig` across a filesystem boundary +/// and far shorter than the epoch, so a bond that settles is always acted on within its own epoch. +/// It is a floor on stability rather than a delay: a bond that has been stable for an hour is acted +/// on at the next pass, not an extra 30 seconds later. +pub const SETTLING_WINDOW_MS: u64 = 30_000; + +/// Tracks how long each bond has held its current presence state. +/// +/// The tracker is fed a full SNAPSHOT of what is on disk on each observation, never individual +/// events. That is what makes it survive a restart and a missed event identically: both are just an +/// observation whose previous state is unknown, and an unknown previous state is simply not yet +/// stable. +#[derive(Debug, Clone, Default)] +pub struct PresenceTracker { + /// For each bond ever seen: whether it was present at the last observation, and the instant that + /// state was first observed. + seen: BTreeMap, +} + +/// One bond's last-observed state and when it entered it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Observation { + present: bool, + since_ms: u64, +} + +impl PresenceTracker { + /// A tracker that has observed nothing. + pub fn new() -> Self { + Self::default() + } + + /// Record what is on disk at `now_ms`, and return the bonds whose presence has been stable for + /// at least `window_ms`. + /// + /// The returned set is the SETTLED disk view — exactly what the planner should be handed as + /// `held`. A bond that has appeared but not yet settled is absent from it, and so is one that + /// has disappeared but not yet settled — which is why an appear-then-vanish inside the window + /// produces no action in either direction. + /// + /// Bonds that have been absent and settled are forgotten, so the map does not grow without bound + /// on a node whose cache churns. Forgetting is safe precisely because the tracker is + /// snapshot-driven: a forgotten bond that reappears is simply a new appearance, and starts its + /// window again. + pub fn observe(&mut self, on_disk: &[Bond], now_ms: u64, window_ms: u64) -> Vec { + let current: std::collections::BTreeSet<&Bond> = on_disk.iter().collect(); + + for bond in on_disk { + match self.seen.get(bond) { + Some(o) if o.present => {} + _ => { + self.seen.insert( + bond.clone(), + Observation { + present: true, + since_ms: now_ms, + }, + ); + } + } + } + + let vanished: Vec = self + .seen + .iter() + .filter(|(bond, o)| o.present && !current.contains(bond)) + .map(|(bond, _)| bond.clone()) + .collect(); + for bond in vanished { + self.seen.insert( + bond, + Observation { + present: false, + since_ms: now_ms, + }, + ); + } + + let settled: Vec = self + .seen + .iter() + .filter(|(_, o)| o.present && now_ms.saturating_sub(o.since_ms) >= window_ms) + .map(|(bond, _)| bond.clone()) + .collect(); + + self.seen + .retain(|_, o| o.present || now_ms.saturating_sub(o.since_ms) < window_ms); + + settled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) + } + + /// An explicit instant. Every fixture below advances from it by hand rather than reading the + /// wall clock, so the window under test is the one in the test and not however long the test + /// happened to take. + const T0: u64 = 1_700_000_000_000; + const WINDOW: u64 = SETTLING_WINDOW_MS; + + #[test] + fn a_newly_appeared_capsule_is_not_yet_settled() { + let mut tracker = PresenceTracker::new(); + assert!(tracker.observe(&[bond("aa", "11")], T0, WINDOW).is_empty()); + } + + #[test] + fn a_capsule_present_across_the_window_settles() { + let mut tracker = PresenceTracker::new(); + tracker.observe(&[bond("aa", "11")], T0, WINDOW); + assert_eq!( + tracker.observe(&[bond("aa", "11")], T0 + WINDOW, WINDOW), + vec![bond("aa", "11")] + ); + } + + /// The bound from both sides: one millisecond under the window must NOT settle, and exactly at + /// it must. A window tested only from above passes for an implementation with no window at all. + #[test] + fn the_settling_window_is_exact_in_both_directions() { + let mut under = PresenceTracker::new(); + under.observe(&[bond("aa", "11")], T0, WINDOW); + assert!( + under + .observe(&[bond("aa", "11")], T0 + WINDOW - 1, WINDOW) + .is_empty(), + "one millisecond short of the window is not settled" + ); + + let mut at = PresenceTracker::new(); + at.observe(&[bond("aa", "11")], T0, WINDOW); + assert_eq!( + at.observe(&[bond("aa", "11")], T0 + WINDOW, WINDOW), + vec![bond("aa", "11")], + "exactly at the window is settled" + ); + } + + /// The hostile case the debounce exists for: a `.dig` that appears and vanishes inside the + /// window locks nothing. + /// + /// The fixture carries a SECOND capsule that is present the whole time and must settle. Without + /// it, a tracker that never settles anything at all passes — which is the failure mode a + /// debounce most easily degrades into, and the one that would leave every store + /// uncollateralised forever while looking cautious. + #[test] + fn a_capsule_that_appears_and_vanishes_inside_the_window_never_settles() { + let mut tracker = PresenceTracker::new(); + let steady = bond("aa", "11"); + let flapping = bond("bb", "22"); + + tracker.observe(&[steady.clone()], T0, WINDOW); + tracker.observe(&[steady.clone(), flapping.clone()], T0 + 1_000, WINDOW); + let settled = tracker.observe(&[steady.clone()], T0 + WINDOW, WINDOW); + + assert_eq!( + settled, + vec![steady], + "the steady capsule settles and the flapping one never does" + ); + } + + /// A capsule removed and restored inside the window is still the same settled presence, so a + /// brief disappearance does not force a reclaim-and-recreate round trip — two fees and two + /// confirmation waits for nothing, which is legacy melt/create churn. + #[test] + fn a_brief_disappearance_inside_the_window_does_not_unsettle_a_capsule() { + let mut tracker = PresenceTracker::new(); + let b = bond("aa", "11"); + + tracker.observe(&[b.clone()], T0, WINDOW); + assert_eq!(tracker.observe(&[b.clone()], T0 + WINDOW, WINDOW), vec![b.clone()]); + + // Gone for one observation, back for the next, both inside a fresh window. + tracker.observe(&[], T0 + WINDOW + 1_000, WINDOW); + let settled = tracker.observe(&[b.clone()], T0 + WINDOW + 2_000, WINDOW); + + assert!( + settled.is_empty(), + "the capsule restarts its window rather than staying settled through a gap" + ); + assert_eq!( + tracker.observe(&[b.clone()], T0 + 2 * WINDOW + 2_000, WINDOW), + vec![b], + "and settles again once it has been stably present for a full window" + ); + } + + /// A capsule that stays gone leaves the settled set, so the planner sees it as no longer held + /// and reclaims its coin. The control is a capsule that stays. + #[test] + fn a_capsule_that_stays_gone_leaves_the_settled_set() { + let mut tracker = PresenceTracker::new(); + let staying = bond("aa", "11"); + let leaving = bond("bb", "22"); + + tracker.observe(&[staying.clone(), leaving.clone()], T0, WINDOW); + assert_eq!( + tracker.observe(&[staying.clone(), leaving.clone()], T0 + WINDOW, WINDOW), + vec![staying.clone(), leaving] + ); + + assert_eq!( + tracker.observe(&[staying.clone()], T0 + 2 * WINDOW, WINDOW), + vec![staying], + "the departed capsule is no longer held, which is what drives its reclaim" + ); + } + + /// A bond that has been absent for longer than the window is forgotten, so a churning cache + /// does not grow the tracker without bound. + #[test] + fn a_long_absent_bond_is_forgotten_and_reappears_as_new() { + let mut tracker = PresenceTracker::new(); + let b = bond("aa", "11"); + + tracker.observe(&[b.clone()], T0, WINDOW); + tracker.observe(&[], T0 + WINDOW, WINDOW); + tracker.observe(&[], T0 + 3 * WINDOW, WINDOW); + + assert!( + tracker.seen.is_empty(), + "a settled absence is forgotten rather than retained forever" + ); + assert!( + tracker.observe(&[b], T0 + 4 * WINDOW, WINDOW).is_empty(), + "and a reappearance starts a fresh window rather than settling instantly" + ); + } + + /// A restart is indistinguishable from a first observation, so nothing is acted on until the + /// window has passed since the node came up. That is the honest reading: a fresh process knows + /// how long a file has been on disk only by watching it. + #[test] + fn a_fresh_tracker_settles_nothing_on_its_first_observation() { + let mut tracker = PresenceTracker::new(); + assert!(tracker + .observe(&[bond("aa", "11"), bond("bb", "22")], T0, WINDOW) + .is_empty()); + } +} diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs new file mode 100644 index 00000000..bf17f003 --- /dev/null +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -0,0 +1,141 @@ +//! [`MirrorSpends`] — the type that bounds what the automatic signer is able to sign. +//! +//! # Why a type and not a check +//! +//! The user cannot approve each mirror-coin spend, so the node signs them itself. What makes that +//! defensible is that the authority is *narrow*: this key can create a mirror coin and reclaim one, +//! and nothing else. A narrow authority enforced by an `if` inside the signer is only as narrow as +//! every future caller remembers to keep it — and a signer that accepts a `Vec` and +//! inspects it is a general-purpose signing oracle with a filter in front, one refactor away from +//! being a general-purpose signing oracle. +//! +//! So the constraint is the argument type. [`MirrorSpends`] has no public constructor, no +//! `Default`, and no way to be built from arbitrary spends. The only two producers are in this +//! module, and each is a thin wrapper over the corresponding `dig_mirror_coin` builder. The signer +//! ([`super::key::MirrorOperatingKey::sign`]) takes one and nothing else will type-check. +//! +//! That is a compile-time property of the API surface rather than a claim about its behaviour: to +//! widen the authority you would have to add a producer here, which is a visible, reviewable edit to +//! a file whose entire purpose is to say what may be signed. +//! +//! # The spend bundles are never hand-rolled +//! +//! Both producers delegate to `dig_mirror_coin`, which owns the puzzle, the CAT construction and the +//! memo layout. Nothing in dig-node assembles a mirror spend itself (§4.1). + +use chia_bls::PublicKey; +use chia_protocol::{Bytes32, Coin, CoinSpend}; +use chia_sdk_driver::Cat; +use dig_mirror_coin::{MirrorAdvertisement, MirrorCoin, MirrorError}; +use num_bigint::BigInt; + +/// What a mirror spend is FOR. Carried alongside the spends so the audit entry and any log can name +/// the operation without re-deriving it from the CLVM. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MirrorOperation { + /// Locking collateral to advertise a `(store, root, epoch)`. + Create, + /// Releasing collateral back to its owner. + Reclaim, +} + +impl MirrorOperation { + /// A stable token for the audit record and for logs. + pub fn as_str(&self) -> &'static str { + match self { + MirrorOperation::Create => "create", + MirrorOperation::Reclaim => "reclaim", + } + } +} + +/// Coin spends that are PROVEN to be a mirror-coin create or reclaim, because the only way to build +/// one is through this module. +/// +/// Holding one is the permission the automatic signer requires. There is deliberately no +/// `from_coin_spends`, no `push`, and no field access that would let a caller assemble the inside of +/// one by hand. +#[derive(Debug, Clone)] +pub struct MirrorSpends { + operation: MirrorOperation, + spends: Vec, +} + +impl MirrorSpends { + /// Which operation these spends perform. + pub fn operation(&self) -> MirrorOperation { + self.operation + } + + /// The spends, for signing and broadcast. Read-only: a caller can look at them and cannot add to + /// them, so a borrowed `MirrorSpends` cannot become a vehicle for an unrelated spend. + pub fn coin_spends(&self) -> &[CoinSpend] { + &self.spends + } +} + +/// Build the spends that lock `collateral_cat_mojos` of $DIG as a mirror for one `(store, root, +/// epoch)`. +/// +/// A thin wrapper over `dig_mirror_coin::create`: it adds no conditions, alters no amount, and +/// changes no destination. Its whole contribution is that what comes back is a [`MirrorSpends`], +/// which is the thing the signer will accept. +/// +/// `collateral_cat_mojos` is CAT mojos of $DIG — `dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS`, +/// which is 20 whole $DIG times `CAT_MOJOS_PER_DIG`. Passing the whole-$DIG figure would lock 0.02 +/// $DIG and still look like a successful advertisement. +#[allow(clippy::too_many_arguments)] +pub fn build_create( + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: BigInt, + urls: Vec, + collateral_cat_mojos: u64, + dig_coins: Vec, + synthetic_key: PublicKey, + fee_coins: Vec, + fee: u64, +) -> Result { + let spends = dig_mirror_coin::create( + MirrorAdvertisement { + store_launcher_id, + root_hash, + epoch, + urls, + collateral: collateral_cat_mojos, + }, + dig_coins, + synthetic_key, + fee_coins, + fee, + )?; + + Ok(MirrorSpends { + operation: MirrorOperation::Create, + spends, + }) +} + +/// Build the spends that release `mirror`'s collateral back to its owner. +/// +/// A thin wrapper over `dig_mirror_coin::reclaim`, which recreates the full locked amount at the +/// owner's own puzzle hash. There is no supply-reducing path in that crate and none is added here: +/// reclaim returns the money, and an operation that destroyed it would be a different function with +/// a different name. +/// +/// `fee` may be zero, and a zero-fee reclaim is supported. That matters: a node whose XCH is +/// exhausted must still be able to recover $DIG it has locked, which is precisely what the legacy +/// could not do. +pub fn build_reclaim( + mirror: &MirrorCoin, + synthetic_key: PublicKey, + fee_coins: Vec, + fee: u64, +) -> Result { + let spends = dig_mirror_coin::reclaim(mirror, synthetic_key, fee_coins, fee)?; + + Ok(MirrorSpends { + operation: MirrorOperation::Reclaim, + spends, + }) +} From 7751c4a544977db9f09a8194baaac0acae617ae9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 15:38:33 -0700 Subject: [PATCH 03/10] feat(mirror): wire the lifecycle module in and derive the collateral per epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of dig-node#377's locked plan. The salvaged module is compiled in for the first time, and its stale premise is removed. The amount is NOT a constant. `dig-constants` carried a fixed `MIRROR_COIN_COLLATERAL_DIG = 20` until 0.13.0 removed it as a twentyfold error on a real-money path; the requirement is derived per epoch through SPEC.md §24's machinery (`apply_safety_margin(required_per_store, margin_bp)`), and the schedule starts at 1.000 DIG per (store, root). Every doc-comment, parameter name and test pin that restated 20 $DIG is gone. Units are renamed `*_cat_mojos` -> `*_dig_base_units` throughout. $DIG carries three decimals; a mojo is XCH's base unit, nine orders of magnitude away, and the crate this module derives its amount from already names its parameter `required_per_store_dig_base_units`. The planner tests gain two fixtures a single-amount battery could not have: a raised requirement funding fewer bonds from the same balance (which goes red against any implementation that ignores `per_coin`), and a whole-$DIG figure asserted from BOTH sides, so "funds nothing" cannot pass by funding nothing ever. `mod.rs` is rewritten to the locked shape: the node signs mirror spends with its own operator wallet (SPEC.md §16.4, machine custody), scoped by construction via `MirrorSpends` and a module-private signer -- not by a separately derived key, which was rejected because the machine seed is the node's network identity and a second fundable address would split the user's deposit invisibly. The phantom `pub mod key;` is dropped: that file was never written, so the branch did not compile as pushed. SPEC.md gains §25, the normative contract for the lifecycle. §25.6 and §25.8 are marked PENDING in the same diff that writes them, because their code lands in steps 6 and 7. Three existing claims are amended -- two of which were already false against §16.4/§18.23 on main. Refs #377 --- SPEC.md | 257 +++++++++++++++++- crates/dig-node-service/src/lib.rs | 5 + crates/dig-node-service/src/mirror/mod.rs | 54 ++-- crates/dig-node-service/src/mirror/plan.rs | 127 ++++++--- .../dig-node-service/src/mirror/presence.rs | 5 +- crates/dig-node-service/src/mirror/spends.rs | 14 +- .../dig-node-service/src/mirror_lifecycle.rs | 4 - 7 files changed, 397 insertions(+), 69 deletions(-) delete mode 100644 crates/dig-node-service/src/mirror_lifecycle.rs diff --git a/SPEC.md b/SPEC.md index c4314767..233f7cff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1612,7 +1612,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()`. | | `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.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.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: the node 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; the node's role on the money path is to read chain state and relay what somebody else signed. 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.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. | | `control.chiaPeers.remove` | `ip` (canonicalised as for `add`), optional `ban` (bool, default `false`) | `{outcome, ip, banned}` where `outcome` is `"removed"` or `"no_such_peer"`. Stops trusting a peer, RESTORING corroboration for it. There is deliberately NO `removed: true` companion field: this is the only way to un-trust a peer holding unbounded authority over the wallet replica, so a consumer MUST match on `outcome` and MUST surface `"no_such_peer"` as a failure to act — an operator told "removed" when nothing matched believes they revoked custody-grade trust and did not. Matching is by the canonical form, so an address spelled differently from the stored entry still names the same peer. `ban: true` keeps the peer excluded so discovery cannot re-add it; the banned set is bounded at `MAX_BANNED_CHIA_PEERS` (256) and on overflow a node MUST evict its OLDEST ban rather than refuse the newest. `ban: false` merely forgets the row and is the un-ban path — clearing a ban that way grants NO trust. `INVALID_PARAMS` on a missing or non-literal `ip`. **MASTER-TOKEN TIER** — a paired token MUST be refused, so it cannot strip the peers an operator deliberately trusts. | @@ -3709,7 +3709,7 @@ ONLY automatic release trigger, a quiet repo can silently stop releasing. Detect | 13 | Subscription persistence | `/subscriptions.json` schema-versioned, atomic, cross-process-locked | §14.1; `subscription.rs` | | 14 | Autonomous sync fail-closed | chain-watch + gap-fill + read-path pin never serve/pull against an unconfirmable root | §14.2–14.4; `chainwatch.rs`, `lib.rs` | | 15 | FFI C-ABI | `dig_runtime_start`/`dig_runtime_start_wallet` (wallet-only vs full) + `dig_rpc`/`dig_wallet_rpc`/`dig_free` + read-crypto `dig_read_verify_decrypt`/`dig_bytes_free` (`DIG_READ_*` codes) signatures + ownership/threading | §15, §15.1; `dig-runtime/src/lib.rs` | -| 16 | Wallet signs nothing locally | every key/sign method is forwarded to the user's Sage wallet; no local signer, no session, no broadcast gate to withhold | §16.2, §18.20; `dig-wallet/src/lib.rs` | +| 16 | No user key is ever held or signed with | every dapp key/sign method is forwarded to the user's Sage wallet; the node's OWN operating wallet (§16.4) signs only under the §23 audit contract (tips §18.23, mirror coins §25), through module-scoped signers no RPC surface can reach | §16.2, §18.20, §23, §25 | --- @@ -4581,7 +4581,7 @@ does not custody. `control.wallet.watch` registers G1 public keys, `control.wall them, and `control.wallet.watched` lists what is currently registered. All three are MUTATIONS and therefore require authorization; none is an open read. -This exists because the correct install has no seed on the node at all: under §908 the user's account +This exists because the correct install has no USER seed on the node at all (the node's own operator seed, §16.4, is machine custody, not the user's account): under §908 the user's account lives in dig-app, so custody contributes zero puzzle hashes, §18.6a refuses a catch-up over the empty set, and the replica's peak never advances. Registration is the only way such a node can watch its user's coins. @@ -7556,7 +7556,7 @@ legal choice and any conversion to whole percent would erase it. ### 24.5. The funding advice — how much to hold, and the states **Collateral is RECLAIMED, not spent.** Each pass creates the coins for `(store, root, epoch n)` and -reclaims epoch `n-1`; reclaims run FIRST and are never gated on funds, so returned collateral funds +reclaims epoch `n-1`; reclaims run FIRST and are never gated on funds (§25.4), so returned collateral funds the creates behind it. **The steady state is roughly ONE epoch's lock, not one per epoch.** A recommendation of "requirement x epochs of runway" overstates by the epoch count and tells an operator to hold many times what they need. @@ -7898,3 +7898,252 @@ at once. default rather than as "keep nothing", which would discard the epoch currently in force. **Epoch 1 MUST NOT be pruned under any policy**: it is the base case every verification walk terminates at, so a node that discarded it could no longer check anything a peer offered it. + +--- + +## 25. Mirror-coin lifecycle — presence on disk made true on chain, signed by the node (dig-node#377) + +The node advertises each `(store, root)` it serves by locking $DIG in a mirror coin, one coin per +`(store, root, epoch)`, and it signs those spends ITSELF, without per-spend approval. This section is +the contract for that lifecycle: the invariant it maintains, the exact boundary of the signing +authority, the reconcile pass, and how the authority is revoked. §23 is the accountability contract +every spend here is subject to; §24 is where the amount comes from. Spend construction is owned by +`dig-mirror-coin` — nothing in this repo assembles a mirror spend, a CAT wrapper, or a memo layout +itself (SYSTEM.md §4.1). + +### 25.1. The invariant + +> **A mirror coin owned by this node for the CURRENT epoch exists ⟺ the `.dig` for that +> `(store, root)` is on disk with `Held` provenance.** + +Both directions are normative, and they fail in different directions: + +* **`.dig` held → coin MUST exist.** Without it the node serves content it is undiscoverable and + unrewarded for. A missed create costs opportunity only — money stays in the wallet. +* **`.dig` gone → coin MUST be reclaimed.** A live coin advertising content this node cannot serve is + the PENALISED state, so reclaim is loss avoidance, not cleanup, and the reclaim path is held to a + higher standard of reliability than the create path. + +The disk side of the invariant is `Node::cache_list_cached()` filtered to +`CapsuleProvenance::Held`. `Relayed` capsules are servable but NEVER advertised (dig-node#276); +collateralising one would stake $DIG on a claim the node deliberately does not make. A capsule that +has not landed and verified is not in the inventory at all, so "never advertise a store you cannot +serve" holds structurally rather than by a sync-state check. + +The chain side is `dig_mirror_coin::list`, keyed on the owner puzzle hash, with ownership read from +each coin's lineage proof. **No local bookkeeping is ever a source of truth for what is bonded** — +the legacy's authoritative local `.json` stranded collateral when it was lost, and this design makes +that unrepresentable: the reconcile's only inputs are the disk and the chain. + +### 25.2. The signing authority — what §908 still forbids, and what this section permits + +**Unchanged, and this section does not weaken any of it:** the node holds no user seed and no user +spend key (§16.3, §18.20, §18.24); no dapp/RPC/control surface can obtain a signature over +caller-supplied spends from the node's keys; `control.wallet.broadcast` remains a relay for bundles +somebody else signed and carries no signing parameter (§4-method table). + +**What this section permits:** the node signs mirror-coin CREATE and RECLAIM spends, automatically, +with the keys of its OWN operating wallet — the §16.4 autoseed identity, which is machine custody, +not user custody. The user funds that wallet (the dig-app deposit flow); money deposited there is +money placed under this section's standing authority. + +The authority is bounded four ways, and every bound is stated so a reader can check it: + +1. **By reachable spend shape.** `MirrorSpends` is a newtype with no public constructor, no + `Default`, and no conversion from `Vec`. Its only producers are `build_create` and + `build_reclaim`, thin wrappers over `dig_mirror_coin::create` / `::reclaim` that add no + conditions, alter no amount and change no destination. The signer's ONLY public entry point is + `sign(&MirrorSpends, &RecordedSpend)`. There is no method on this path that accepts an arbitrary + `CoinSpend`, so widening the authority requires adding a producer — a visible, reviewable edit to + the one file whose purpose is to say what may be signed. +2. **By destination, structurally.** A create's collateral lands at the $DIG CAT construction around + `dig_mirror_coin::mirror_coin_puzzle_hash()`, with change returned to the node's own puzzle hash; + a reclaim recreates the FULL locked amount at the owner's own puzzle hash and is refused + (`NotOwner`) for any coin the node does not own. Both properties are enforced inside + `dig-mirror-coin`, not re-checked here. There is no supply-reducing path. +3. **By amount** (§25.3): per coin, exactly the margined per-epoch requirement; per pass, at most + that amount times the number of held `(store, root)` pairs, plus fees. +4. **By fee source and size.** Fees are paid from XCH coins only — the crate's builders take + separate fee inputs, so a fee can never shave collateral. `fee_mojos` per spend MUST come from a + named constant, MUST be recorded in the audit entry, and MUST NOT exceed + `MIRROR_SPEND_FEE_CEILING_MOJOS` = 1_000_000_000 (0.001 XCH). The shipped default fee is 0; a + zero-fee reclaim is explicitly supported by `dig_mirror_coin::reclaim`. + +**The signer instance is module-private.** It is constructed at bring-up from the operator seed — +only when the seed opens under the device key (§16.4 `BootstrapState::Opened`/`Created`); a `Locked` +or `Orphaned` wallet yields no signer and the lifecycle reports itself unavailable rather than +degrading. The instance is NEVER installed on the general `WalletBackend`, is not reachable from any +RPC, control, or dapp method, and does not change `current_signer()`'s answer for any other surface. +Mirror spends do not transit `control.wallet.broadcast`, and `DIG_WALLET_ENABLE_LIVE_BROADCAST` +does not govern them: that flag gates the GENERAL node-custodied wallet surface, while this +lifecycle is governed by §25.7's switch and §23's audit contract. + +**Key derivation MUST be the standard Chia HD derivation** from the operator mnemonic, so the phrase +exported by `dign wallet export-seed` (§16.3) recovers the collateral wallet — including anything +locked in unreclaimed mirror coins, via any standard wallet — with no dig-node code involved. The +owner key is the first derived key; its standard puzzle hash is the wallet's receive address, the +`owner_puzzle_hash` term of every hint this node creates, the address reclaims return to, and the +address create-change returns to. Deposits, bonds and reclaims therefore all move through ONE +address the wallet already tracks. + +**Every spend is audited, structurally.** The signer takes `&RecordedSpend`, whose only source is +`SpendJournal::begin` (§23.3) — recording is the shape of the call. Entries carry +`kind: "mirror-coin"` (`spend_audit::kinds::MIRROR_COIN`), +`authority: { principal: "node", grant: "mirror-collateral" }`, the asset (`dig` for creates and +reclaims), the amount in DIG base units, the fee in XCH mojos, and `store_id`; `purpose` SHOULD name +the root and epoch. Confirmation is by observing the CREATED coin, never the funding coin (§23.2). +A pass that ends without an outcome leaves `unresolved`, and a `failed` entry at `broadcast` or +`confirmation` stage is reconciled against the chain, never treated as "the money stayed put" — +a mirror spend that landed unrecorded is collateral locked with the node believing it is not. + +### 25.3. The amount is DERIVED per epoch, never a constant + +The collateral per coin is `apply_safety_margin(required_per_store, margin_bp)` for the CURRENT +epoch, obtained through §24's requirement machinery (the censused epoch record) and the §24.4 +margin. It MUST NOT be a compile-time constant, MUST NOT restate the model's arithmetic +(`required_per_store` is the whole answer; the formula as usually written omits the floor clamp), +and MUST NOT be read from a stale epoch. All lifecycle amounts are **DIG base units** +(`1 DIG = 1_000`); field and parameter names MUST say so — a mirror amount is never "mojos". + +When the requirement is not `Known` (§24.2 — `not_censused`, `behind_finality_depth`, +`record_unreadable`, `no_chain_source`), **creates are DEFERRED** for that pass and reported with +the requirement's own reason. **Reclaims are unaffected**: a reclaim's amount is read from the coin +being reclaimed (`MirrorCoin::collateral()`), so recovering money never waits on a census. A coin +locked under a previous epoch's amount is reclaimed at that amount — reclaim returns what was +locked, exactly. + +### 25.4. The reconcile pass — two observations, a pure plan, reclaims first + +A pass runs: at start-up (once the wallet and a chain source are available), on every round tick +(`dig_constants::MIRROR_ROUND_LENGTH_MS`), and after a debounced presence change (§25.5). Each pass: + +1. **Observes disk**: the `Held` capsule set (§25.1) — the desired bonds for the current epoch. +2. **Observes chain**: `dig_mirror_coin::list(source, owner_puzzle_hash)` — the coins actually owned. +3. **Plans**, purely (no I/O, no clock — the epoch is a parameter): + + | owned coin | its `.dig` held | action | + |---|---|---| + | `epoch == current` | yes | keep | + | `epoch == current` | no | **reclaim** (`NoLongerHeld` — the penalised state; the priority) | + | `epoch < current` | either | **reclaim** (`EpochEnded` — the automatic form of the operation the legacy left to an operator; dig-node has no operator) | + | `epoch > current` | either | **keep** | + + The last row is a decision, not a gap: the epoch clock is wall-clock with no chain input + (§24.3), so a slow local clock reads a legitimately-created next-epoch coin as "future", and + reclaiming on that reading would destroy a valid bond on the strength of this machine's clock. + Keeping is the recoverable direction; the coin becomes ordinary at the next tick. Duplicate + coins for one `(store, root, epoch)` are ALL kept while the bond is held — choosing which valid + bond to destroy is information the planner does not have — and both are reclaimed as + `EpochEnded` after rollover. + +4. **Executes reclaims FIRST, then creates.** Reclaims are NEVER gated on funds: they return + collateral, which may fund the creates behind them, and a reclaim withheld for lack of funds is + the legacy defect where a wallet at zero could neither advertise nor recover what it had locked. + When no XCH is selectable, reclaims are attempted with `fee = 0`; zero-fee mempool admission is + not guaranteed under fee pressure, so an unadmitted zero-fee reclaim is retried on subsequent + passes rather than escalated. This is why epoch rollover is a re-create, not a top-up: pass + order makes epoch n−1's returned collateral available to epoch n's creates. +5. **Creates in deterministic order** (sorted by `(store_id, root)`), stopping CLEANLY at the first + unaffordable one: no partial spend, no retry loop, no half-written audit entry. The shortfall — + which `(store, root)` pairs are uncollateralised and how many DIG base units short — is exposed + on §25.8's surface. $DIG and XCH shortfalls are distinguished: $DIG missing blocks creates; XCH + missing blocks fees, which degrades reclaims to `fee = 0` and blocks creates only if a non-zero + create fee is configured. An underfunded pass MUST NOT stall, retry-loop, or block any other + node work — the next pass re-derives everything, so added funds are picked up without restarting + any epoch's work. +6. **Suppresses in-flight duplicates.** At most one in-flight create per `(store, root, epoch)`: + a bond whose current-epoch create has a `pending` or `submitted` audit entry is excluded from + the plan's create set until that entry resolves. The audit record is the in-flight ledger; the + disk and the chain remain the only steady-state truths. + +A confirmed create is `Confirmed { height, coin_id }` in the audit record, observed on the created +coin. The `intended_coin_id` is recorded at submission so §23.5's reconcile accounts for it. + +### 25.5. Presence and debounce + +Presence changes are detected by SCANNING, with an optional watcher as an accelerator — never the +reverse. A watcher event is exactly what a crash, an unmounted volume, or an uncovered path loses; +the periodic pass (§25.4) is the correctness mechanism. + +The debounce is **presence-stable-for-a-window**, not a timer after an event: a bond must be +observed in the SAME state across `SETTLING_WINDOW_MS` (default 30_000) before that state is acted +on, in BOTH directions. An event-reset timer never settles under repeated rewrites and cannot see +changes that produced no event. A capsule that appears and vanishes inside the window was never +stable and triggers neither a create nor a reclaim — this is the churn control that prevents a +flapping file from costing two fees per flap, and it is why an atomic replace (delete-then-create +to a watcher) does not produce a spurious round trip. + +Two exemptions: the START-UP scan is un-debounced — a scan at start-up IS the settled state; and +the node's own capsule writes are structurally invisible mid-write, because a capsule stages under +the downloads directory and is renamed into the inventory only after verification, so the scan +cannot observe a half-written file the node produced itself. + +### 25.6. The DHT pointer, and epoch rollover + +> **PENDING — not yet implemented.** This subsection is normative and is NOT satisfied by +> code as of this section's introduction. Tracked as dig-node#377 step 7 (the dig-dht 0.12.1 → 0.15 bump and the +> announce-seam attach). Until it lands, a reader MUST NOT +> rely on the behaviour described here. + +After a create confirms, the node attaches the coin id to its DHT provider record +(`dig_dht::ProviderRecord::unverified_mirror_coin_id`) for that content, and it MUST re-announce on +epoch rollover once the new epoch's coin confirms — dig-dht has no clock and republish re-attaches +whatever was recorded at announce time, so an un-refreshed pointer goes stale one epoch after +publication and a correctly-collateralised node reads as uncollateralised. + +The pointer is an UNTRUSTED convenience (NC-12): it tells a verifier where to look, never what the +coin is. Its absence MUST NOT degrade discovery or be treated as a fault. A verifier — this node +when it checks others, and others when they check this node — accepts a coin as bonding +`(store, root, epoch)` only on the coin's own evidence: it sits at the mirror-coin puzzle hash, is +genuinely $DIG with the asset id re-derived from the creating spend, carries the declared +collateral, and `MirrorCoin::advertises(store, root, epoch)` passes — an exact equality on the +declared tuple plus a recomputed hint, which is what defeats the constructible additive-morph +collision (the epoch term is freely chosen, so hint equality alone proves nothing). + +### 25.7. Consent, the switch, and revocation + +There is deliberately no per-spend approval; the standing authority is the consent model, and it is +honest the same way auto-tipping (§18.23) is: **disclosed, default-on, bounded, fully audited, and +one setting to turn off** (§6.0/#207). + +* **What the user accepts, and when:** running dig-node with collateralisation enabled (the + default) and funding its operating wallet. The grant is named — every audit entry carries + `grant: "mirror-collateral"` — and the account of every exercise of it is `dign spends` and the + dig-app Activity tab. +* **The switch** is a persisted node setting (`collateral.json`, beside §24.4's margin; the node is + the authoritative home §24.4). It gates CREATES ONLY. **Reclaims run regardless of the switch** — + a disable that stopped reclaims would strand locked funds, which inverts the point of revoking. +* **Revocation reclaims what is locked, with no new machinery:** OFF forces the desired bond set + empty, so the next pass reclaims every live coin this wallet owns (current epoch as + `NoLongerHeld`, prior epochs as `EpochEnded`) and the collateral returns to the wallet balance. + Per-store revocation is deleting/unpinning the `.dig`; the invariant does the rest. +* Disabling collateralisation does not touch the wallet, the audit record, or any other surface. + +### 25.8. The per-store state surface + +> **PENDING — not yet implemented.** This subsection is normative and is NOT satisfied by +> code as of this section's introduction. Tracked as dig-node#377 step 6 (the `dig-node-control-interface` 0.25.0 +> method declaration, release-first, then the node serving it and the `dign` verb). Until it lands, a reader MUST NOT +> rely on the behaviour described here. + +The lifecycle exposes, per `(store, root)`, over the control plane and with a `dign` verb (§8.6 +CLI parity): the bond state — `bonded { coin_id, epoch, amount }`, `pending` (in-flight create), +`unfunded { short_dig_base_units }`, `deferred { requirement reason }` (§25.3), `withheld` +(`Relayed` provenance — deliberately not advertised), or `reclaiming` — so a client can distinguish +"out of funds" from "withheld on purpose" without guessing from the store list. Conflating those +two produces hourly alarms about a healthy node (dig-app#300). The method is declared in +`dig-node-control-interface` (release-first) before the node serves it. + +### 25.9. Failure directions, stated + +* A missed CREATE fails safe: money stays in the wallet, the node is undiscoverable for that root, + and §25.8 says so. +* A missed RECLAIM fails expensive (the penalty) — which is why reclaims run first, why the + start-up scan is the reliable path, and why `.dig`-gone is the highest-priority row of the plan. +* A slow clock KEEPS foreign-epoch coins (never destroys a valid bond); a fast clock creates the + next epoch's coin early and reclaims the old one on the same pass — the same funds movement as + an ordinary rollover. +* An unknown requirement defers creates and never defers reclaims. +* A crash at any point loses at most watcher events; the next pass re-derives the plan from disk + and chain, and §23.5's reconcile plus in-flight suppression prevent both double-creates and + silent losses. diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index e482b812..60850b62 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -73,6 +73,11 @@ pub mod logging; /// `127.0.0.1` from the rest of the machine. See [`loopback`]. pub mod loopback; pub mod meta; +/// The mirror-coin lifecycle (dig-node#377, `SPEC.md` §25): presence of a `.dig` on disk drives +/// creation of an on-chain mirror coin locking the epoch's required $DIG for that +/// `(store, root, epoch)`, and its disappearance drives reclaim of the collateral. The node signs +/// those spends itself with its own operator wallet, scoped by construction. See [`mirror`]. +pub mod mirror; /// `dig-node open ` (#389): the OS scheme-handler target the /// installer registers for `chia://` + `urn:dig:chia:`. Strictly validates the untrusted /// handler argument, then opens the user's default browser at the resolving URL. See [`open`]. diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index fb5eaa4f..0cde4b63 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -1,36 +1,46 @@ //! The mirror-coin lifecycle (dig-node#377) — presence of a `.dig` on disk, made true on chain. //! -//! A **mirror coin** locks 20 $DIG to advertise that this node serves one `(store, root)` for one +//! A **mirror coin** locks $DIG to advertise that this node serves one `(store, root)` for one //! epoch. This module is what keeps that advertisement honest: a capsule this node holds and is -//! willing to serve gets a coin, and a coin whose capsule is gone gets reclaimed. +//! willing to serve gets a coin, and a coin whose capsule is gone gets reclaimed. `SPEC.md` §25 is +//! the normative contract; this module doc says why the shape is what it is. //! //! # Reclaim is loss avoidance, not cleanup //! //! A live coin advertising a capsule the node cannot serve is **penalised later**. So the reclaim //! path is not tidying up after the interesting work — it is the half where money is at stake, and -//! it is held to a higher standard than the create path: +//! it is held to a higher standard than the create path (§25.9): //! //! * **Reclaims run first in every pass.** A reclaim returns collateral, which may fund the creates //! behind it, and a reclaim withheld because the wallet is short is the legacy defect where a //! wallet at zero could neither advertise nor recover what it had already locked. //! * **Reclaims are never gated on funds.** [`plan::split_by_funds`] never sees them. +//! * **Reclaims never wait on the collateral requirement.** A reclaim's amount is read from the coin +//! being reclaimed, so recovering money does not depend on a census answering (§25.3). //! * **The start-up reconcile is the reliable path, not the file watcher.** A watcher's event is //! exactly what a crash loses; a scan at start-up re-derives the whole answer from two //! observations that survive anything. //! -//! # The node signs these itself — a carve-out scoped by a separate key, not by a permission +//! # The node signs these itself — with its OWN operating wallet, scoped by construction //! -//! §908 says the node signs nothing on the user's behalf, and dig-node holds no user spend key at -//! all (the `wallet.*`/`auth.*` custody surface is retired, dig_ecosystem#1701). The carve-out is -//! therefore not a relaxation of that: mirror coins are spent by a **dedicated operating key** this -//! node derives for itself ([`key`]), which the user funds. The node signs its OWN wallet, and that -//! key controls nothing the user owns. +//! §908 says the node signs nothing **on the user's behalf**, and it still holds no user seed and no +//! user spend key. That is untouched here. What signs a mirror spend is the node's own **operator +//! wallet** (`SPEC.md` §16.4 autoseed, sealed under the device key) — machine custody, not user +//! custody — which §23 already permits to sign certain spends automatically, and which the shipped +//! auto-tipping path (§18.23) already uses for unattended $DIG spends. //! -//! Scope is then held by a type rather than a runtime check. [`spends::MirrorSpends`] has no public -//! constructor; the only producers wrap `dig_mirror_coin::create` and `::reclaim`, and the signer's -//! only entry point takes one. There is no method anywhere on this path that accepts an arbitrary -//! `CoinSpend`, so the reachable spend shapes are mirror-coin create and mirror-coin reclaim by -//! construction. +//! A *separately derived* mirror key was considered and rejected. The machine identity seed is the +//! node's **network** identity (peer_id/TLS), not money custody; and a second fundable address would +//! split the user's deposit across two wallets that no balance surface can see, or else require an +//! automated wallet-to-wallet transfer — strictly *more* unattended signing than it prevents. +//! +//! Scope is therefore held by a **type**, not by a runtime check or by key hygiene. +//! [`spends::MirrorSpends`] has no public constructor; its only producers wrap +//! `dig_mirror_coin::create` and `::reclaim`, and the signer's only entry point takes one. There is +//! no method anywhere on this path that accepts an arbitrary `CoinSpend`, so the reachable spend +//! shapes are mirror-coin create and mirror-coin reclaim **by construction**. The signer instance is +//! module-private and is never installed on the general `WalletBackend`, so wiring it does not +//! change what any other surface — including default-on auto-tipping — is able to sign. //! //! # Accountability is what pays for it //! @@ -40,14 +50,26 @@ //! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin) — so recording is the SHAPE of //! the call rather than a convention a later producer can forget. //! -//! # Nothing here re-derives the epoch or the hint +//! # Nothing here re-derives the epoch, the hint, or the amount //! //! The epoch comes from `dig_constants::mirror_epoch_at_unix_ms` and the hint from //! `dig_mirror_coin::mirror_hint`. Both are canonical, and a locally computed version of either //! would put coins under a value no verifier queries — collateral that is genuinely locked and //! genuinely invisible. +//! +//! The **amount is derived per epoch** and is never a constant here: it is +//! `dig_mirror_collateral::margin::apply_safety_margin(required_per_store, margin_bp)` for the +//! current epoch, obtained through the requirement machinery `SPEC.md` §24 describes. `dig-constants` +//! carried a fixed `MIRROR_COIN_COLLATERAL_DIG = 20` until 0.13.0 removed it as a twentyfold error on +//! a real-money path — the schedule starts at **1.000 DIG** per `(store, root)`. Restating the +//! model's arithmetic is equally forbidden: `required_per_store` is the whole answer, and the formula +//! as usually written omits its floor clamp. +//! +//! All amounts in this module are **DIG base units** (1 DIG = 1_000), and every name says so. A +//! mirror amount is never "mojos" — a mojo is XCH's base unit, nine orders of magnitude away, and +//! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named +//! `*_mojos` and come from separate coins so a fee can never shave collateral. -pub mod key; pub mod plan; pub mod presence; pub mod spends; diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs index e594dfa7..4577a532 100644 --- a/crates/dig-node-service/src/mirror/plan.rs +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -22,7 +22,7 @@ use std::collections::BTreeSet; /// /// "Willing to advertise" is not the same as "present on disk". A capsule pulled on a stranger's /// behalf is marked `Relayed` and is deliberately never advertised (dig-node#276), so it is not a -/// bond: staking 20 $DIG on it would be paying for an advertisement that is never published. +/// bond: locking collateral on it would be paying for an advertisement that is never published. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Bond { /// Store launcher id, lowercase 64-hex. @@ -57,7 +57,7 @@ pub struct HeldMirror { /// The epoch this coin declares it bonds. pub epoch: i64, /// The $DIG locked, in CAT mojos. - pub collateral_cat_mojos: u64, + pub collateral_dig_base_units: u64, } impl HeldMirror { @@ -82,7 +82,7 @@ pub enum ReclaimReason { /// The coin bonds an epoch that has already ended. /// /// The legacy had this as an operational step a human ran, and dig-node has no operator — so - /// leaving it manual would strand 20 $DIG per store per epoch, forever, with nobody to notice. + /// leaving it manual would strand one epoch's collateral per store, forever, with nobody to notice. EpochEnded, } @@ -175,7 +175,7 @@ pub struct FundingSplit { /// The creates it cannot — what the node must report as uncollateralised. pub short: Vec, /// How much more $DIG, in CAT mojos, would cover [`Self::short`] entirely. - pub shortfall_cat_mojos: u64, + pub shortfall_dig_base_units: u64, } impl FundingSplit { @@ -188,7 +188,8 @@ impl FundingSplit { /// Split `create` at the point the balance runs out. /// /// Creates STOP at the first unaffordable one rather than skipping it to take a cheaper later one. A -/// mirror coin is all-or-nothing at 20 $DIG — there is no partial collateralisation — so there is no +/// mirror coin is all-or-nothing at the epoch's required amount — there is no partial +/// collateralisation — so there is no /// cheaper one to find, and hunting for an affordable subset would only make which stores get /// advertised depend on the balance in a way nobody could predict or explain. /// @@ -196,30 +197,43 @@ impl FundingSplit { /// balance is the legacy defect where a wallet at zero could neither advertise nor recover what it /// had already locked. /// -/// `per_coin` is `dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS` — CAT mojos, never whole $DIG, -/// which is 1,000x smaller and would make everything look affordable. -pub fn split_by_funds(create: &[Bond], balance_cat_mojos: u64, per_coin: u64) -> FundingSplit { +/// `per_coin` is the CURRENT epoch's requirement in DIG base units — +/// `apply_safety_margin(required_per_store, margin_bp)` (`SPEC.md` §25.3), never a constant and never +/// a whole-$DIG figure. Whole $DIG is 1,000x smaller than base units and would make everything look +/// affordable; `dig-constants` removed its fixed `MIRROR_COIN_COLLATERAL_DIG = 20` in 0.13.0 for +/// exactly that class of error. +pub fn split_by_funds(create: &[Bond], balance_dig_base_units: u64, per_coin: u64) -> FundingSplit { // A zero per-coin collateral would make every bond free and the split meaningless. The crate // refuses a zero-collateral mirror anyway, so treat it as "nothing is affordable" rather than // dividing by it and reporting infinite capacity. let affordable_count = if per_coin == 0 { 0 } else { - ((balance_cat_mojos / per_coin) as usize).min(create.len()) + ((balance_dig_base_units / per_coin) as usize).min(create.len()) }; let (affordable, short) = create.split_at(affordable_count); FundingSplit { affordable: affordable.to_vec(), short: short.to_vec(), - shortfall_cat_mojos: (short.len() as u64).saturating_mul(per_coin), + shortfall_dig_base_units: (short.len() as u64).saturating_mul(per_coin), } } #[cfg(test)] mod tests { use super::*; - use dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS; + /// A stand-in for one epoch's requirement, in DIG base units: 1.000 DIG, the schedule's + /// starting value. It is a TEST constant, deliberately not imported from anywhere — the + /// production amount is derived per epoch (`SPEC.md` §25.3), so a test that pinned it to a + /// library constant would go green against an implementation that had hard-coded one. + const PER_COIN: u64 = 1_000; + + /// A DIFFERENT requirement, used to prove the split is genuinely parameterised rather than + /// agreeing with [`PER_COIN`] by coincidence. The requirement moves in both directions as the + /// network's state moves, so any fixture that only ever exercises one amount cannot tell a + /// parameter from a constant. + const PER_COIN_RAISED: u64 = 2_500; /// A distinguishable 64-hex id. Real ids are opaque, and tests that use short strings hide /// length assumptions in the path builders that consume them. @@ -242,7 +256,7 @@ mod tests { store_id: id(store), root: id(root), epoch, - collateral_cat_mojos: MIRROR_COIN_COLLATERAL_CAT_MOJOS, + collateral_dig_base_units: PER_COIN, } } @@ -471,13 +485,13 @@ mod tests { let creates = vec![bond("aa", "11"), bond("bb", "22")]; let split = split_by_funds( &creates, - 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, + 2 * PER_COIN, + PER_COIN, ); assert!(split.is_funded()); assert_eq!(split.affordable, creates); - assert_eq!(split.shortfall_cat_mojos, 0); + assert_eq!(split.shortfall_dig_base_units, 0); } /// The bound from BOTH sides. Exactly one coin's worth funds exactly one coin, and one mojo @@ -488,38 +502,77 @@ mod tests { let at_bound = split_by_funds( &creates, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, + PER_COIN, + PER_COIN, + ); + assert!( + at_bound.is_funded(), + "exactly one requirement's worth funds exactly one coin" ); - assert!(at_bound.is_funded(), "exactly 20 $DIG funds exactly one coin"); let one_under = split_by_funds( &creates, - MIRROR_COIN_COLLATERAL_CAT_MOJOS - 1, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, + PER_COIN - 1, + PER_COIN, ); assert!( !one_under.is_funded(), "one mojo short must not collateralise anything" ); - assert_eq!(one_under.shortfall_cat_mojos, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + assert_eq!(one_under.shortfall_dig_base_units, PER_COIN); } - /// The wallet that reads as rich because someone used whole $DIG where CAT mojos were meant. - /// 20 is the whole-$DIG figure and it must fund NOTHING; the 1,000x confusion is the specific - /// mistake `dig-constants` pins two constants to prevent. + /// The wallet that reads as rich because someone passed a WHOLE-$DIG figure where base units + /// were meant. + /// + /// A balance of 5 whole $DIG is 5_000 base units and would fund five coins at the starting + /// requirement; expressed as the bare number 5 it funds none. The fixture asserts BOTH halves, + /// because asserting only the underfunded half is satisfied by an implementation that funds + /// nothing at all. #[test] - fn a_balance_of_twenty_whole_dig_expressed_as_mojos_funds_nothing() { + fn a_whole_dig_figure_used_where_base_units_were_meant_funds_nothing() { let creates = vec![bond("aa", "11")]; - let split = split_by_funds( + let whole_dig = 5_u64; + + let mistaken = split_by_funds(&creates, whole_dig, PER_COIN); + assert!( + !mistaken.is_funded(), + "5 base units is 0.005 $DIG and collateralises nothing" + ); + + let correct = split_by_funds( &creates, - dig_constants::MIRROR_COIN_COLLATERAL_DIG, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, + whole_dig * dig_constants::CAT_MOJOS_PER_DIG, + PER_COIN, ); assert!( - !split.is_funded(), - "20 CAT mojos is 0.02 $DIG and collateralises nothing" + correct.is_funded(), + "the same figure in base units funds the bond, so the test is not vacuously red" + ); + } + + /// The requirement is a PARAMETER, not a constant: the same balance and the same bonds split + /// differently when the epoch's requirement moves. + /// + /// This is the assertion that would go red against an implementation that ignored `per_coin` + /// and used a hard-coded figure — which is precisely the defect `dig-constants` 0.13.0 removed + /// a constant to prevent, and which no single-amount fixture can see. + #[test] + fn a_raised_requirement_funds_fewer_bonds_from_the_same_balance() { + let creates = vec![bond("aa", "11"), bond("bb", "22")]; + let balance = 2 * PER_COIN; + + let at_start = split_by_funds(&creates, balance, PER_COIN); + assert_eq!(at_start.affordable.len(), 2, "both bonds fit at 1.000 DIG each"); + + let raised = split_by_funds(&creates, balance, PER_COIN_RAISED); + assert_eq!( + raised.affordable, + vec![bond("aa", "11")], + "at 2.500 DIG each the same balance funds only the first bond" ); + assert_eq!(raised.short, vec![bond("bb", "22")]); + assert_eq!(raised.shortfall_dig_base_units, PER_COIN_RAISED); } /// Partial funding stops at the first unaffordable create and reports the rest, rather than @@ -530,13 +583,13 @@ mod tests { let creates = vec![bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; let split = split_by_funds( &creates, - 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS, - MIRROR_COIN_COLLATERAL_CAT_MOJOS, + 2 * PER_COIN, + PER_COIN, ); assert_eq!(split.affordable, vec![bond("aa", "11"), bond("bb", "22")]); assert_eq!(split.short, vec![bond("cc", "33")]); - assert_eq!(split.shortfall_cat_mojos, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + assert_eq!(split.shortfall_dig_base_units, PER_COIN); } /// An empty wallet is short everything and creates nothing — and, crucially, this says nothing @@ -545,13 +598,13 @@ mod tests { #[test] fn an_empty_wallet_creates_nothing_and_reports_the_whole_shortfall() { let creates = vec![bond("aa", "11"), bond("bb", "22")]; - let split = split_by_funds(&creates, 0, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + let split = split_by_funds(&creates, 0, PER_COIN); assert!(split.affordable.is_empty()); assert_eq!(split.short, creates); assert_eq!( - split.shortfall_cat_mojos, - 2 * MIRROR_COIN_COLLATERAL_CAT_MOJOS + split.shortfall_dig_base_units, + 2 * PER_COIN ); } @@ -561,7 +614,7 @@ mod tests { #[test] fn a_wallet_at_zero_still_reclaims_what_it_already_locked() { let plan = plan(&[], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH); - let split = split_by_funds(&plan.create, 0, MIRROR_COIN_COLLATERAL_CAT_MOJOS); + let split = split_by_funds(&plan.create, 0, PER_COIN); assert_eq!( plan.reclaim, diff --git a/crates/dig-node-service/src/mirror/presence.rs b/crates/dig-node-service/src/mirror/presence.rs index 3f21d5b5..ab6e773b 100644 --- a/crates/dig-node-service/src/mirror/presence.rs +++ b/crates/dig-node-service/src/mirror/presence.rs @@ -1,4 +1,4 @@ -//! Debounce — deciding when a `.dig` on disk has been there long enough to be worth 20 $DIG. +//! Debounce — deciding when a `.dig` on disk has been there long enough to be worth collateralising. //! //! # What this does NOT guard against //! @@ -27,7 +27,8 @@ //! # The asymmetry is deliberate //! //! Both directions are debounced, but they are not equally dangerous, and the window makes that -//! explicit. Acting early on an APPEARANCE locks 20 $DIG against a capsule that may be gone in a +//! explicit. Acting early on an APPEARANCE locks an epoch's collateral against a capsule that may be +//! gone in a //! second. Acting late on a DISAPPEARANCE leaves a coin live without its `.dig`, which is the //! penalised state. So the settling window is a floor on how long a bond must be stable, and the //! start-up reconcile — which has no window, because a scan at start-up IS the settled state — diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index bf17f003..3361f4cc 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -74,23 +74,25 @@ impl MirrorSpends { } } -/// Build the spends that lock `collateral_cat_mojos` of $DIG as a mirror for one `(store, root, +/// Build the spends that lock `collateral_dig_base_units` of $DIG as a mirror for one `(store, root, /// epoch)`. /// /// A thin wrapper over `dig_mirror_coin::create`: it adds no conditions, alters no amount, and /// changes no destination. Its whole contribution is that what comes back is a [`MirrorSpends`], /// which is the thing the signer will accept. /// -/// `collateral_cat_mojos` is CAT mojos of $DIG — `dig_constants::MIRROR_COIN_COLLATERAL_CAT_MOJOS`, -/// which is 20 whole $DIG times `CAT_MOJOS_PER_DIG`. Passing the whole-$DIG figure would lock 0.02 -/// $DIG and still look like a successful advertisement. +/// `collateral_dig_base_units` is $DIG in **base units** (1 DIG = 1_000), and it is the CURRENT +/// epoch's derived requirement — `apply_safety_margin(required_per_store, margin_bp)` (`SPEC.md` +/// §25.3) — never a constant. Passing a whole-$DIG figure would lock a thousandth of the intended +/// amount and still look like a successful advertisement, which is why the parameter name carries +/// its unit. #[allow(clippy::too_many_arguments)] pub fn build_create( store_launcher_id: Bytes32, root_hash: Bytes32, epoch: BigInt, urls: Vec, - collateral_cat_mojos: u64, + collateral_dig_base_units: u64, dig_coins: Vec, synthetic_key: PublicKey, fee_coins: Vec, @@ -102,7 +104,7 @@ pub fn build_create( root_hash, epoch, urls, - collateral: collateral_cat_mojos, + collateral: collateral_dig_base_units, }, dig_coins, synthetic_key, diff --git a/crates/dig-node-service/src/mirror_lifecycle.rs b/crates/dig-node-service/src/mirror_lifecycle.rs deleted file mode 100644 index 44c2f145..00000000 --- a/crates/dig-node-service/src/mirror_lifecycle.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! The mirror-coin lifecycle (dig-node#377) — WIP scaffold. -//! -//! Presence of a `.dig` file on disk is the trigger: a store+root this node serves gets an -//! on-chain mirror coin locking 20 $DIG, and a coin whose `.dig` is gone gets reclaimed. From 830d6dae70f32af3965e106dd1e5232e9d238923 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 15:48:38 -0700 Subject: [PATCH 04/10] feat(mirror): the scoped signer, and in-flight suppression in the planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 2 and 3 of dig-node#377's locked plan -- the core of the ticket. Production could not sign anything (dig-node#410) and could not record anything (dig-node#411): every `with_signer` call site and every `SpendJournal::new` sits inside test code. This wires both, for the mirror path only, and wires them together on purpose. A signer without a journal is strictly worse than neither -- unattended spends with no record is the exact state SPEC.md §23's bargain exists to prevent. `dig_wallet::operator_wallet::OperatorWallet` opens the §16.4 autoseed under the device key and derives on the STANDARD Chia HD path, reusing `digstore_chain::keys::derive_wallet_keys`. Standard derivation is a recovery property, not a style choice: the phrase `dign wallet export-seed` returns must open this wallet -- including anything locked in unreclaimed mirror coins -- in any ordinary Chia wallet. Its test re-derives the expected address from `chia_bls` + `chia_puzzle_types` directly rather than calling the same helper the implementation calls, because comparing the code against itself would pass for a derivation that recovers nothing. `autoseed::open_operator_phrase` is the one route to the phrase. It reads with `allow_create: false`: minting a device key beside an existing seed produces a key that cannot open it. Its failure type is `Option`, so no error path can carry key material. `mirror::signer::MirrorSigner::sign` takes `&MirrorSpends` and `&RecordedSpend` and nothing else. The first has no public constructor; the second's sole producer is `SpendJournal::begin`. Signing is therefore reachable only for a mirror-shaped spend that has already been journaled -- both bounds structural. `MIRROR_SPEND_FEE_CEILING_MOJOS` (0.001 XCH) is refused from both sides. The operator wallet is NEVER installed on `WalletBackend`. dig-wallet gains the guard test the brief asks for: the general surface still answers `current_signer() == None` after an operator wallet is opened, with a third assertion showing a backend CAN hold a signer so the `None` is a measurement rather than a tautology. Without that guard, enabling collateralisation would silently activate default-on auto-tipping. `plan()` gains `in_flight`, excluding bonds whose create is submitted and unconfirmed. Its four tests include the two a store-keyed or plan-wide implementation would fail: suppression never withholds a reclaim, and one root in flight does not suppress another root of the same store. The chia types that reach `mirror::spends`' public signatures move from dev-dependencies to dependencies, as one set on the 0.36.1 line. Refs #377, #410, #411 --- crates/dig-node-service/Cargo.toml | 16 ++ crates/dig-node-service/src/mirror/mod.rs | 1 + crates/dig-node-service/src/mirror/plan.rs | 128 +++++++++- crates/dig-node-service/src/mirror/signer.rs | 255 +++++++++++++++++++ crates/dig-node-service/src/mirror/spends.rs | 14 + crates/dig-wallet/src/autoseed.rs | 24 ++ crates/dig-wallet/src/lib.rs | 5 + crates/dig-wallet/src/operator_wallet.rs | 193 ++++++++++++++ crates/dig-wallet/src/sage/rpc.rs | 51 ++++ 9 files changed, 681 insertions(+), 6 deletions(-) create mode 100644 crates/dig-node-service/src/mirror/signer.rs create mode 100644 crates/dig-wallet/src/operator_wallet.rs diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index a0924dc8..6fe5198c 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -112,6 +112,22 @@ dig-mirror-coin = "0.7" # to the line `chia-query` and `dig-mirror-coin` compiled against, so all three unify on one crate. dig-chainsource-interface = "0.3" +# The chia types that reach `mirror::spends`' PUBLIC signatures: `dig_mirror_coin::create` takes +# `Vec`, a `PublicKey`, `Coin` fee inputs and a `BigInt` epoch, and `build_create`/ +# `build_reclaim` pass them straight through. They were dev-dependencies while the module was +# unwired; a public signature cannot be spelled in a dev-dependency. +# +# The whole chia set moves TOGETHER and every version here is the one `dig-mirror-coin` 0.7 and +# `dig-wallet` compile against. A crate split across two chia lines compiles until something +# crosses a public signature -- which is exactly what these four do -- and then the `Bytes32` the +# builder wants is a different type from the one the caller holds. +chia-bls = "0.36.1" +chia-protocol = "0.36.1" +chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +# The epoch term of a mirror hint is a `BigInt`, not a `u64`: the morph is arithmetic over +# 32-byte values and the crate's API says so. Same line as `dig-mirror-coin`'s own. +num-bigint = "0.4.6" + # The OS CSPRNG for all authorization material — the control token, pairing ids/tokens # (§7), and the relay loop-probe id (`control::fill_random`). Wraps `getrandom(2)` / # `/dev/urandom` on Unix and `BCryptGenRandom` on Windows, one code path on every diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 0cde4b63..981e3fe5 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -72,4 +72,5 @@ pub mod plan; pub mod presence; +pub mod signer; pub mod spends; diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs index 4577a532..da1b2042 100644 --- a/crates/dig-node-service/src/mirror/plan.rs +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -127,7 +127,27 @@ impl MirrorPlan { /// one is needed, but choosing which of two valid bonds to destroy is a decision this function does /// not have the information to make, and destroying the wrong one costs a real advertisement. They /// stop being duplicates at the next rollover, when both are reclaimed as `EpochEnded`. -pub fn plan(held: &[Bond], on_chain: &[HeldMirror], current_epoch: i64) -> MirrorPlan { +/// +/// `in_flight` is the set of bonds whose CURRENT-epoch create has already been submitted and has not +/// yet resolved — a `pending` or `submitted` mirror-coin entry in the audit record (`SPEC.md` +/// §25.4.6). They are excluded from the create set, and only from it: an in-flight create says +/// nothing about whether a DIFFERENT coin should be reclaimed, so passing a bond here can never +/// withhold a reclaim. +/// +/// The suppression is necessary because a create is not visible on chain until it confirms, and a +/// pass that ran in that window would see the bond as uncovered and pay for a second coin. The +/// chain and the disk remain the only STEADY-STATE truths; this is the in-flight ledger, and it is +/// consulted for nothing else. +/// +/// Suppressing is the fail-safe direction: a wrongly-suppressed create leaves money in the wallet +/// and the node undiscoverable for one pass (§25.9), while a wrongly-permitted one locks a second +/// epoch's collateral against a bond that already has a coin. +pub fn plan( + held: &[Bond], + on_chain: &[HeldMirror], + current_epoch: i64, + in_flight: &[Bond], +) -> MirrorPlan { let held_set: BTreeSet<&Bond> = held.iter().collect(); let mut reclaim = Vec::new(); @@ -151,9 +171,11 @@ pub fn plan(held: &[Bond], on_chain: &[HeldMirror], current_epoch: i64) -> Mirro } } + let in_flight_set: BTreeSet<&Bond> = in_flight.iter().collect(); + let mut create: Vec = held .iter() - .filter(|b| !covered.contains(*b)) + .filter(|b| !covered.contains(*b) && !in_flight_set.contains(*b)) .cloned() .collect(); @@ -174,7 +196,7 @@ pub struct FundingSplit { pub affordable: Vec, /// The creates it cannot — what the node must report as uncollateralised. pub short: Vec, - /// How much more $DIG, in CAT mojos, would cover [`Self::short`] entirely. + /// How many more DIG base units would cover [`Self::short`] entirely. pub shortfall_dig_base_units: u64, } @@ -265,9 +287,15 @@ mod tests { /// happens to select, and passes for the wrong reason on most days. const NOW_EPOCH: i64 = 100; + /// The ordinary case: nothing submitted and awaiting confirmation. + /// + /// Named rather than written as a bare `&[]` at every call site so that a reader can tell the + /// "no creates are in flight" fixtures from the ones that deliberately exercise suppression. + const NOT_IN_FLIGHT: &[Bond] = &[]; + #[test] fn a_held_capsule_with_no_coin_is_created() { - let plan = plan(&[bond("aa", "11")], &[], NOW_EPOCH); + let plan = plan(&[bond("aa", "11")], &[], NOW_EPOCH, NOT_IN_FLIGHT); assert_eq!(plan.create, vec![bond("aa", "11")]); assert!(plan.reclaim.is_empty()); } @@ -278,6 +306,7 @@ mod tests { &[bond("aa", "11")], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert!(plan.is_empty(), "steady state must be a no-op: {plan:?}"); } @@ -296,6 +325,7 @@ mod tests { coin("c2", "bb", "22", NOW_EPOCH), ], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -321,6 +351,7 @@ mod tests { &[bond("aa", "11")], &[coin("old", "aa", "11", NOW_EPOCH - 1)], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -346,6 +377,7 @@ mod tests { coin("new", "aa", "11", NOW_EPOCH), ], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -372,6 +404,7 @@ mod tests { coin("past", "aa", "11", NOW_EPOCH - 1), ], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -394,6 +427,7 @@ mod tests { &[bond("aa", "11"), bond("aa", "22")], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -416,6 +450,7 @@ mod tests { &[bond("aa", "22")], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( @@ -436,6 +471,7 @@ mod tests { coin("c2", "aa", "11", NOW_EPOCH), ], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert!(held.is_empty(), "neither duplicate is destroyed while held"); @@ -446,6 +482,7 @@ mod tests { coin("c2", "aa", "11", NOW_EPOCH), ], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( gone.reclaim.len(), @@ -458,7 +495,7 @@ mod tests { /// saw both a legacy and a migrated artifact must not pay 40 $DIG for one advertisement. #[test] fn a_bond_listed_twice_on_disk_is_created_once() { - let plan = plan(&[bond("aa", "11"), bond("aa", "11")], &[], NOW_EPOCH); + let plan = plan(&[bond("aa", "11"), bond("aa", "11")], &[], NOW_EPOCH, NOT_IN_FLIGHT); assert_eq!(plan.create, vec![bond("aa", "11")]); } @@ -468,11 +505,13 @@ mod tests { &[bond("aa", "11"), bond("bb", "22"), bond("cc", "33")], &[], NOW_EPOCH, + NOT_IN_FLIGHT, ); let reversed = plan( &[bond("cc", "33"), bond("bb", "22"), bond("aa", "11")], &[], NOW_EPOCH, + NOT_IN_FLIGHT, ); assert_eq!( forward.create, reversed.create, @@ -613,7 +652,7 @@ mod tests { /// advertise nor recover at zero balance, and that is what stranded the money. #[test] fn a_wallet_at_zero_still_reclaims_what_it_already_locked() { - let plan = plan(&[], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH); + let plan = plan(&[], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH, NOT_IN_FLIGHT); let split = split_by_funds(&plan.create, 0, PER_COIN); assert_eq!( @@ -623,4 +662,81 @@ mod tests { ); assert!(split.affordable.is_empty()); } + + /// A create already submitted and awaiting confirmation is not paid for twice. + /// + /// The window is real: a create is invisible on chain until it confirms, so a pass that ran + /// between submission and confirmation sees the bond as uncovered. The control is a SECOND held + /// bond with nothing in flight, which must still be created — without it, an implementation + /// that suppressed every create whenever anything was in flight passes identically, and that + /// implementation would stall collateralisation of the whole node behind one slow confirmation. + #[test] + fn a_bond_whose_create_is_in_flight_is_not_created_again() { + let plan = plan( + &[bond("aa", "11"), bond("bb", "22")], + &[], + NOW_EPOCH, + &[bond("aa", "11")], + ); + + assert_eq!( + plan.create, + vec![bond("bb", "22")], + "only the bond with a create in flight is suppressed" + ); + } + + /// In-flight suppression touches CREATES only. A bond whose create is in flight, whose capsule + /// has since gone, still has its EXISTING coin reclaimed. + /// + /// This is the assertion that keeps the suppression from becoming a way to withhold money: the + /// two coins are different coins, and a filter written over the plan rather than over the create + /// set would silently swallow the reclaim. + #[test] + fn an_in_flight_create_never_suppresses_a_reclaim() { + let plan = plan( + &[], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + &[bond("aa", "11")], + ); + + assert_eq!( + plan.reclaim, + vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + "a reclaim is never gated on an unrelated create being in flight" + ); + assert!(plan.create.is_empty()); + } + + /// Suppression is keyed on the BOND, not on the store. Two roots of one store are two coins, + /// and a create in flight for one must not withhold the other. + /// + /// A store-keyed implementation passes every fixture above and fails only this one, which is + /// why it is here: the whole point of the per-root shape is that the two are funded separately. + #[test] + fn an_in_flight_create_for_one_root_does_not_suppress_another_root_of_the_same_store() { + let plan = plan( + &[bond("aa", "11"), bond("aa", "22")], + &[], + NOW_EPOCH, + &[bond("aa", "11")], + ); + + assert_eq!(plan.create, vec![bond("aa", "22")]); + } + + /// A bond that is in flight AND already covered on chain is simply covered — suppression adds + /// nothing and removes nothing. Recorded because the two exclusions compose, and a reader + /// should not have to reason about whether one masks the other. + #[test] + fn a_covered_bond_that_is_also_in_flight_is_still_a_no_op() { + let plan = plan( + &[bond("aa", "11")], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + &[bond("aa", "11")], + ); + assert!(plan.is_empty()); + } } diff --git a/crates/dig-node-service/src/mirror/signer.rs b/crates/dig-node-service/src/mirror/signer.rs new file mode 100644 index 00000000..4381bebe --- /dev/null +++ b/crates/dig-node-service/src/mirror/signer.rs @@ -0,0 +1,255 @@ +//! The one thing in this process that may sign a mirror-coin spend. +//! +//! # Two guarantees, both structural +//! +//! **What may be signed** is bounded by the argument type. [`MirrorSigner::sign`] takes a +//! [`MirrorSpends`], which has no public constructor and whose only producers wrap +//! `dig_mirror_coin::create` and `::reclaim`. There is no entry point here that accepts a +//! `CoinSpend`, a `Vec`, or a `SpendBundle`, so this signer is not a signing oracle with +//! a filter in front of it — it is a function that cannot be handed anything else. +//! +//! **That a spend is recorded** is bounded the same way. `sign` also takes a +//! [`RecordedSpend`](crate::spend_audit::RecordedSpend), whose sole producer is +//! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin). A caller therefore cannot reach +//! a signature without having first written a `pending` audit entry: recording is the SHAPE of the +//! call, not a convention a later producer can forget. That is the whole §908 bargain — the node may +//! spend without asking *because* the account of it is readable afterwards — and it is worth stating +//! that a signer wired without a journal would be strictly worse than neither, since it would produce +//! unattended spends with no record. +//! +//! # It is not installed anywhere +//! +//! The [`OperatorWallet`] inside is held by this type and is never passed to +//! `WalletBackend::with_signer`. `WalletBackend::current_signer()` therefore answers exactly what it +//! answered before this module existed — asserted in dig-wallet's own +//! `sage::rpc::tests::opening_the_operator_wallet_installs_no_signer_on_the_general_surface`, because +//! that is where a backend can be built and where the mistake would be made. The failure it guards +//! against is silent: installing a signer on the general backend would +//! activate every other node-custodied spend path, including default-on auto-tipping, as a side +//! effect of turning collateralisation on. +//! +//! # Fees cannot shave collateral +//! +//! The crate's builders take XCH fee coins separately from the $DIG being locked, so a fee is never +//! taken out of the amount advertised. [`MIRROR_SPEND_FEE_CEILING_MOJOS`] bounds the fee itself, and +//! [`MirrorSigner::sign`] refuses above it rather than trusting every future caller to check. + +use chia_protocol::{Bytes32, SpendBundle}; +use dig_wallet::operator_wallet::OperatorWallet; + +use crate::spend_audit::RecordedSpend; + +use super::spends::MirrorSpends; + +/// The most XCH, in mojos, a single mirror spend may pay in fees: 0.001 XCH. +/// +/// Deliberately generous against observed mainnet fees — its job is that the bound EXISTS and is +/// stated, not that it is tight. The shipped default fee is 0 and a zero-fee reclaim is explicitly +/// supported, so this ceiling is reached only by a caller that has chosen to pay. +/// +/// It may be lowered. It MUST NOT be raised without saying why in the same change: the whole point of +/// a named ceiling on an unattended money path is that widening it is visible. +pub const MIRROR_SPEND_FEE_CEILING_MOJOS: u64 = 1_000_000_000; + +/// Why a mirror spend could not be signed. +/// +/// No variant carries key material, a phrase, or a puzzle hash — an error from a signing path is one +/// of the likeliest things to end up in a log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignError { + /// The requested fee exceeds [`MIRROR_SPEND_FEE_CEILING_MOJOS`]. + FeeAboveCeiling { + /// What was asked for, in XCH mojos. + requested_mojos: u64, + /// The ceiling it exceeded, in XCH mojos. + ceiling_mojos: u64, + }, + /// The signature could not be produced. Carries a one-line cause with no key material in it. + Signing(String), +} + +impl std::fmt::Display for SignError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SignError::FeeAboveCeiling { + requested_mojos, + ceiling_mojos, + } => write!( + f, + "mirror spend fee {requested_mojos} mojos exceeds the ceiling of {ceiling_mojos}" + ), + SignError::Signing(cause) => write!(f, "mirror spend could not be signed: {cause}"), + } + } +} + +impl std::error::Error for SignError {} + +/// Signs mirror-coin creates and reclaims with the node's own operating wallet, and nothing else. +/// +/// Construct one at bring-up and keep it inside the lifecycle. It is deliberately not `Clone` and +/// exposes no way to get at the wallet or the signer it holds: the only thing a holder can do with +/// one is sign a proven mirror spend that has already been journaled. +pub struct MirrorSigner { + wallet: OperatorWallet, +} + +impl MirrorSigner { + /// Wrap an already-opened operator wallet. + /// + /// Takes the wallet rather than opening it so the decision "is an operator wallet available at + /// all" belongs to bring-up, where the answer can be reported once as a capability state, instead + /// of being rediscovered — and possibly reported differently — at every spend. + pub fn new(wallet: OperatorWallet) -> Self { + Self { wallet } + } + + /// This wallet's own puzzle hash: the `owner_puzzle_hash` term of every hint this node creates, + /// the destination reclaims return to, and the address the user funds. + pub fn owner_puzzle_hash(&self) -> Bytes32 { + self.wallet.owner_puzzle_hash() + } + + /// Sign `spends`, which the record `_recorded` has already been opened for. + /// + /// `_recorded` is unused by the signing arithmetic and is required anyway. That is the point: its + /// only producer is `SpendJournal::begin`, so demanding one makes a `pending` audit entry a + /// precondition of a signature that the type system enforces. Removing this parameter would + /// remove the audit guarantee while changing no observable behaviour — which is exactly why it is + /// spelled out here rather than left to a reviewer to notice. + /// + /// Refuses a fee above [`MIRROR_SPEND_FEE_CEILING_MOJOS`] before signing anything. + pub fn sign( + &self, + spends: &MirrorSpends, + _recorded: &RecordedSpend, + fee_mojos: u64, + ) -> Result { + if fee_mojos > MIRROR_SPEND_FEE_CEILING_MOJOS { + return Err(SignError::FeeAboveCeiling { + requested_mojos: fee_mojos, + ceiling_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS, + }); + } + + let coin_spends = spends.coin_spends().to_vec(); + let signature = self + .wallet + .signer() + .sign(&coin_spends) + .map_err(|e| SignError::Signing(e.to_string()))?; + + Ok(SpendBundle::new(coin_spends, signature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spend_audit::{ + kinds, Asset, Authority, SpendIntent, SpendJournal, SpendLog, + }; + + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon art"; + + fn signer() -> MirrorSigner { + MirrorSigner::new( + OperatorWallet::from_phrase(PHRASE, Bytes32::from([7u8; 32])).expect("derives"), + ) + } + + fn journal(dir: &std::path::Path) -> SpendJournal { + SpendJournal::new(SpendLog::at(dir.join("spend-audit.jsonl"))) + } + + fn intent() -> SpendIntent { + SpendIntent { + kind: crate::spend_audit::SpendKind::new(kinds::MIRROR_COIN), + purpose: "collateralise a held capsule".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: 1_000, + fee_mojos: 0, + store_id: Some("store".to_string()), + } + } + + /// The mirror signer holds its wallet and hands nothing back. + /// + /// The companion assertion — that the GENERAL `WalletBackend` surface still answers + /// `current_signer() == None` once an operator wallet has been opened — lives in dig-wallet + /// (`sage::rpc::tests::opening_the_operator_wallet_installs_no_signer_on_the_general_surface`), + /// because that is where a backend can be built and where the mistake would be made. It is the + /// guard against a silent side effect: installing this wallet on the general backend would + /// activate every other node-custodied spend path, default-on auto-tipping included. + #[test] + fn the_mirror_signer_exposes_its_wallet_to_nobody() { + let mirror = signer(); + assert_ne!( + mirror.owner_puzzle_hash(), + Bytes32::default(), + "a real wallet is open behind it" + ); + } + + /// A fee above the ceiling is refused, and one at the ceiling is not. + /// + /// Both sides, because a bound tested only from above passes for an implementation with no bound + /// at all, and one tested only at the bound passes for an implementation that refuses everything. + #[test] + fn the_fee_ceiling_is_exact_in_both_directions() { + let dir = tempfile::tempdir().expect("tempdir"); + let journal = journal(dir.path()); + let recorded = journal.begin(intent()); + let spends = super::super::spends::empty_for_tests(); + let signer = signer(); + + let over = signer.sign(&spends, &recorded, MIRROR_SPEND_FEE_CEILING_MOJOS + 1); + assert_eq!( + over, + Err(SignError::FeeAboveCeiling { + requested_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS + 1, + ceiling_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS, + }), + "one mojo over the ceiling is refused" + ); + + assert!( + signer + .sign(&spends, &recorded, MIRROR_SPEND_FEE_CEILING_MOJOS) + .is_ok(), + "exactly at the ceiling is permitted, so the refusal above is not unconditional" + ); + } + + /// Signing requires a journaled spend, and the journal entry exists BEFORE the signature. + /// + /// The type system already makes a `RecordedSpend` unobtainable without `SpendJournal::begin`, so + /// this test cannot fail while compiling — which is the property being demonstrated. What it does + /// check is the observable half: that `begin` has actually written a `pending` line by the time a + /// signature is possible, rather than deferring the write to some later resolution. + #[test] + fn a_pending_audit_entry_exists_before_a_signature_can_be_produced() { + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log.clone()); + + let recorded = journal.begin(intent()); + let ledger = log.ledger().expect("ledger readable"); + assert_eq!( + ledger.records.len(), + 1, + "the record is written by `begin`, not by whatever happens next" + ); + assert_eq!(ledger.records[0].status.token(), "pending"); + + signer() + .sign(&super::super::spends::empty_for_tests(), &recorded, 0) + .expect("an empty spend set signs to an empty aggregate"); + } +} diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index 3361f4cc..ca6c1976 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -141,3 +141,17 @@ pub fn build_reclaim( spends, }) } + +/// An empty [`MirrorSpends`] for tests that exercise the SIGNER rather than the builders. +/// +/// `#[cfg(test)]` so it cannot become a production constructor — the no-public-constructor property +/// is the whole authority bound, and a test seam that widened it would quietly remove the thing this +/// module exists to guarantee. It carries [`MirrorOperation::Create`] because a `MirrorSpends` always +/// names an operation; the operation is irrelevant to an empty spend set. +#[cfg(test)] +pub(crate) fn empty_for_tests() -> MirrorSpends { + MirrorSpends { + operation: MirrorOperation::Create, + spends: Vec::new(), + } +} diff --git a/crates/dig-wallet/src/autoseed.rs b/crates/dig-wallet/src/autoseed.rs index c21a406e..b6288c96 100644 --- a/crates/dig-wallet/src/autoseed.rs +++ b/crates/dig-wallet/src/autoseed.rs @@ -309,6 +309,30 @@ pub fn open_sealed_with_device_key( seed_store::decrypt_seed(sealed, device_key_hex) } +/// Open the operator wallet's own mnemonic from `paths`, or `None` when it cannot be opened. +/// +/// The one route by which anything outside this module obtains the operator phrase. It is deliberately +/// narrow: it takes no key, mints nothing, and creates nothing. A missing seed, a missing or malformed +/// device key ([`BootstrapState::Orphaned`]), and a seed that will not decrypt +/// ([`BootstrapState::Locked`]) all collapse to `None` — a bring-up caller's only correct response to +/// any of them is the same one, and distinguishing them here would tempt a caller into reporting which +/// failure it hit, which is a statement about the machine's key state. +/// +/// **`None` means "no operator wallet is available", never "there is no wallet".** A caller MUST report +/// the capability as unavailable rather than degrade to some other key. +/// +/// The phrase comes back in a [`Zeroizing`] wrapper and this function neither logs, formats, nor +/// returns it in an error: the error type here is `Option` precisely so no failure path can carry a +/// fragment of key material in a message. +pub fn open_operator_phrase(paths: &WalletPaths) -> Option> { + // `allow_create: false` is load-bearing. Minting a device key beside an existing seed produces a + // key that cannot open it; a READ path must never take that branch, and the bootstrap + // ([`ensure_wallet`]) is the only caller entitled to. + let key = load_device_key(&paths.device_key, false).ok()?; + let sealed = fs::read(&paths.seed).ok()?; + open_sealed_with_device_key(&sealed, &key.hex()).ok() +} + /// Read the sidecar, or `None` when it is absent, unreadable or unparsable. /// /// Callers must treat `None` as "not disposable" rather than as "auto" — an existing seed with no diff --git a/crates/dig-wallet/src/lib.rs b/crates/dig-wallet/src/lib.rs index b1728b4f..08426f73 100644 --- a/crates/dig-wallet/src/lib.rs +++ b/crates/dig-wallet/src/lib.rs @@ -72,6 +72,11 @@ pub mod seed_export; // under a machine-held device key. Every failure arm is fail-closed and writes nothing. pub mod autoseed; +/// Bring-up for the node's OWN operating wallet (`SPEC.md` §16.4) — the one wallet this process may +/// sign with, derived on the standard Chia HD path so the exported phrase recovers it anywhere. It is +/// never installed on the general [`sage`] backend. See [`operator_wallet`]. +pub mod operator_wallet; + /// One wallet request awaiting Sage. `wc_dispatch` cannot reach the relay itself (the /// live WalletConnect requester SignClient lives /// in the wallet UI page, the one tab that stays open), so it parks the call here and diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs new file mode 100644 index 00000000..f6067575 --- /dev/null +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -0,0 +1,193 @@ +//! Bring-up for the node's OWN operating wallet — the one wallet this process may sign with. +//! +//! # What this is, and what it is emphatically not +//! +//! `SPEC.md` §16.4 mints a sealed mnemonic on every install, under a device key, with no user in the +//! path. That wallet is **machine custody**: it is the node's own money, the address the dig-app +//! deposit flow funds, and the address collateral returns to. It is NOT the user's account, and +//! §908 is untouched by this module — no user seed reaches this process, and nothing here can be +//! reached by a dapp, an RPC method, or the control plane. +//! +//! This module is the only place that turns that sealed phrase into something that can produce a +//! signature. It exists so there is exactly ONE such place: a second derivation of the same wallet +//! would be a second answer to "which address is ours", and the two would disagree the moment either +//! moved. +//! +//! # Standard Chia HD derivation, deliberately +//! +//! The keys are derived by `digstore_chain::keys::derive_wallet_keys`, the ecosystem's canonical +//! standard-Chia-HD derivation (BIP-39 seed → master → `master_to_wallet_unhardened(0)` → +//! `derive_synthetic()`). Using the standard path is a **recovery property, not a style choice**: the +//! phrase `dign wallet export-seed` hands back must open this wallet in any ordinary Chia wallet, +//! including whatever is locked in unreclaimed mirror coins, with no dig-node code involved. A +//! bespoke derivation would make the exported phrase a phrase that recovers nothing. +//! +//! The first derived key is the wallet: its standard puzzle hash is the receive address, the +//! `owner_puzzle_hash` term of every mirror hint this node creates, the address reclaims return to, +//! and the address create-change returns to. Deposits, bonds and reclaims therefore all move through +//! one address the wallet already tracks. +//! +//! # Nothing here is installed on the general wallet surface +//! +//! [`OperatorWallet`] is a value a caller holds; it is not registered anywhere. In particular it is +//! never passed to `WalletBackend::with_signer`, so `WalletBackend::current_signer()` answers exactly +//! what it answered before — see the guard test at the bottom of this file. That matters beyond +//! tidiness: installing a signer on the general backend would silently activate every other +//! node-custodied spend path, including default-on auto-tipping, as a side effect of enabling +//! collateralisation. + +use chia_protocol::Bytes32; + +use crate::autoseed::{self, WalletPaths}; +use crate::sage::spend::WalletSigner; + +/// The node's own operating wallet, opened and ready to sign. +/// +/// Holding one is not permission to sign anything in particular — the signer inside is reachable +/// only through [`Self::signer`], and the mirror lifecycle wraps it so its own callers can pass +/// nothing but a proven mirror spend. +pub struct OperatorWallet { + signer: WalletSigner, + owner_puzzle_hash: Bytes32, +} + +impl OperatorWallet { + /// Open the operator wallet at `paths` for the network `agg_sig_data` selects. + /// + /// `None` when no operator wallet is available — the seed is absent, the device key is missing or + /// malformed (§16.4 `Orphaned`), the sealed seed will not open (`Locked`), or the phrase does not + /// derive. A caller MUST report the capability as unavailable rather than fall back to any other + /// key; there is no other key it would be correct to use. + /// + /// This function reads. It never mints a seed and never mints a device key: minting a device key + /// beside an existing seed produces a key that cannot open it, turning a recoverable mistake into + /// permanent loss. Only the §16.4 bootstrap may create either. + /// + /// Nothing here is logged. The phrase lives in a zeroizing wrapper for the length of the + /// derivation and the failure type is `Option`, so no error path can carry a fragment of it. + pub fn open(paths: &WalletPaths, agg_sig_data: Bytes32) -> Option { + let phrase = autoseed::open_operator_phrase(paths)?; + Self::from_phrase(&phrase, agg_sig_data) + } + + /// Derive the wallet from a mnemonic already in hand. + /// + /// Separated from [`Self::open`] so the derivation — the half with a checkable property — can be + /// exercised against a known phrase without a sealed file, a device key, or a temp layout. The + /// seal is `autoseed`'s contract and is tested there; this is the derivation's. + pub fn from_phrase(phrase: &str, agg_sig_data: Bytes32) -> Option { + let keys = digstore_chain::keys::derive_wallet_keys(phrase).ok()?; + Some(Self { + owner_puzzle_hash: keys.owner_puzzle_hash, + signer: WalletSigner::new(vec![keys.synthetic_sk], agg_sig_data), + }) + } + + /// The signer for this wallet's keys. + pub fn signer(&self) -> &WalletSigner { + &self.signer + } + + /// This wallet's own standard puzzle hash — the receive address, the reclaim destination, the + /// change destination, and the `owner_puzzle_hash` term of every mirror hint this node creates. + /// + /// One value for all four uses on purpose: a deposit the user makes, a bond the node locks, and + /// the collateral that comes back all move through an address the wallet already watches. + pub fn owner_puzzle_hash(&self) -> Bytes32 { + self.owner_puzzle_hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed, well-known BIP-39 phrase. Fixed rather than generated so the derived address is a + /// value this test can compare against an INDEPENDENT derivation rather than against itself. + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon art"; + + fn agg_sig_data() -> Bytes32 { + Bytes32::from([7u8; 32]) + } + + /// The wallet derives, and its address is the standard-layer puzzle hash of the first + /// unhardened synthetic key. + /// + /// The assertion re-derives the expected value from `chia_bls` + `chia_puzzle_types` DIRECTLY, + /// rather than calling the same helper the implementation calls. Comparing the code against + /// itself would pass for any derivation whatsoever, including one whose exported phrase recovers + /// nothing — which is the whole property this test exists to hold. + #[test] + fn the_operator_address_is_the_standard_chia_hd_address_for_the_phrase() { + use chia_bls::{derive_keys::master_to_wallet_unhardened, DerivableKey, SecretKey}; + use chia_puzzle_types::standard::StandardArgs; + + let wallet = OperatorWallet::from_phrase(PHRASE, agg_sig_data()) + .expect("a valid 24-word phrase derives"); + + let mnemonic = bip39::Mnemonic::parse(PHRASE).expect("valid phrase"); + let seed = mnemonic.to_seed(""); + let master = SecretKey::from_seed(&seed); + let expected_sk = master_to_wallet_unhardened(&master, 0).derive_synthetic(); + let expected_ph = Bytes32::from( + StandardArgs::curry_tree_hash(expected_sk.public_key()).to_bytes(), + ); + + assert_eq!( + wallet.owner_puzzle_hash(), + expected_ph, + "the exported phrase must recover this wallet in any standard Chia wallet" + ); + } + + /// The derived key is the one the signer will actually sign with — not merely an address that + /// happens to match. + /// + /// Asserted through `public_keys()`, which is what `WalletSigner::sign` decides on, rather than + /// through `puzzle_hashes()`: a coin's puzzle hash equals its owner's p2 hash only for a bare + /// standard coin, and a mirror coin is a CAT. The required KEY is what is invariant. + #[test] + fn the_signer_holds_the_key_the_address_is_derived_from() { + let wallet = OperatorWallet::from_phrase(PHRASE, agg_sig_data()).expect("derives"); + let keys = digstore_chain::keys::derive_wallet_keys(PHRASE).expect("derives"); + + assert!( + wallet.signer().public_keys().contains(&keys.synthetic_pk), + "the signer must hold the key that spends the wallet's own address" + ); + assert_eq!( + wallet.signer().change_puzzle_hash(), + Some(wallet.owner_puzzle_hash()), + "change returns to the same address deposits arrive at" + ); + } + + /// A phrase that is not a valid mnemonic yields no wallet, rather than a wallet derived from + /// garbage. The control is the valid phrase above, which must still derive — without it, an + /// implementation that returned `None` unconditionally would pass. + #[test] + fn an_invalid_phrase_yields_no_wallet() { + assert!( + OperatorWallet::from_phrase("not a mnemonic", agg_sig_data()).is_none(), + "an unparsable phrase must not produce a wallet" + ); + assert!( + OperatorWallet::from_phrase(PHRASE, agg_sig_data()).is_some(), + "and the valid phrase must, so the assertion above is not vacuous" + ); + } + + /// Two networks derive the SAME address and different signing domains. + /// + /// `agg_sig_data` changes the message a signature commits to, never the key that produces it, so + /// an implementation that folded the network into derivation would give a node one address on + /// mainnet and another on testnet — and money sent to the first would be invisible to the second. + #[test] + fn the_address_does_not_depend_on_the_network_domain() { + let a = OperatorWallet::from_phrase(PHRASE, Bytes32::from([1u8; 32])).expect("derives"); + let b = OperatorWallet::from_phrase(PHRASE, Bytes32::from([2u8; 32])).expect("derives"); + assert_eq!(a.owner_puzzle_hash(), b.owner_puzzle_hash()); + } +} diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 3c1cc55d..0760eae6 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -4886,6 +4886,57 @@ mod tests { "00".repeat(32) } + /// Opening the node's OWN operating wallet must NOT give the general wallet surface a signer. + /// + /// `crate::operator_wallet::OperatorWallet` exists so the mirror-coin lifecycle (dig-node#377) + /// can sign its own spends. It is deliberately a value the caller holds, never something + /// installed here — because installing it would activate every OTHER node-custodied spend path + /// at once, default-on auto-tipping included, as an invisible side effect of enabling + /// collateralisation. That is a behaviour change to a money path nobody reviewed. + /// + /// The fixture is built so the two halves are distinguishable. Asserting only that a fresh + /// backend has no signer proves nothing about `operator_wallet` — it is true of a backend + /// nothing has touched. So the wallet is genuinely opened first (asserted non-degenerate), and + /// the backend is then re-read; and the third assertion shows the backend CAN hold a signer, so + /// the `None` above is a fact about what was installed rather than about what is possible. + #[tokio::test] + async fn opening_the_operator_wallet_installs_no_signer_on_the_general_surface() { + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + let be = backend_with(vec![], true).await; + assert!( + be.current_signer().is_none(), + "baseline: a backend with nothing installed cannot sign" + ); + + let operator = crate::operator_wallet::OperatorWallet::from_phrase( + PHRASE, + Bytes32::from([7u8; 32]), + ) + .expect("the operator wallet really does open"); + assert_ne!( + operator.owner_puzzle_hash(), + Bytes32::default(), + "so the assertion below is about installation, not about a failed open" + ); + + assert!( + be.current_signer().is_none(), + "the operator wallet is held by its caller and installed on nothing" + ); + + let installed = backend_with(vec![], true) + .await + .with_signer(Arc::new(crate::sage::spend::WalletSigner::new( + vec![], + Bytes32::from([7u8; 32]), + ))); + assert!( + installed.current_signer().is_some(), + "a backend CAN hold a signer, so the `None` above is a measurement and not a tautology" + ); + } + async fn backend_with(coins: Vec, synced: bool) -> WalletBackend { let db = WalletDb::open_in_memory().await.unwrap(); db.upsert_coins(&coins).await.unwrap(); From 20a4d0fd9e4645fd8ab9577332b34f9c7352ad76 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 18:36:54 -0700 Subject: [PATCH 05/10] test(mirror): the raised-requirement fixture could not fund even one bond Verified as a fixture error and not a code defect before changing it: with `balance = 2 * PER_COIN` (2,000 base units) the raised leg asked `split_by_funds` for one bond at 2,500 each, which 2,000 cannot buy. The assertion expected one affordable bond, so the test was red for an arithmetic reason of its own making. `split_by_funds` divides balance by `per_coin` and clamps to the create count -- 2500/1000 = 2 and 2500/2500 = 1 -- which is the behaviour the test means to pin. Balance is now 2,500 so the SAME balance lands on a different side of each requirement, which is the only shape that distinguishes a parameterised split from one agreeing with a hard-coded amount by coincidence. Also lands the in-progress pass runner (step 4) so the work survives a cap. Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/mod.rs | 1 + crates/dig-node-service/src/mirror/pass.rs | 518 +++++++++++++++++++++ crates/dig-node-service/src/mirror/plan.rs | 5 +- 3 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 crates/dig-node-service/src/mirror/pass.rs diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 981e3fe5..2ec3c8c5 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -70,6 +70,7 @@ //! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named //! `*_mojos` and come from separate coins so a fee can never shave collateral. +pub mod pass; pub mod plan; pub mod presence; pub mod signer; diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs new file mode 100644 index 00000000..83fb7e28 --- /dev/null +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -0,0 +1,518 @@ +//! What one reconcile pass DECIDES — the whole of `SPEC.md` §25.4 and §25.7, as a pure function. +//! +//! # Why the decision is separated from the doing +//! +//! A pass observes two things (the settled disk, the owned coins), consults three more (the epoch's +//! requirement, the wallet balance, the enable switch), and produces a list of spends to make plus a +//! state to report for every bond. Only the *making* needs a chain, a wallet and a clock; the +//! deciding needs none of them. +//! +//! Keeping the deciding pure is what makes the hostile cases testable at all. "The requirement is +//! unknown and there are coins to reclaim", "the switch is off and two coins are live", "$DIG is +//! short but XCH is not" are each a handful of literals against [`decide`], rather than a chain and a +//! wallet that must be induced into a state and then observed through a socket. +//! +//! # The three rules that are easy to get backwards +//! +//! Each of these fails in an expensive direction, so each is stated here and asserted below. +//! +//! 1. **Reclaims are never withheld.** Not for want of $DIG, not for want of XCH, not because the +//! requirement is unknown, and not because collateralisation is switched off. A reclaim RETURNS +//! money; withholding one is the legacy defect where a wallet at zero could neither advertise nor +//! recover what it had already locked. A reclaim's amount comes from the coin being reclaimed, so +//! it needs no requirement to be known. +//! 2. **The switch gates CREATES only.** Turning collateralisation off must RELEASE what is locked, +//! not freeze it — a revocation that stranded funds inverts the point of revoking. It does that +//! with no new machinery: OFF forces the desired bond set empty, and the ordinary plan then +//! reclaims every live coin. +//! 3. **An unknown requirement defers creates and reports why.** Deferring is not the same as being +//! unfunded, and conflating them produces an out-of-funds alarm about a wallet that is fine +//! (dig-app#300). A missed create fails safe — the money stays in the wallet. + +use dig_mirror_collateral::margin::apply_safety_margin; + +use crate::collateral::{CollateralRequirementResult, CollateralUnknownReason}; + +use super::plan::{plan, Bond, FundingSplit, HeldMirror, MirrorPlan, ReclaimReason}; + +/// What one pass has decided to do, and what to report for every bond it considered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PassDecision { + /// Coins to spend back to the owner, in plan order — reclaims first, always. + pub reclaim: Vec<(HeldMirror, ReclaimReason)>, + /// Creates to make, in deterministic order, each at [`Self::per_coin_dig_base_units`]. + pub create: Vec, + /// The margined per-epoch requirement these creates lock, when it is known. + /// + /// `None` exactly when [`Self::create`] is empty for want of a requirement — never a figure the + /// pass guessed. A caller MUST NOT substitute a default: a create at the wrong amount is a coin + /// that locks money and advertises nothing a verifier will accept. + pub per_coin_dig_base_units: Option, + /// One state per bond the node holds, for the §25.8 surface. + pub states: Vec<(Bond, BondState)>, +} + +/// What the node can say about one bond right now. +/// +/// The variants exist to keep three genuinely different situations apart. "I am out of money", +/// "I do not yet know the price", and "this coin is already in the mempool" all mean "no coin yet" +/// and call for entirely different responses from a person — and collapsing the first two is what +/// produces an hourly out-of-funds alarm about a perfectly funded node. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BondState { + /// A coin for this bond and epoch is on chain. + Bonded { + /// The coin a person can look up. + coin_id: String, + /// The epoch it bonds. + epoch: i64, + /// What it locks, read from the coin rather than from this epoch's requirement — a coin + /// created under a previous requirement locks the previous amount. + amount_dig_base_units: u64, + }, + /// A create for this bond has been submitted and has not yet confirmed. + Pending, + /// The wallet cannot cover this create. + Unfunded { + /// How many more DIG base units this bond alone needs. + short_dig_base_units: u64, + }, + /// The epoch's requirement is not known, so no create may be priced. NOT an out-of-funds state. + Deferred { + /// Why the requirement is unknown, verbatim from the requirement machinery so this surface + /// cannot invent a reason of its own. + reason: CollateralUnknownReason, + }, + /// Collateralisation is switched off. The node holds the capsule and is deliberately not + /// advertising it; any coin it already had is being reclaimed. + Withheld, +} + +/// Everything a pass consults, gathered once so the decision can be taken without further I/O. +#[derive(Debug, Clone)] +pub struct PassInputs<'a> { + /// The SETTLED `Held` bonds on disk (§25.5) — what this node is willing to advertise. + pub held: &'a [Bond], + /// The mirror coins this wallet owns, from `dig_mirror_coin::list`. + pub on_chain: &'a [HeldMirror], + /// Bonds whose current-epoch create is submitted and unconfirmed (§25.4.6). + pub in_flight: &'a [Bond], + /// The epoch in force by the wall clock. + pub current_epoch: i64, + /// This epoch's requirement, or the named reason it is unknown. + pub requirement: &'a CollateralRequirementResult, + /// The local safety margin, in basis points (`collateral.json`). + pub margin_bp: u64, + /// Spendable $DIG, in base units. + pub dig_balance_base_units: u64, + /// Whether creates are enabled (§25.7). Reclaims ignore this. + pub creates_enabled: bool, +} + +/// Decide one pass. +/// +/// Pure: no clock, no chain, no wallet, no file. Every input that varies is a parameter, so a fixture +/// is a handful of literals and the answer is the same on every machine on every day. +pub fn decide(inputs: &PassInputs<'_>) -> PassDecision { + // Rule 2. OFF forces the DESIRED set empty rather than short-circuiting the pass, so the ordinary + // plan reclaims every live coin. A `return` here would freeze the locked collateral instead of + // releasing it, which is the failure that inverts the meaning of "revoke". + let desired: &[Bond] = if inputs.creates_enabled { + inputs.held + } else { + &[] + }; + + let MirrorPlan { reclaim, create } = plan( + desired, + inputs.on_chain, + inputs.current_epoch, + inputs.in_flight, + ); + + // Rule 3. The requirement is consulted only to PRICE creates. Note it is read after the plan is + // taken, never before it: nothing about an unknown price may reach the reclaim list. + let per_coin = match inputs.requirement { + CollateralRequirementResult::Known { + required_per_store_dig_base_units, + .. + } => Some(apply_safety_margin( + *required_per_store_dig_base_units, + inputs.margin_bp, + )), + CollateralRequirementResult::Unknown { .. } => None, + }; + + let (affordable, split) = match per_coin { + Some(per_coin) => { + let split = super::plan::split_by_funds( + &create, + inputs.dig_balance_base_units, + per_coin, + ); + (split.affordable.clone(), Some(split)) + } + None => (Vec::new(), None), + }; + + let states = bond_states(inputs, &affordable, split.as_ref(), per_coin); + + PassDecision { + reclaim, + create: affordable, + per_coin_dig_base_units: per_coin, + states, + } +} + +/// One state per HELD bond — what the node would say about each if asked right now. +/// +/// Keyed on what is on disk rather than on what the plan produced, because a bond the plan had +/// nothing to do about is exactly the one whose state a person most wants ("it is bonded") and the +/// one a plan-derived list would omit entirely. +fn bond_states( + inputs: &PassInputs<'_>, + affordable: &[Bond], + split: Option<&FundingSplit>, + per_coin: Option, +) -> Vec<(Bond, BondState)> { + let mut states: Vec<(Bond, BondState)> = Vec::new(); + + for bond in inputs.held { + // The chain first: a coin that exists outranks every reason a coin might not. + let coin = inputs + .on_chain + .iter() + .find(|c| c.epoch == inputs.current_epoch && c.store_id == bond.store_id && c.root == bond.root); + + let state = if let Some(coin) = coin { + BondState::Bonded { + coin_id: coin.coin_id.clone(), + epoch: coin.epoch, + amount_dig_base_units: coin.collateral_dig_base_units, + } + } else if !inputs.creates_enabled { + BondState::Withheld + } else if inputs.in_flight.contains(bond) { + BondState::Pending + } else { + match (per_coin, split) { + (Some(per_coin), Some(split)) => { + if affordable.contains(bond) { + // Selected for this pass but not yet submitted. `Pending` is the honest + // reading: the node has decided to make it and nothing is short. + BondState::Pending + } else if split.short.contains(bond) { + BondState::Unfunded { + short_dig_base_units: per_coin, + } + } else { + // Neither affordable nor short: the plan did not want a create, which at this + // point can only mean a duplicate entry already accounted for. + BondState::Pending + } + } + _ => BondState::Deferred { + reason: unknown_reason(inputs.requirement), + }, + } + }; + + states.push((bond.clone(), state)); + } + + states.sort_by(|a, b| a.0.cmp(&b.0)); + states.dedup_by(|a, b| a.0 == b.0); + states +} + +/// The reason a requirement is unknown, for the surface. +/// +/// A `Known` requirement can never reach here — [`decide`] only asks once `per_coin` is `None` — so +/// the fallback is unreachable in practice. It is `NotCensused` rather than a panic because a state +/// surface that aborts the pass is worse than one that names the most common cause. +fn unknown_reason(requirement: &CollateralRequirementResult) -> CollateralUnknownReason { + match requirement { + CollateralRequirementResult::Unknown { reason } => *reason, + CollateralRequirementResult::Known { .. } => CollateralUnknownReason::NotCensused, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) + } + + fn coin(tag: &str, store: &str, root: &str, epoch: i64, amount: u64) -> HeldMirror { + HeldMirror { + coin_id: id(tag), + store_id: id(store), + root: id(root), + epoch, + collateral_dig_base_units: amount, + } + } + + const NOW_EPOCH: i64 = 100; + /// The schedule's starting requirement, PRE-margin: 1.000 DIG. + const REQUIRED: u64 = 1_000; + + fn known() -> CollateralRequirementResult { + CollateralRequirementResult::Known { + epoch: NOW_EPOCH as u64, + protocol_version: 1, + required_per_store_dig_base_units: REQUIRED, + stores: 1, + owners: 1, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + } + } + + fn unknown(reason: CollateralUnknownReason) -> CollateralRequirementResult { + CollateralRequirementResult::Unknown { reason } + } + + /// A fully funded, switched-on node with the requirement known — the baseline every hostile + /// fixture below varies exactly one field of. + fn inputs<'a>( + held: &'a [Bond], + on_chain: &'a [HeldMirror], + requirement: &'a CollateralRequirementResult, + ) -> PassInputs<'a> { + PassInputs { + held, + on_chain, + in_flight: &[], + current_epoch: NOW_EPOCH, + requirement, + margin_bp: 0, + dig_balance_base_units: 1_000_000, + creates_enabled: true, + } + } + + #[test] + fn a_held_capsule_on_a_funded_node_is_created_at_the_margined_requirement() { + let held = [bond("aa", "11")]; + let req = known(); + let mut i = inputs(&held, &[], &req); + i.margin_bp = 500; // +5% + + let d = decide(&i); + + assert_eq!(d.create, vec![bond("aa", "11")]); + assert_eq!( + d.per_coin_dig_base_units, + Some(apply_safety_margin(REQUIRED, 500)), + "the amount is the margined requirement, not the bare one and not a constant" + ); + assert_ne!( + d.per_coin_dig_base_units, + Some(REQUIRED), + "a 5% margin must actually change the figure, or the margin is being ignored" + ); + } + + /// Rule 3: an unknown requirement defers creates and never touches reclaims. + /// + /// The fixture carries BOTH a bond needing a create and a coin needing a reclaim. Without the + /// coin, an implementation that returned an empty decision on an unknown requirement would pass + /// — and that implementation strands collateral for as long as the census is behind. + #[test] + fn an_unknown_requirement_defers_creates_and_still_reclaims() { + let held = [bond("aa", "11")]; + let req = unknown(CollateralUnknownReason::BehindFinalityDepth); + let on_chain = [coin("gone", "bb", "22", NOW_EPOCH, REQUIRED)]; + let i = inputs(&held, &on_chain, &req); + + let d = decide(&i); + + assert!(d.create.is_empty(), "no create may be priced"); + assert_eq!(d.per_coin_dig_base_units, None, "and no amount is guessed"); + assert_eq!( + d.reclaim, + vec![( + coin("gone", "bb", "22", NOW_EPOCH, REQUIRED), + ReclaimReason::NoLongerHeld + )], + "a reclaim's amount comes from its own coin, so it needs no requirement" + ); + assert_eq!( + d.states, + vec![( + bond("aa", "11"), + BondState::Deferred { + reason: CollateralUnknownReason::BehindFinalityDepth + } + )], + "and the reason is reported verbatim, not as an out-of-funds alarm" + ); + } + + /// Rule 1: a wallet at zero still reclaims, and reports the shortfall rather than the deferral. + /// + /// The two "no coin yet" states must stay distinguishable — this asserts `Unfunded`, and the + /// test above asserts `Deferred`, on fixtures identical but for the one field. + #[test] + fn a_wallet_at_zero_reclaims_and_reports_unfunded_not_deferred() { + let held = [bond("aa", "11")]; + let req = known(); + let on_chain = [coin("gone", "bb", "22", NOW_EPOCH, REQUIRED)]; + let mut i = inputs(&held, &on_chain, &req); + i.dig_balance_base_units = 0; + + let d = decide(&i); + + assert!(d.create.is_empty()); + assert_eq!(d.reclaim.len(), 1, "a reclaim is never gated on the balance"); + assert_eq!( + d.states, + vec![( + bond("aa", "11"), + BondState::Unfunded { + short_dig_base_units: REQUIRED + } + )] + ); + } + + /// Rule 2: the switch OFF releases what is locked rather than freezing it. + /// + /// Two live coins go back and nothing is created. The `Withheld` state is asserted too, because + /// a node that reported `Unfunded` while switched off would send a person hunting for money they + /// do not need. + #[test] + fn switching_creates_off_reclaims_every_live_coin_and_creates_none() { + let held = [bond("aa", "11"), bond("bb", "22")]; + let req = known(); + let on_chain = [ + coin("c1", "aa", "11", NOW_EPOCH, REQUIRED), + coin("c2", "bb", "22", NOW_EPOCH, REQUIRED), + ]; + let mut i = inputs(&held, &on_chain, &req); + i.creates_enabled = false; + + let d = decide(&i); + + assert!(d.create.is_empty(), "the switch gates creates"); + assert_eq!( + d.reclaim.len(), + 2, + "and OFF must RELEASE the locked collateral, not freeze it: {d:?}" + ); + assert!(d + .reclaim + .iter() + .all(|(_, why)| *why == ReclaimReason::NoLongerHeld)); + assert!(d + .states + .iter() + .all(|(_, s)| *s == BondState::Withheld)); + } + + /// The switch OFF must still reclaim a PRIOR epoch's coin, which is a different plan row. + /// + /// Recorded separately because the two reclaim reasons come from different branches, and an + /// implementation that emptied the desired set but skipped the epoch comparison would leave last + /// epoch's money locked forever on a node whose owner had switched the feature off. + #[test] + fn switching_creates_off_also_reclaims_a_prior_epochs_coin() { + let held = [bond("aa", "11")]; + let req = known(); + let on_chain = [coin("old", "aa", "11", NOW_EPOCH - 1, REQUIRED)]; + let mut i = inputs(&held, &on_chain, &req); + i.creates_enabled = false; + + let d = decide(&i); + assert_eq!( + d.reclaim, + vec![( + coin("old", "aa", "11", NOW_EPOCH - 1, REQUIRED), + ReclaimReason::EpochEnded + )] + ); + } + + /// A bond already on chain reports the amount ITS OWN COIN locks, not this epoch's requirement. + /// + /// The fixture deliberately makes the two differ: a coin created when the requirement was 1.000 + /// DIG, read in an epoch whose requirement has risen to 2.500. Reporting the current requirement + /// would tell a person they have more locked than they do, and there is no fixture where the two + /// are equal that could tell the difference. + #[test] + fn a_bonded_coin_reports_what_it_locks_rather_than_todays_requirement() { + let held = [bond("aa", "11")]; + let req = CollateralRequirementResult::Known { + required_per_store_dig_base_units: 2_500, + ..known() + }; + let on_chain = [coin("c1", "aa", "11", NOW_EPOCH, 1_000)]; + let i = inputs(&held, &on_chain, &req); + + let d = decide(&i); + + assert_eq!( + d.states, + vec![( + bond("aa", "11"), + BondState::Bonded { + coin_id: id("c1"), + epoch: NOW_EPOCH, + amount_dig_base_units: 1_000, + } + )] + ); + assert!(d.create.is_empty()); + } + + /// A create in flight reports `Pending` rather than `Unfunded`, even on a wallet that could not + /// afford a second one. Without this, a node mid-confirmation looks broke. + #[test] + fn an_in_flight_create_reports_pending_on_an_empty_wallet() { + let held = [bond("aa", "11")]; + let req = known(); + let in_flight = [bond("aa", "11")]; + let mut i = inputs(&held, &[], &req); + i.in_flight = &in_flight; + i.dig_balance_base_units = 0; + + let d = decide(&i); + + assert!(d.create.is_empty(), "it is already in flight"); + assert_eq!(d.states, vec![(bond("aa", "11"), BondState::Pending)]); + } + + /// A partially funded node creates the affordable prefix and reports the rest as short — the two + /// halves in one decision, so the states and the create list cannot drift apart. + #[test] + fn a_partially_funded_node_creates_a_prefix_and_reports_the_rest_short() { + let held = [bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; + let req = known(); + let mut i = inputs(&held, &[], &req); + i.dig_balance_base_units = 2 * REQUIRED; + + let d = decide(&i); + + assert_eq!(d.create, vec![bond("aa", "11"), bond("bb", "22")]); + assert_eq!( + d.states.last().map(|(_, s)| s.clone()), + Some(BondState::Unfunded { + short_dig_base_units: REQUIRED + }), + "the third bond is the one that did not fit" + ); + } +} diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs index da1b2042..8e6aec29 100644 --- a/crates/dig-node-service/src/mirror/plan.rs +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -599,7 +599,10 @@ mod tests { #[test] fn a_raised_requirement_funds_fewer_bonds_from_the_same_balance() { let creates = vec![bond("aa", "11"), bond("bb", "22")]; - let balance = 2 * PER_COIN; + // Chosen so the SAME balance lands on a different side of each requirement: 2.500 DIG buys + // two bonds at 1.000 each and exactly one at 2.500. A balance that funded the same count at + // both amounts could not distinguish a parameter from a constant. + let balance = PER_COIN_RAISED; let at_start = split_by_funds(&creates, balance, PER_COIN); assert_eq!(at_start.affordable.len(), 2, "both bonds fit at 1.000 DIG each"); From 14a18d6c9e9eeb75927891ea0e2a0b2b32bdc173 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 18:42:08 -0700 Subject: [PATCH 06/10] fix(mirror): the pass module compiles, and OFF stops hiding live locked coins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the untracked pass runner needed before it could be believed. `pass.rs` imported `CollateralRequirementResult` and `CollateralUnknownReason` through `crate::collateral`, where they are private re-imports -- E0603, the lib did not build at all. They now come from their owner, `dig_node_control_interface::results`, which is also the contract §25.8's surface serves, so there is one definition rather than a local alias that can drift. `known()` returned an enum VARIANT and one fixture updated it with `..known()`, which Rust does not allow on an enum. Split into `known_at(required)`. The switch-off fixture asserted every state was `Withheld` while two coins were live on chain, and the code reported `Bonded`. The CODE is right and the fixture was wrong: a coin that has not yet been reclaimed is still locking money and is still penalisable, so a person told `Withheld` about it can see neither. The fixture now carries a third bond with NO coin -- the one the switch genuinely withholds -- which makes the assertion about PLACEMENT rather than outcome: a switch-first implementation reports all three as `Withheld` and hides two live locked coins, and this fixture is red against it where a two-coin fixture is not. Also records, at `BondState`, that §25.8 gives `withheld` a narrower meaning (`Relayed` provenance) and names a `reclaiming` state this enum lacks. Step 6 reconciles that at the interface rather than by renaming here. Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/pass.rs | 74 +++++++++++++++++----- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 83fb7e28..88b18d3c 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -31,7 +31,10 @@ use dig_mirror_collateral::margin::apply_safety_margin; -use crate::collateral::{CollateralRequirementResult, CollateralUnknownReason}; +// From the control interface's published contract rather than re-exported through +// `crate::collateral`: these are the same types the §25.8 surface serves, and naming their +// owner keeps one definition rather than a local alias that could drift from it. +use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; use super::plan::{plan, Bond, FundingSplit, HeldMirror, MirrorPlan, ReclaimReason}; @@ -85,6 +88,13 @@ pub enum BondState { }, /// Collateralisation is switched off. The node holds the capsule and is deliberately not /// advertising it; any coin it already had is being reclaimed. + /// + /// NOTE for step 6, which serves `SPEC.md` §25.8 over the control plane: that subsection is + /// still marked PENDING and its vocabulary does not yet line up with this enum. §25.8 gives + /// `withheld` a NARROWER meaning (`Relayed` provenance — a capsule this node holds but does not + /// claim to serve) and lists a `reclaiming` state this enum has no variant for. Serving these + /// variants under those names without reconciling them would publish a contract whose words mean + /// something else. Reconcile at the interface, not by quietly renaming here. Withheld, } @@ -270,10 +280,18 @@ mod tests { const REQUIRED: u64 = 1_000; fn known() -> CollateralRequirementResult { + known_at(REQUIRED) + } + + /// A `Known` requirement at an arbitrary per-store figure. + /// + /// Spelled as a function rather than `..known()` because `Known` is an enum VARIANT, and + /// functional-update syntax does not apply to one. + fn known_at(required_per_store_dig_base_units: u64) -> CollateralRequirementResult { CollateralRequirementResult::Known { epoch: NOW_EPOCH as u64, protocol_version: 1, - required_per_store_dig_base_units: REQUIRED, + required_per_store_dig_base_units, stores: 1, owners: 1, multiplier_micros: 1_000_000, @@ -389,14 +407,23 @@ mod tests { ); } - /// Rule 2: the switch OFF releases what is locked rather than freezing it. + /// Rule 2: the switch OFF releases what is locked rather than freezing it — and says so + /// truthfully while the release is still in flight. /// - /// Two live coins go back and nothing is created. The `Withheld` state is asserted too, because - /// a node that reported `Unfunded` while switched off would send a person hunting for money they - /// do not need. + /// The fixture carries THREE bonds under OFF: two with a live coin, one without. That third + /// bond is what makes the assertion about placement rather than about outcome. A `Bonded` coin + /// is a fact of the chain and it outranks the switch, because the money is still locked and + /// still penalisable until the reclaim confirms; a person told `Withheld` about a coin that + /// exists cannot see either. But a bond with NO coin under OFF is genuinely withheld, and + /// reporting it as `Unfunded` or `Deferred` would send that person hunting for money they do + /// not need. + /// + /// A switch-first implementation — one that checked `creates_enabled` before the chain — reports + /// all three as `Withheld` and hides two live locked coins. This fixture is red against it; + /// a two-bond fixture with coins on both is not. #[test] fn switching_creates_off_reclaims_every_live_coin_and_creates_none() { - let held = [bond("aa", "11"), bond("bb", "22")]; + let held = [bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; let req = known(); let on_chain = [ coin("c1", "aa", "11", NOW_EPOCH, REQUIRED), @@ -417,10 +444,30 @@ mod tests { .reclaim .iter() .all(|(_, why)| *why == ReclaimReason::NoLongerHeld)); - assert!(d - .states - .iter() - .all(|(_, s)| *s == BondState::Withheld)); + + let state = |store: &str, root: &str| { + d.states + .iter() + .find(|(b, _)| *b == bond(store, root)) + .map(|(_, s)| s.clone()) + .expect("every held bond is reported") + }; + + assert_eq!( + state("aa", "11"), + BondState::Bonded { + coin_id: id("c1"), + epoch: NOW_EPOCH, + amount_dig_base_units: REQUIRED, + }, + "a coin that is still on chain is still locking money, switch or no switch" + ); + assert!(matches!(state("bb", "22"), BondState::Bonded { .. })); + assert_eq!( + state("cc", "33"), + BondState::Withheld, + "the bond with no coin is the one the switch actually withholds" + ); } /// The switch OFF must still reclaim a PRIOR epoch's coin, which is a different plan row. @@ -455,10 +502,7 @@ mod tests { #[test] fn a_bonded_coin_reports_what_it_locks_rather_than_todays_requirement() { let held = [bond("aa", "11")]; - let req = CollateralRequirementResult::Known { - required_per_store_dig_base_units: 2_500, - ..known() - }; + let req = known_at(2_500); let on_chain = [coin("c1", "aa", "11", NOW_EPOCH, 1_000)]; let i = inputs(&held, &on_chain, &req); From 9d601762b97b9a306fe84a448ad1dbca912a444a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 18:52:36 -0700 Subject: [PATCH 07/10] fix(mirror): the dig-wallet test target did not compile, so its guards never ran Found by running clippy with --all-targets, which the lane had not done. Two tests the previous commit's message cites as evidence had NEVER EXECUTED, because `crates/dig-wallet/src/sage/rpc.rs`'s and `operator_wallet.rs`'s test target failed to build: error[E0603]: module `derive_keys` is private `chia_bls::derive_keys` is a private module in 0.36 whose contents are re-exported at the crate root, and `derive_synthetic` comes from the `DeriveSynthetic` trait in chia-puzzle-types, not from chia-bls at all. So both `the_operator_address_is_the_standard_chia_hd_address_for_the_phrase` -- the independent re-derivation that holds the recovery property -- and `opening_the_operator_wallet_installs_no_signer_on_the_general_surface` -- the assertion that wiring the mirror signer does not switch default-on auto-tipping live -- were absent rather than green. Both now compile and pass. A test that does not compile is not a weaker test than one that fails; it is indistinguishable from one that was never written, and `cargo test -p --lib ` reports nothing about a target it cannot build. Also `split_by_funds` uses `checked_div` rather than a hand-rolled zero check (clippy::manual_checked_ops), keeping the WHY of the zero case in a comment, and `presence.rs`'s single-element fixtures use `slice::from_ref`. Version 0.169.0: a new capability, no existing behaviour changed. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/dig-node-service/src/mirror/pass.rs | 20 ++-- crates/dig-node-service/src/mirror/plan.rs | 95 +++++++++++-------- .../dig-node-service/src/mirror/presence.rs | 19 ++-- crates/dig-node-service/src/mirror/signer.rs | 4 +- crates/dig-wallet/src/operator_wallet.rs | 14 ++- crates/dig-wallet/src/sage/rpc.rs | 17 ++-- 8 files changed, 95 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4acbea6c..43602c4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.168.0" +version = "0.169.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 7df802a0..ed193b19 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.168.0" +version = "0.169.0" # 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-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 88b18d3c..d18925c2 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -155,11 +155,8 @@ pub fn decide(inputs: &PassInputs<'_>) -> PassDecision { let (affordable, split) = match per_coin { Some(per_coin) => { - let split = super::plan::split_by_funds( - &create, - inputs.dig_balance_base_units, - per_coin, - ); + let split = + super::plan::split_by_funds(&create, inputs.dig_balance_base_units, per_coin); (split.affordable.clone(), Some(split)) } None => (Vec::new(), None), @@ -190,10 +187,9 @@ fn bond_states( for bond in inputs.held { // The chain first: a coin that exists outranks every reason a coin might not. - let coin = inputs - .on_chain - .iter() - .find(|c| c.epoch == inputs.current_epoch && c.store_id == bond.store_id && c.root == bond.root); + let coin = inputs.on_chain.iter().find(|c| { + c.epoch == inputs.current_epoch && c.store_id == bond.store_id && c.root == bond.root + }); let state = if let Some(coin) = coin { BondState::Bonded { @@ -395,7 +391,11 @@ mod tests { let d = decide(&i); assert!(d.create.is_empty()); - assert_eq!(d.reclaim.len(), 1, "a reclaim is never gated on the balance"); + assert_eq!( + d.reclaim.len(), + 1, + "a reclaim is never gated on the balance" + ); assert_eq!( d.states, vec![( diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs index 8e6aec29..718609b3 100644 --- a/crates/dig-node-service/src/mirror/plan.rs +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -228,11 +228,12 @@ pub fn split_by_funds(create: &[Bond], balance_dig_base_units: u64, per_coin: u6 // A zero per-coin collateral would make every bond free and the split meaningless. The crate // refuses a zero-collateral mirror anyway, so treat it as "nothing is affordable" rather than // dividing by it and reporting infinite capacity. - let affordable_count = if per_coin == 0 { - 0 - } else { - ((balance_dig_base_units / per_coin) as usize).min(create.len()) - }; + // `checked_div` is `None` at a zero requirement, and zero affordable is the right reading of + // it: a zero-collateral mirror would make every bond free, and the crate refuses to create + // one anyway. Reporting infinite capacity instead would plan creates that cannot be made. + let affordable_count = balance_dig_base_units + .checked_div(per_coin) + .map_or(0, |n| (n as usize).min(create.len())); let (affordable, short) = create.split_at(affordable_count); FundingSplit { @@ -330,7 +331,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("c2", "bb", "22", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + vec![( + coin("c2", "bb", "22", NOW_EPOCH), + ReclaimReason::NoLongerHeld + )], "only the coin whose capsule is gone may be reclaimed" ); assert!( @@ -356,7 +360,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("old", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)] + vec![( + coin("old", "aa", "11", NOW_EPOCH - 1), + ReclaimReason::EpochEnded + )] ); assert_eq!( plan.create, @@ -382,7 +389,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("old", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)] + vec![( + coin("old", "aa", "11", NOW_EPOCH - 1), + ReclaimReason::EpochEnded + )] ); assert!( plan.create.is_empty(), @@ -409,7 +419,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("past", "aa", "11", NOW_EPOCH - 1), ReclaimReason::EpochEnded)], + vec![( + coin("past", "aa", "11", NOW_EPOCH - 1), + ReclaimReason::EpochEnded + )], "a coin from the future must survive a slow local clock" ); assert_eq!( @@ -455,7 +468,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)] + vec![( + coin("c1", "aa", "11", NOW_EPOCH), + ReclaimReason::NoLongerHeld + )] ); assert_eq!(plan.create, vec![bond("aa", "22")]); } @@ -495,7 +511,12 @@ mod tests { /// saw both a legacy and a migrated artifact must not pay 40 $DIG for one advertisement. #[test] fn a_bond_listed_twice_on_disk_is_created_once() { - let plan = plan(&[bond("aa", "11"), bond("aa", "11")], &[], NOW_EPOCH, NOT_IN_FLIGHT); + let plan = plan( + &[bond("aa", "11"), bond("aa", "11")], + &[], + NOW_EPOCH, + NOT_IN_FLIGHT, + ); assert_eq!(plan.create, vec![bond("aa", "11")]); } @@ -522,11 +543,7 @@ mod tests { #[test] fn a_fully_funded_wallet_creates_everything_and_is_short_nothing() { let creates = vec![bond("aa", "11"), bond("bb", "22")]; - let split = split_by_funds( - &creates, - 2 * PER_COIN, - PER_COIN, - ); + let split = split_by_funds(&creates, 2 * PER_COIN, PER_COIN); assert!(split.is_funded()); assert_eq!(split.affordable, creates); @@ -539,21 +556,13 @@ mod tests { fn the_collateral_bound_is_exact_in_both_directions() { let creates = vec![bond("aa", "11")]; - let at_bound = split_by_funds( - &creates, - PER_COIN, - PER_COIN, - ); + let at_bound = split_by_funds(&creates, PER_COIN, PER_COIN); assert!( at_bound.is_funded(), "exactly one requirement's worth funds exactly one coin" ); - let one_under = split_by_funds( - &creates, - PER_COIN - 1, - PER_COIN, - ); + let one_under = split_by_funds(&creates, PER_COIN - 1, PER_COIN); assert!( !one_under.is_funded(), "one mojo short must not collateralise anything" @@ -605,7 +614,11 @@ mod tests { let balance = PER_COIN_RAISED; let at_start = split_by_funds(&creates, balance, PER_COIN); - assert_eq!(at_start.affordable.len(), 2, "both bonds fit at 1.000 DIG each"); + assert_eq!( + at_start.affordable.len(), + 2, + "both bonds fit at 1.000 DIG each" + ); let raised = split_by_funds(&creates, balance, PER_COIN_RAISED); assert_eq!( @@ -623,11 +636,7 @@ mod tests { #[test] fn a_partially_funded_wallet_stops_at_the_first_unaffordable_create() { let creates = vec![bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; - let split = split_by_funds( - &creates, - 2 * PER_COIN, - PER_COIN, - ); + let split = split_by_funds(&creates, 2 * PER_COIN, PER_COIN); assert_eq!(split.affordable, vec![bond("aa", "11"), bond("bb", "22")]); assert_eq!(split.short, vec![bond("cc", "33")]); @@ -644,10 +653,7 @@ mod tests { assert!(split.affordable.is_empty()); assert_eq!(split.short, creates); - assert_eq!( - split.shortfall_dig_base_units, - 2 * PER_COIN - ); + assert_eq!(split.shortfall_dig_base_units, 2 * PER_COIN); } /// An empty wallet with coins to reclaim still reclaims them. This is the assertion that makes @@ -655,12 +661,20 @@ mod tests { /// advertise nor recover at zero balance, and that is what stranded the money. #[test] fn a_wallet_at_zero_still_reclaims_what_it_already_locked() { - let plan = plan(&[], &[coin("c1", "aa", "11", NOW_EPOCH)], NOW_EPOCH, NOT_IN_FLIGHT); + let plan = plan( + &[], + &[coin("c1", "aa", "11", NOW_EPOCH)], + NOW_EPOCH, + NOT_IN_FLIGHT, + ); let split = split_by_funds(&plan.create, 0, PER_COIN); assert_eq!( plan.reclaim, - vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + vec![( + coin("c1", "aa", "11", NOW_EPOCH), + ReclaimReason::NoLongerHeld + )], "reclaim must not be gated on the balance" ); assert!(split.affordable.is_empty()); @@ -706,7 +720,10 @@ mod tests { assert_eq!( plan.reclaim, - vec![(coin("c1", "aa", "11", NOW_EPOCH), ReclaimReason::NoLongerHeld)], + vec![( + coin("c1", "aa", "11", NOW_EPOCH), + ReclaimReason::NoLongerHeld + )], "a reclaim is never gated on an unrelated create being in flight" ); assert!(plan.create.is_empty()); diff --git a/crates/dig-node-service/src/mirror/presence.rs b/crates/dig-node-service/src/mirror/presence.rs index ab6e773b..0ff4c92f 100644 --- a/crates/dig-node-service/src/mirror/presence.rs +++ b/crates/dig-node-service/src/mirror/presence.rs @@ -206,9 +206,9 @@ mod tests { let steady = bond("aa", "11"); let flapping = bond("bb", "22"); - tracker.observe(&[steady.clone()], T0, WINDOW); + tracker.observe(std::slice::from_ref(&steady), T0, WINDOW); tracker.observe(&[steady.clone(), flapping.clone()], T0 + 1_000, WINDOW); - let settled = tracker.observe(&[steady.clone()], T0 + WINDOW, WINDOW); + let settled = tracker.observe(std::slice::from_ref(&steady), T0 + WINDOW, WINDOW); assert_eq!( settled, @@ -225,19 +225,22 @@ mod tests { let mut tracker = PresenceTracker::new(); let b = bond("aa", "11"); - tracker.observe(&[b.clone()], T0, WINDOW); - assert_eq!(tracker.observe(&[b.clone()], T0 + WINDOW, WINDOW), vec![b.clone()]); + tracker.observe(std::slice::from_ref(&b), T0, WINDOW); + assert_eq!( + tracker.observe(std::slice::from_ref(&b), T0 + WINDOW, WINDOW), + vec![b.clone()] + ); // Gone for one observation, back for the next, both inside a fresh window. tracker.observe(&[], T0 + WINDOW + 1_000, WINDOW); - let settled = tracker.observe(&[b.clone()], T0 + WINDOW + 2_000, WINDOW); + let settled = tracker.observe(std::slice::from_ref(&b), T0 + WINDOW + 2_000, WINDOW); assert!( settled.is_empty(), "the capsule restarts its window rather than staying settled through a gap" ); assert_eq!( - tracker.observe(&[b.clone()], T0 + 2 * WINDOW + 2_000, WINDOW), + tracker.observe(std::slice::from_ref(&b), T0 + 2 * WINDOW + 2_000, WINDOW), vec![b], "and settles again once it has been stably present for a full window" ); @@ -258,7 +261,7 @@ mod tests { ); assert_eq!( - tracker.observe(&[staying.clone()], T0 + 2 * WINDOW, WINDOW), + tracker.observe(std::slice::from_ref(&staying), T0 + 2 * WINDOW, WINDOW), vec![staying], "the departed capsule is no longer held, which is what drives its reclaim" ); @@ -271,7 +274,7 @@ mod tests { let mut tracker = PresenceTracker::new(); let b = bond("aa", "11"); - tracker.observe(&[b.clone()], T0, WINDOW); + tracker.observe(std::slice::from_ref(&b), T0, WINDOW); tracker.observe(&[], T0 + WINDOW, WINDOW); tracker.observe(&[], T0 + 3 * WINDOW, WINDOW); diff --git a/crates/dig-node-service/src/mirror/signer.rs b/crates/dig-node-service/src/mirror/signer.rs index 4381bebe..323527e6 100644 --- a/crates/dig-node-service/src/mirror/signer.rs +++ b/crates/dig-node-service/src/mirror/signer.rs @@ -146,9 +146,7 @@ impl MirrorSigner { #[cfg(test)] mod tests { use super::*; - use crate::spend_audit::{ - kinds, Asset, Authority, SpendIntent, SpendJournal, SpendLog, - }; + use crate::spend_audit::{kinds, Asset, Authority, SpendIntent, SpendJournal, SpendLog}; const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index f6067575..ead683f1 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -121,8 +121,13 @@ abandon abandon abandon art"; /// nothing — which is the whole property this test exists to hold. #[test] fn the_operator_address_is_the_standard_chia_hd_address_for_the_phrase() { - use chia_bls::{derive_keys::master_to_wallet_unhardened, DerivableKey, SecretKey}; - use chia_puzzle_types::standard::StandardArgs; + // `derive_keys` is a private module in chia-bls 0.36; its contents are re-exported at the + // crate root, which is the supported path. + // Both re-exported at their crate roots; `derive_keys` and `derive_synthetic` are private + // modules. `DeriveSynthetic` is the trait that carries `derive_synthetic`, and it lives in + // chia-puzzle-types rather than chia-bls. + use chia_bls::{master_to_wallet_unhardened, SecretKey}; + use chia_puzzle_types::{standard::StandardArgs, DeriveSynthetic}; let wallet = OperatorWallet::from_phrase(PHRASE, agg_sig_data()) .expect("a valid 24-word phrase derives"); @@ -131,9 +136,8 @@ abandon abandon abandon art"; let seed = mnemonic.to_seed(""); let master = SecretKey::from_seed(&seed); let expected_sk = master_to_wallet_unhardened(&master, 0).derive_synthetic(); - let expected_ph = Bytes32::from( - StandardArgs::curry_tree_hash(expected_sk.public_key()).to_bytes(), - ); + let expected_ph = + Bytes32::from(StandardArgs::curry_tree_hash(expected_sk.public_key()).to_bytes()); assert_eq!( wallet.owner_puzzle_hash(), diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 0760eae6..4866df46 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -4909,11 +4909,9 @@ mod tests { "baseline: a backend with nothing installed cannot sign" ); - let operator = crate::operator_wallet::OperatorWallet::from_phrase( - PHRASE, - Bytes32::from([7u8; 32]), - ) - .expect("the operator wallet really does open"); + let operator = + crate::operator_wallet::OperatorWallet::from_phrase(PHRASE, Bytes32::from([7u8; 32])) + .expect("the operator wallet really does open"); assert_ne!( operator.owner_puzzle_hash(), Bytes32::default(), @@ -4925,12 +4923,9 @@ mod tests { "the operator wallet is held by its caller and installed on nothing" ); - let installed = backend_with(vec![], true) - .await - .with_signer(Arc::new(crate::sage::spend::WalletSigner::new( - vec![], - Bytes32::from([7u8; 32]), - ))); + let installed = backend_with(vec![], true).await.with_signer(Arc::new( + crate::sage::spend::WalletSigner::new(vec![], Bytes32::from([7u8; 32])), + )); assert!( installed.current_signer().is_some(), "a backend CAN hold a signer, so the `None` above is a measurement and not a tautology" From 626e270e3fe3d8912e4bf2a89b1bd7faf659a2d5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 18:56:20 -0700 Subject: [PATCH 08/10] feat(mirror): the collateralisation switch, default-on and reclaiming when off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC.md §25.7's switch, as a persisted field on `CollateralConfig` (`collateral.json`, beside the safety margin). It gates CREATES only: the pass already forces the desired bond set empty when off, so the ordinary plan reclaims every live coin. A switch that also stopped reclaims would strand the user's $DIG behind their own decision to stop. `default = "default_mirror_enabled"` rather than `#[serde(default)]`, because `bool`'s Default is `false` and an upgraded node whose config predates the field would then stop advertising every store it serves while reporting no error -- declining to collateralise is a legitimate state, so nothing downstream would notice. The test's fixture is an EMPTY JSON object for exactly that reason: it is what an upgraded node has on disk, and it is the only input that distinguishes the two attributes. Its second half is the control, that an explicit `false` is still honoured. The two in-crate struct literals now spread `..default()` rather than naming every field, which is the same reasoning `control.rs:3419` already records. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 55 ++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 0c4ba43d..011d23cc 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -74,12 +74,31 @@ pub struct CollateralConfig { /// inventing a decision the operator never made. #[serde(default)] pub retention_epochs: Option, + + /// Whether the mirror-coin lifecycle may CREATE bonds (`SPEC.md` §25.7). + /// + /// **It gates creates only. Reclaims run regardless**, which is why turning this off releases + /// locked collateral instead of freezing it: OFF forces the desired bond set empty, and the + /// ordinary pass then reclaims every live coin. A switch that stopped reclaims would strand the + /// user's $DIG behind their own decision to stop, which inverts the meaning of revoking. + /// + /// Default-on, and `default` rather than required, for the same reason the two fields above + /// are: a config written before this field existed expressed no preference, and a node that + /// silently stopped collateralising on upgrade would go undiscoverable without saying so. The + /// consent model that makes default-on honest is §25.7's — disclosed, bounded, fully audited, + /// one setting to turn off. + #[serde(default = "default_mirror_enabled")] + pub mirror_enabled: bool, } fn default_margin_bp() -> u64 { SAFETY_MARGIN_BP_DEFAULT } +fn default_mirror_enabled() -> bool { + true +} + impl Default for CollateralConfig { fn default() -> Self { CollateralConfig { @@ -87,6 +106,7 @@ impl Default for CollateralConfig { // Keep everything. See the field's own documentation for why this is not a tuning // choice. retention_epochs: None, + mirror_enabled: default_mirror_enabled(), } } } @@ -1575,6 +1595,36 @@ mod tests { } } + /// A config written before `mirror_enabled` existed still collateralises. + /// + /// The fixture is an EMPTY object rather than one naming the field, because that is what an + /// upgraded node actually has on disk, and it is the only input that can tell `#[serde(default)]` + /// (which would read `false`) from `default = "default_mirror_enabled"` (which reads `true`). + /// The false reading is silent: the node stops advertising every store it serves and reports no + /// error, because declining to collateralise is a legitimate state. + /// + /// The second half is the control — an explicit `false` must still be honoured, or the field is + /// not a switch at all. + #[test] + fn a_config_predating_the_switch_still_collateralises_and_an_explicit_off_is_honoured() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join(COLLATERAL_CONFIG_FILE), b"{}").expect("write"); + assert!( + CollateralConfig::load_from(dir.path()).mirror_enabled, + "an upgraded node must not silently stop advertising what it serves" + ); + + std::fs::write( + dir.path().join(COLLATERAL_CONFIG_FILE), + br#"{"mirror_enabled":false}"#, + ) + .expect("write"); + assert!( + !CollateralConfig::load_from(dir.path()).mirror_enabled, + "and an operator who turned it off stays turned off across a restart" + ); + } + #[test] fn a_config_that_expresses_no_retention_keeps_everything() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1587,8 +1637,9 @@ mod tests { // Zero is not a retention of nothing. Honouring it would delete the epoch in force. assert_eq!( CollateralConfig { + retention_epochs: Some(0), margin_bp: 0, - retention_epochs: Some(0) + ..CollateralConfig::default() } .retention(), RetentionPolicy::KeepEverything @@ -1720,7 +1771,7 @@ mod tests { // pass if the fixture used the default. CollateralConfig { margin_bp: 250, - retention_epochs: None, + ..CollateralConfig::default() } .save_to(dir.path()) .expect("save"); From 42d4975688640545ba45a28a85c8357afce22c86 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 19:38:55 -0700 Subject: [PATCH 09/10] =?UTF-8?q?fix(mirror):=20bound=20the=20fee=20on=20t?= =?UTF-8?q?he=20artifact,=20and=20mark=20=C2=A725's=20unbuilt=20half=20pen?= =?UTF-8?q?ding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fee ceiling guarded a caller's claim rather than the bundle. `MirrorSigner::sign` took `fee_mojos` as an argument independent of the fee `build_create`/`build_reclaim` had already baked into the spends, and `MirrorSpends` did not record that fee — so a reclaim built at 0.9 XCH signed cleanly when offered alongside a `0`, with no edit to `signer.rs` and nothing in `signer.rs` for a reviewer to see. That was the one of §25.2's four bounds held by a promise instead of by construction, in a module whose whole thesis is that scope is held by a type. `MirrorSpends` now carries `fee_mojos`, set by each builder from the same `fee` it hands to `dig_mirror_coin`, and `sign` reads it. The parameter is gone, so there is no number a caller can substitute: the type has no public constructor, so the only fee reachable is the one the bundle pays. This also makes the code match the `sign(&MirrorSpends, &RecordedSpend)` signature §25.2 already stated. Proven by a new integration test that builds a genuine reclaim at 900x the ceiling through the real builder. Before the change it signed, and the emitted CLVM carried `RESERVE_FEE 900000000000`; after it, refused. Two controls — at the ceiling, and a zero-fee reclaim — keep the refusal from being unconditional. §25 is also marked for what is actually built. The section is normative in the present indicative throughout while only its deciding half exists: no pass is constructed or scheduled, and nothing reaches `MirrorSigner::new`, `build_create`, `build_reclaim` or `dig_mirror_coin::list`. Rather than hunt clause by clause, §25 gains a status banner that ALLOWLISTS what code satisfies today and declares everything else pending on #412 — a list of what is missing has to be complete to be safe, and this one only has to be complete about what is present. Per-subsection banners cover §25.1, §25.2, §25.4, §25.5 and §25.7 for readers landing on an anchor. No clause was weakened or deleted. Part of #377 --- SPEC.md | 72 ++++++++- crates/dig-node-service/src/mirror/signer.rs | 30 +++- crates/dig-node-service/src/mirror/spends.rs | 23 ++- .../tests/mirror_fee_ceiling.rs | 151 ++++++++++++++++++ 4 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 crates/dig-node-service/tests/mirror_fee_ceiling.rs diff --git a/SPEC.md b/SPEC.md index 233f7cff..9e8176c2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7911,8 +7911,39 @@ every spend here is subject to; §24 is where the amount comes from. Spend const `dig-mirror-coin` — nothing in this repo assembles a mirror spend, a CAT wrapper, or a memo layout itself (SYSTEM.md §4.1). +> **IMPLEMENTATION STATUS — read this before relying on any clause in §25.** This section is +> normative in full and is written in the present indicative throughout. At this head **only the +> deciding half exists; nothing runs it.** Satisfied by code today, and *only* this: +> +> * §25.2's structural bounds on the signing authority — `MirrorSpends` and its two producers +> (`mirror/spends.rs`), `MirrorSigner::sign` and the fee ceiling (`mirror/signer.rs`), and the +> `&RecordedSpend` precondition that makes a journal entry the shape of the call. +> * §25.3's pricing rule and §25.4's step-3 planner, as **pure functions** over supplied +> observations (`mirror/plan.rs`, `mirror/pass.rs`), including §25.7's switch semantics and +> §25.9's decision directions. +> * §25.5's stability rule, as a **pure tracker** (`mirror/presence.rs`), and §25.7's persisted +> `collateral.json` switch. +> +> **Everything else in §25 is PENDING**, tracked as +> . That includes every clause under which the +> node *observes* disk or chain, *scans*, *schedules*, *spends*, *broadcasts*, *records an outcome*, +> or *reports a state*: no pass is constructed and none is scheduled, and no caller anywhere reaches +> `MirrorSigner::new`, `build_create`, `build_reclaim` or `dig_mirror_coin::list`. +> +> **A clause not named in the list above MUST be read as pending, whatever its grammatical voice.** +> The default is deliberately pending rather than satisfied: a per-clause list of what is missing has +> to be complete to be safe, and this one only has to be complete about what is *present*. + ### 25.1. The invariant +> **PENDING — the invariant is stated, and nothing maintains it at this head.** Neither side is +> observed: the disk side (`cache_list_cached()` filtered to `Held`) is not read by this module, and +> the chain side is not read at all — `dig_mirror_coin::list` appears nowhere in this repo. So a +> reader MUST NOT infer that a coin's existence tracks a `.dig`'s presence today; the biconditional +> below is the obligation discharges. The +> `Relayed`-is-never-advertised rule is the exception: it holds structurally, because the plan's +> desired set is built from `Held` bonds only. + > **A mirror coin owned by this node for the CURRENT epoch exists ⟺ the `.dig` for that > `(store, root)` is on disk with `Held` provenance.** @@ -7966,7 +7997,12 @@ The authority is bounded four ways, and every bound is stated so a reader can ch 4. **By fee source and size.** Fees are paid from XCH coins only — the crate's builders take separate fee inputs, so a fee can never shave collateral. `fee_mojos` per spend MUST come from a named constant, MUST be recorded in the audit entry, and MUST NOT exceed - `MIRROR_SPEND_FEE_CEILING_MOJOS` = 1_000_000_000 (0.001 XCH). The shipped default fee is 0; a + `MIRROR_SPEND_FEE_CEILING_MOJOS` = 1_000_000_000 (0.001 XCH). The ceiling MUST be enforced + against the fee the spends THEMSELVES pay — `MirrorSpends::fee_mojos()`, recorded by the builder + that baked it into the bundle — and MUST NOT be a separate argument to `sign`. A fee passed + alongside the artifact bounds a caller's claim about the bundle rather than the bundle, which + would make this the one of these four bounds that any caller could step around by passing a + different number. The shipped default fee is 0; a zero-fee reclaim is explicitly supported by `dig_mirror_coin::reclaim`. **The signer instance is module-private.** It is constructed at bring-up from the operator seed — @@ -7986,6 +8022,15 @@ owner key is the first derived key; its standard puzzle hash is the wallet's rec address create-change returns to. Deposits, bonds and reclaims therefore all move through ONE address the wallet already tracks. +> **PARTIALLY PENDING.** The two structural halves of this subsection ARE satisfied by code: the +> spend-shape bound (`MirrorSpends`), the fee bound (now read from the artifact, below), and the +> `&RecordedSpend` precondition. The sentence above — that the signer instance is *constructed at +> bring-up* from the operator seed — is NOT: `MirrorSigner::new` has no caller at this head, so no +> signer is constructed anywhere and the `Locked`/`Orphaned` reporting it describes does not exist. +> Nor is the audit-EXECUTION paragraph below satisfied: no entry is ever written for a mirror spend, +> no confirmation is observed, and nothing reconciles an `unresolved` or `failed` one. Both are +> tracked as . + **Every spend is audited, structurally.** The signer takes `&RecordedSpend`, whose only source is `SpendJournal::begin` (§23.3) — recording is the shape of the call. Entries carry `kind: "mirror-coin"` (`spend_audit::kinds::MIRROR_COIN`), @@ -8014,6 +8059,16 @@ locked, exactly. ### 25.4. The reconcile pass — two observations, a pure plan, reclaims first +> **PARTIALLY PENDING — step 3 only is implemented.** The pure planner *is* satisfied by code +> (`mirror/plan.rs`, `mirror/pass.rs`): given the two observations, the epoch, the requirement, the +> balance and the switch, it produces exactly the table below. **Nothing supplies it and nothing acts +> on its answer.** The observations (steps 1–2), the execution order (step 4), the funds-limited +> create loop (step 5), the audit-ledger in-flight suppression (step 6), the confirmation record, and +> the three triggers above — start-up, the `MIRROR_ROUND_LENGTH_MS` tick, and the debounced presence +> change — are NOT implemented at this head. Tracked as +> . Until it lands, a reader MUST NOT rely on any +> pass running at all. + A pass runs: at start-up (once the wallet and a chain source are available), on every round tick (`dig_constants::MIRROR_ROUND_LENGTH_MS`), and after a debounced presence change (§25.5). Each pass: @@ -8061,6 +8116,14 @@ coin. The `intended_coin_id` is recorded at submission so §23.5's reconcile acc ### 25.5. Presence and debounce +> **PARTIALLY PENDING — the debounce rule is implemented; the scanning is not.** The +> stability-across-a-window rule, in both directions, is satisfied by the pure tracker in +> `mirror/presence.rs` and its `SETTLING_WINDOW_MS`. **Nothing feeds it**: there is no periodic +> scan, no start-up scan, no watcher, and no caller of the tracker at this head — so the scanning +> cadence described below, the un-debounced start-up exemption, and the claim that the periodic pass +> is the correctness mechanism are all pending, tracked as +> . + Presence changes are detected by SCANNING, with an optional watcher as an accelerator — never the reverse. A watcher event is exactly what a crash, an unmounted volume, or an uncovered path loses; the periodic pass (§25.4) is the correctness mechanism. @@ -8102,6 +8165,13 @@ collision (the epoch term is freely chosen, so hint equality alone proves nothin ### 25.7. Consent, the switch, and revocation +> **PARTIALLY PENDING.** The switch itself is real — it persists in `collateral.json`, defaults on, +> and the planner honours it by forcing the desired bond set empty so every live coin falls into the +> reclaim set. What does NOT exist at this head is the pass that acts on that plan, so **turning +> collateralisation off today reclaims nothing, because nothing is ever created either.** The +> revocation bullet below describes the behaviour once +> lands; it is decided, not performed. + There is deliberately no per-spend approval; the standing authority is the consent model, and it is honest the same way auto-tipping (§18.23) is: **disclosed, default-on, bounded, fully audited, and one setting to turn off** (§6.0/#207). diff --git a/crates/dig-node-service/src/mirror/signer.rs b/crates/dig-node-service/src/mirror/signer.rs index 323527e6..c415897f 100644 --- a/crates/dig-node-service/src/mirror/signer.rs +++ b/crates/dig-node-service/src/mirror/signer.rs @@ -33,6 +33,14 @@ //! The crate's builders take XCH fee coins separately from the $DIG being locked, so a fee is never //! taken out of the amount advertised. [`MIRROR_SPEND_FEE_CEILING_MOJOS`] bounds the fee itself, and //! [`MirrorSigner::sign`] refuses above it rather than trusting every future caller to check. +//! +//! The ceiling is checked against [`MirrorSpends::fee_mojos`] — the fee the bundle actually pays, +//! recorded by the builder that baked it in — and NOT against a fee the caller passes alongside. +//! Those are not the same bound. A separate argument would make this the one bound of the four that +//! is a promise about a parameter rather than a property of the artifact: a caller holding a reclaim +//! built at 0.9 XCH could offer it with a `0` and be signed, with no edit to this file and nothing +//! for a reviewer of this file to see. Since the fee now travels ON the thing being signed, and +//! `MirrorSpends` has no public constructor, there is no number a caller can substitute. use chia_protocol::{Bytes32, SpendBundle}; use dig_wallet::operator_wallet::OperatorWallet; @@ -57,9 +65,9 @@ pub const MIRROR_SPEND_FEE_CEILING_MOJOS: u64 = 1_000_000_000; /// of the likeliest things to end up in a log. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SignError { - /// The requested fee exceeds [`MIRROR_SPEND_FEE_CEILING_MOJOS`]. + /// The fee these spends pay exceeds [`MIRROR_SPEND_FEE_CEILING_MOJOS`]. FeeAboveCeiling { - /// What was asked for, in XCH mojos. + /// What the spends actually pay, in XCH mojos. requested_mojos: u64, /// The ceiling it exceeded, in XCH mojos. ceiling_mojos: u64, @@ -118,13 +126,14 @@ impl MirrorSigner { /// remove the audit guarantee while changing no observable behaviour — which is exactly why it is /// spelled out here rather than left to a reviewer to notice. /// - /// Refuses a fee above [`MIRROR_SPEND_FEE_CEILING_MOJOS`] before signing anything. + /// Refuses a fee above [`MIRROR_SPEND_FEE_CEILING_MOJOS`] before signing anything. The fee read + /// is `spends`' own — see the module doc for why it is deliberately not a parameter here. pub fn sign( &self, spends: &MirrorSpends, _recorded: &RecordedSpend, - fee_mojos: u64, ) -> Result { + let fee_mojos = spends.fee_mojos(); if fee_mojos > MIRROR_SPEND_FEE_CEILING_MOJOS { return Err(SignError::FeeAboveCeiling { requested_mojos: fee_mojos, @@ -204,10 +213,12 @@ abandon abandon abandon art"; let dir = tempfile::tempdir().expect("tempdir"); let journal = journal(dir.path()); let recorded = journal.begin(intent()); - let spends = super::super::spends::empty_for_tests(); let signer = signer(); - let over = signer.sign(&spends, &recorded, MIRROR_SPEND_FEE_CEILING_MOJOS + 1); + let over = signer.sign( + &super::super::spends::empty_for_tests(MIRROR_SPEND_FEE_CEILING_MOJOS + 1), + &recorded, + ); assert_eq!( over, Err(SignError::FeeAboveCeiling { @@ -219,7 +230,10 @@ abandon abandon abandon art"; assert!( signer - .sign(&spends, &recorded, MIRROR_SPEND_FEE_CEILING_MOJOS) + .sign( + &super::super::spends::empty_for_tests(MIRROR_SPEND_FEE_CEILING_MOJOS), + &recorded + ) .is_ok(), "exactly at the ceiling is permitted, so the refusal above is not unconditional" ); @@ -247,7 +261,7 @@ abandon abandon abandon art"; assert_eq!(ledger.records[0].status.token(), "pending"); signer() - .sign(&super::super::spends::empty_for_tests(), &recorded, 0) + .sign(&super::super::spends::empty_for_tests(0), &recorded) .expect("an empty spend set signs to an empty aggregate"); } } diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index ca6c1976..7ea176e3 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -59,6 +59,7 @@ impl MirrorOperation { pub struct MirrorSpends { operation: MirrorOperation, spends: Vec, + fee_mojos: u64, } impl MirrorSpends { @@ -72,6 +73,19 @@ impl MirrorSpends { pub fn coin_spends(&self) -> &[CoinSpend] { &self.spends } + + /// The XCH fee, in mojos, that these spends actually pay. + /// + /// Recorded at build time from the same `fee` handed to the `dig_mirror_coin` builder, so it + /// describes the artifact rather than a caller's account of it. That distinction is the whole + /// value of the field: `MirrorSigner::sign` bounds THIS against + /// [`MIRROR_SPEND_FEE_CEILING_MOJOS`](super::signer::MIRROR_SPEND_FEE_CEILING_MOJOS), and a fee + /// supplied to the signer separately would have been a number about the bundle instead of the + /// bundle's own — bypassable by any caller that passed a different one, with no edit to the + /// signer. It is also the figure §25.2 requires in the audit entry. + pub fn fee_mojos(&self) -> u64 { + self.fee_mojos + } } /// Build the spends that lock `collateral_dig_base_units` of $DIG as a mirror for one `(store, root, @@ -115,6 +129,7 @@ pub fn build_create( Ok(MirrorSpends { operation: MirrorOperation::Create, spends, + fee_mojos: fee, }) } @@ -139,6 +154,7 @@ pub fn build_reclaim( Ok(MirrorSpends { operation: MirrorOperation::Reclaim, spends, + fee_mojos: fee, }) } @@ -148,10 +164,15 @@ pub fn build_reclaim( /// is the whole authority bound, and a test seam that widened it would quietly remove the thing this /// module exists to guarantee. It carries [`MirrorOperation::Create`] because a `MirrorSpends` always /// names an operation; the operation is irrelevant to an empty spend set. +/// +/// `fee_mojos` is a parameter rather than zero so a signer test can exercise the ceiling without a +/// chain. The fee is the one thing about these spends the signer reads, so a fixture that could not +/// vary it could not test the bound at all. #[cfg(test)] -pub(crate) fn empty_for_tests() -> MirrorSpends { +pub(crate) fn empty_for_tests(fee_mojos: u64) -> MirrorSpends { MirrorSpends { operation: MirrorOperation::Create, spends: Vec::new(), + fee_mojos, } } diff --git a/crates/dig-node-service/tests/mirror_fee_ceiling.rs b/crates/dig-node-service/tests/mirror_fee_ceiling.rs new file mode 100644 index 00000000..e0ef764f --- /dev/null +++ b/crates/dig-node-service/tests/mirror_fee_ceiling.rs @@ -0,0 +1,151 @@ +//! The fee ceiling bounds the ARTIFACT being signed, not a number the caller says about it. +//! +//! `SPEC.md` §25.2 states four bounds on the mirror signing authority and rests the module's whole +//! thesis on their being held by construction. Three of them are structural — the spend shape is a +//! type with no public constructor, the destination is enforced inside `dig-mirror-coin`, the audit +//! record is a required argument. The fee bound is the one that could have been a promise about a +//! parameter, and a promise about a parameter is only as good as the caller. +//! +//! So the test that matters is not "does `sign` refuse a large number" — it is "does `sign` refuse a +//! bundle that PAYS a large fee". Those are the same assertion only when the fee is read from the +//! spends. The fixture below builds a genuine reclaim at 900× the ceiling through the real builder +//! and offers it to the signer, which is exactly the shape a caller reaches for when it wants the +//! spend it already has to be signed. + +mod support; + +use chia_protocol::{Bytes32, Coin}; +use dig_mirror_coin::MirrorCoin; +use dig_node_service::mirror::signer::{MirrorSigner, SignError, MIRROR_SPEND_FEE_CEILING_MOJOS}; +use dig_node_service::mirror::spends::build_reclaim; +use dig_node_service::spend_audit::{ + kinds, Asset, Authority, RecordedSpend, SpendIntent, SpendJournal, SpendKind, SpendLog, +}; +use support::{creating_spend, mirror_memos, root_1, store_a, wallet, Wallet}; + +const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon art"; + +/// 900× the ceiling — chosen from the ceiling itself rather than picked, so the fixture cannot drift +/// under a future retune of `MIRROR_SPEND_FEE_CEILING_MOJOS` into a value that is legal after all. +const RUINOUS_FEE_MOJOS: u64 = MIRROR_SPEND_FEE_CEILING_MOJOS * 900; + +/// XCH coins this wallet owns, exactly covering `fee`. +/// +/// A non-zero fee needs somewhere to come from: `dig_mirror_coin::reclaim` assembles a fee bundle +/// only when there is a fee to pay, and one built from no coins has nowhere to emit its required +/// concurrency condition. Passing none turned an assertion about the ceiling into a build failure, +/// which is a different test. +fn fee_coins(owner: &Wallet, fee: u64) -> Vec { + if fee == 0 { + return Vec::new(); + } + vec![Coin::new(Bytes32::new([0x5E; 32]), owner.puzzle_hash, fee)] +} + +fn signer() -> MirrorSigner { + MirrorSigner::new( + dig_wallet::operator_wallet::OperatorWallet::from_phrase(PHRASE, Bytes32::from([7u8; 32])) + .expect("the fixture phrase derives an operator wallet"), + ) +} + +fn recorded(journal: &SpendJournal) -> RecordedSpend { + journal.begin(SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "reclaim a mirror coin".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: support::COLLATERAL, + fee_mojos: 0, + store_id: Some("store".to_string()), + }) +} + +/// A genuine mirror coin this wallet owns, built from a real CAT spend that is executed to produce +/// its conditions. +fn owned_mirror_coin(owner: &Wallet) -> MirrorCoin { + let memos = mirror_memos(owner, store_a(), root_1(), &["https://example.invalid"]); + let (spend, coin) = creating_spend(owner, &memos); + + MirrorCoin::from_creating_spend(&spend, coin.coin_id()) + .expect("the fixture spend decodes") + .expect("and it is a mirror coin") +} + +/// A reclaim built at a ruinous fee is refused, however the caller describes it. +/// +/// This is the finding, stated as a test. The old signature took the fee as a separate argument, so +/// a caller holding a bundle that pays 0.9 XCH could hand it over alongside a `0` and be signed — +/// the ceiling saw the argument, and the argument was not the thing being signed. +#[test] +fn a_reclaim_built_above_the_ceiling_is_refused_even_though_no_caller_says_so() { + let owner = wallet(3); + let coin = owned_mirror_coin(&owner); + + let spends = build_reclaim( + &coin, + owner.public_key, + fee_coins(&owner, RUINOUS_FEE_MOJOS), + RUINOUS_FEE_MOJOS, + ) + .expect("a reclaim at any fee builds; refusing it is the signer's job"); + + let dir = tempfile::tempdir().expect("tempdir"); + let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + + assert_eq!( + signer().sign(&spends, &recorded(&journal)), + Err(SignError::FeeAboveCeiling { + requested_mojos: RUINOUS_FEE_MOJOS, + ceiling_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS, + }), + "the ceiling must read the fee the spends actually pay" + ); +} + +/// The control: the same path at a legal fee signs. +/// +/// Without this the test above is satisfied by a signer that refuses everything, which would hold +/// the fee bound by making the module useless rather than by making it correct. +#[test] +fn the_same_reclaim_at_a_legal_fee_signs() { + let owner = wallet(3); + let coin = owned_mirror_coin(&owner); + + let spends = build_reclaim( + &coin, + owner.public_key, + fee_coins(&owner, MIRROR_SPEND_FEE_CEILING_MOJOS), + MIRROR_SPEND_FEE_CEILING_MOJOS, + ) + .expect("builds"); + + let dir = tempfile::tempdir().expect("tempdir"); + let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + + assert!( + signer().sign(&spends, &recorded(&journal)).is_ok(), + "exactly at the ceiling is permitted, so the refusal above is not unconditional" + ); +} + +/// A zero-fee reclaim signs — the case §25.4 requires to work when the wallet holds no XCH. +#[test] +fn a_zero_fee_reclaim_signs() { + let owner = wallet(3); + let coin = owned_mirror_coin(&owner); + let spends = build_reclaim(&coin, owner.public_key, fee_coins(&owner, 0), 0).expect("builds"); + + let dir = tempfile::tempdir().expect("tempdir"); + let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + + assert!( + signer().sign(&spends, &recorded(&journal)).is_ok(), + "a wallet with no XCH must still be able to recover what it has locked" + ); +} From ec01b76d230555e5b79fbce9e55d37eb7d3eca53 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 19:50:08 -0700 Subject: [PATCH 10/10] fix(mirror): make the audit record, the wallet and the fee all properties of the spends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, one disease: a value passed NEXT TO the artifact instead of read FROM it. Each was a bound §25.2 claims to hold by construction that actually rested on a caller doing the right thing. `MirrorSigner::sign` now takes the journal and opens the record itself: sign(&MirrorSpends, &SpendJournal) -> Result<(SpendBundle, RecordedSpend), SignError> - One record per signature, structurally. A `&RecordedSpend` was a shared borrow and was never consumed, so one `begin()` could back a loop of signatures -- N unattended spends accounted for as one. The signer opens its own, so it cannot. - The intent is DERIVED from the spends (`MirrorSpends::intent`), not supplied beside them. Amount, store, root, epoch and fee come from the bundle, so the record cannot name figures the spend does not move. A record that is confidently wrong is worse than none, because it is the record that buys the permission to spend unattended. - The signer refuses spends belonging to another wallet. The builders take a `synthetic_key` while the signer signs with its own and nothing related the two; §25.2's destination bound rested on call-site convention. The owner is re-derived from the create's key and read from the coin's lineage proof for a reclaim, so it is a property of the bundle either way. The failure direction was already safe -- the network rejects a mismatch -- but "the network catches it" is a different guarantee from "it cannot be built", and this module's whole thesis is the second one. Five signer tests, each with a control so none is satisfied by a signer that refuses everything: the ceiling from both sides, the wallet guard with its own spends as the control, TWO signatures asserting TWO records (asserting merely that a record exists would pass under the old shape too), and the recorded intent. The integration test carries the amount and store halves against a real bundle, which an empty spend set cannot show, and asserts a refused spend leaves no pending entry for a spend that never happened. §25.1's `Relayed`-is-never-advertised rule is marked PENDING and explicitly assigned to #412's observation step. It is prose at this head -- `plan.rs` takes the bond set it is given and carries no provenance field -- and it is the one place another party could influence what this node spends its own money on. An earlier draft of that banner claimed it held structurally; that claim was itself born-false and is corrected. Also: `SPEC.md` §25.2 now states the signature the code has, and two doc pointers fixed (a rustdoc link to the `mirror::key` module this PR deleted, and a guard-test reference naming the wrong file). Part of #377 --- SPEC.md | 33 ++- crates/dig-node-service/Cargo.toml | 4 + crates/dig-node-service/src/mirror/mod.rs | 15 +- crates/dig-node-service/src/mirror/signer.rs | 205 ++++++++++++------ crates/dig-node-service/src/mirror/spends.rs | 88 +++++++- .../tests/mirror_fee_ceiling.rs | 82 ++++--- crates/dig-wallet/src/operator_wallet.rs | 5 +- 7 files changed, 321 insertions(+), 111 deletions(-) diff --git a/SPEC.md b/SPEC.md index 9e8176c2..b12b5123 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7916,8 +7916,9 @@ itself (SYSTEM.md §4.1). > deciding half exists; nothing runs it.** Satisfied by code today, and *only* this: > > * §25.2's structural bounds on the signing authority — `MirrorSpends` and its two producers -> (`mirror/spends.rs`), `MirrorSigner::sign` and the fee ceiling (`mirror/signer.rs`), and the -> `&RecordedSpend` precondition that makes a journal entry the shape of the call. +> (`mirror/spends.rs`); and in `mirror/signer.rs`, the fee ceiling, the refusal of spends belonging +> to another wallet, and the one-record-per-signature audit entry whose intent is derived from the +> spends. All four read the artifact rather than a value passed beside it. > * §25.3's pricing rule and §25.4's step-3 planner, as **pure functions** over supplied > observations (`mirror/plan.rs`, `mirror/pass.rs`), including §25.7's switch semantics and > §25.9's decision directions. @@ -7940,9 +7941,15 @@ itself (SYSTEM.md §4.1). > observed: the disk side (`cache_list_cached()` filtered to `Held`) is not read by this module, and > the chain side is not read at all — `dig_mirror_coin::list` appears nowhere in this repo. So a > reader MUST NOT infer that a coin's existence tracks a `.dig`'s presence today; the biconditional -> below is the obligation discharges. The -> `Relayed`-is-never-advertised rule is the exception: it holds structurally, because the plan's -> desired set is built from `Held` bonds only. +> below is the obligation discharges. +> +> **The `Relayed`-is-never-advertised rule is PENDING too, and it is the load-bearing one.** It is +> prose at this head, not code: the planner takes a bond set it is GIVEN and carries no provenance +> field, so nothing filters `Relayed` out — the exclusion lives only in a doc comment +> (`mirror/plan.rs:24`) and in the sentence below. This is the single point at which another party +> could influence what this node spends its own money on, since a `Relayed` capsule arrives on +> somebody else's behalf. The observation step #412 builds MUST filter to `Held` provenance at the +> source, and MUST NOT rely on a caller passing an already-filtered set. > **A mirror coin owned by this node for the CURRENT epoch exists ⟺ the `.dig` for that > `(store, root)` is on disk with `Held` provenance.** @@ -7984,7 +7991,10 @@ The authority is bounded four ways, and every bound is stated so a reader can ch `Default`, and no conversion from `Vec`. Its only producers are `build_create` and `build_reclaim`, thin wrappers over `dig_mirror_coin::create` / `::reclaim` that add no conditions, alter no amount and change no destination. The signer's ONLY public entry point is - `sign(&MirrorSpends, &RecordedSpend)`. There is no method on this path that accepts an arbitrary + `sign(&MirrorSpends, &SpendJournal) -> Result<(SpendBundle, RecordedSpend), SignError>`, and it + MUST refuse spends whose owner puzzle hash is not its own wallet's — the builders take a + `synthetic_key` while the signer signs with its own, and nothing else relates the two. + There is no method on this path that accepts an arbitrary `CoinSpend`, so widening the authority requires adding a producer — a visible, reviewable edit to the one file whose purpose is to say what may be signed. 2. **By destination, structurally.** A create's collateral lands at the $DIG CAT construction around @@ -8031,8 +8041,15 @@ address the wallet already tracks. > no confirmation is observed, and nothing reconciles an `unresolved` or `failed` one. Both are > tracked as . -**Every spend is audited, structurally.** The signer takes `&RecordedSpend`, whose only source is -`SpendJournal::begin` (§23.3) — recording is the shape of the call. Entries carry +**Every spend is audited, structurally — exactly ONE entry per signature, and it cannot lie about +the spend.** The signer takes the `SpendJournal` (§23.3) and opens the record itself, returning the +`RecordedSpend` for the caller to resolve: recording is the shape of the call. Two properties MUST +hold and are held here by construction rather than by convention. **One record MUST NOT be able to +back more than one signature** — an already-opened record passed by shared borrow can be reused in a +loop, accounting for N unattended spends as one. And **the intent MUST be DERIVED from the spends**, +never supplied alongside them: an intent a caller states can name a different amount, store or fee +than the bundle moves, and a record that is confidently wrong is worse than no record, because +§908's carve-out is bought precisely with the account being true. Entries carry `kind: "mirror-coin"` (`spend_audit::kinds::MIRROR_COIN`), `authority: { principal: "node", grant: "mirror-collateral" }`, the asset (`dig` for creates and reclaims), the amount in DIG base units, the fee in XCH mojos, and `store_id`; `purpose` SHOULD name diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 6fe5198c..ffe448d5 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -124,6 +124,10 @@ dig-chainsource-interface = "0.3" chia-bls = "0.36.1" chia-protocol = "0.36.1" chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +# `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the +# signer can refuse spends that are not its own wallet's rather than trusting the call site to have +# passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. +clvm-utils = "0.36.1" # The epoch term of a mirror hint is a `BigInt`, not a `u64`: the morph is arithmetic over # 32-byte values and the crate's API says so. Same line as `dig-mirror-coin`'s own. num-bigint = "0.4.6" diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 2ec3c8c5..99b35612 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -45,10 +45,17 @@ //! # Accountability is what pays for it //! //! Because the user cannot approve each spend, they are owed a complete account of every spend made -//! without asking. The signer takes a -//! [`RecordedSpend`](crate::spend_audit::RecordedSpend) (dig-node#376), whose only source is -//! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin) — so recording is the SHAPE of -//! the call rather than a convention a later producer can forget. +//! without asking. So the signer takes the +//! [`SpendJournal`](crate::spend_audit::SpendJournal) (dig-node#376) and opens the record ITSELF, +//! returning the [`RecordedSpend`](crate::spend_audit::RecordedSpend) for the caller to resolve — +//! recording is the SHAPE of the call rather than a convention a later producer can forget. +//! +//! Opening it there, rather than accepting one already opened, is what makes the account TRUE +//! rather than merely present. Exactly one entry exists per signature, so N unattended spends can +//! never be accounted for as one; and the entry's amount, store and fee are derived from the spends +//! by [`spends::MirrorSpends::intent`], so no caller is in a position to state a figure the bundle +//! does not move. A record that is confidently wrong would be worse than none at all, because it is +//! the record that buys the permission to spend without asking. //! //! # Nothing here re-derives the epoch, the hint, or the amount //! diff --git a/crates/dig-node-service/src/mirror/signer.rs b/crates/dig-node-service/src/mirror/signer.rs index c415897f..c947c430 100644 --- a/crates/dig-node-service/src/mirror/signer.rs +++ b/crates/dig-node-service/src/mirror/signer.rs @@ -8,14 +8,27 @@ //! `CoinSpend`, a `Vec`, or a `SpendBundle`, so this signer is not a signing oracle with //! a filter in front of it — it is a function that cannot be handed anything else. //! -//! **That a spend is recorded** is bounded the same way. `sign` also takes a -//! [`RecordedSpend`](crate::spend_audit::RecordedSpend), whose sole producer is -//! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin). A caller therefore cannot reach -//! a signature without having first written a `pending` audit entry: recording is the SHAPE of the -//! call, not a convention a later producer can forget. That is the whole §908 bargain — the node may -//! spend without asking *because* the account of it is readable afterwards — and it is worth stating -//! that a signer wired without a journal would be strictly worse than neither, since it would produce -//! unattended spends with no record. +//! **That a spend is recorded, ONCE and TRUTHFULLY**, is bounded the same way. `sign` takes the +//! [`SpendJournal`](crate::spend_audit::SpendJournal) itself and opens the record here, from the +//! spends, returning the [`RecordedSpend`](crate::spend_audit::RecordedSpend) for the caller to +//! resolve. Recording is therefore the SHAPE of the call, not a convention a later producer can +//! forget — and a signer wired without a journal would be strictly worse than neither, since it +//! would produce unattended spends with no record. +//! +//! Taking the journal rather than an already-opened record closes two gaps that an earlier shape +//! left open, both of which an executor written the obvious way would have walked into: +//! +//! * **One record could back N signatures.** A `&RecordedSpend` is a shared borrow and is not +//! consumed, so a single `begin()` could be handed to `sign` in a loop — N unattended spends +//! accounted for as one. +//! * **The record could describe a different spend than the one signed.** The intent — amount, +//! store, fee — was supplied by the caller alongside the spends and never checked against them, +//! so an entry reading "one spend of X" could sit beside N spends of Y. +//! +//! A record that is confidently wrong is worse than no record at all, because §908's carve-out is +//! bought precisely with the account being true. Both gaps close the same way, and it is the same +//! way the fee ceiling closes below: **read the fact from the artifact, never from a value passed +//! next to it.** //! //! # It is not installed anywhere //! @@ -45,7 +58,7 @@ use chia_protocol::{Bytes32, SpendBundle}; use dig_wallet::operator_wallet::OperatorWallet; -use crate::spend_audit::RecordedSpend; +use crate::spend_audit::{RecordedSpend, SpendJournal}; use super::spends::MirrorSpends; @@ -72,6 +85,11 @@ pub enum SignError { /// The ceiling it exceeded, in XCH mojos. ceiling_mojos: u64, }, + /// These spends belong to a different wallet than the one this signer holds. + /// + /// Carries no puzzle hash: an error from a signing path is one of the likeliest things to end up + /// in a log, and the two hashes are the only interesting thing in it. + NotThisWallet, /// The signature could not be produced. Carries a one-line cause with no key material in it. Signing(String), } @@ -86,6 +104,10 @@ impl std::fmt::Display for SignError { f, "mirror spend fee {requested_mojos} mojos exceeds the ceiling of {ceiling_mojos}" ), + SignError::NotThisWallet => write!( + f, + "mirror spends belong to a different wallet than this signer holds" + ), SignError::Signing(cause) => write!(f, "mirror spend could not be signed: {cause}"), } } @@ -118,21 +140,28 @@ impl MirrorSigner { self.wallet.owner_puzzle_hash() } - /// Sign `spends`, which the record `_recorded` has already been opened for. + /// Open an audit record for `spends` and sign them, returning both. /// - /// `_recorded` is unused by the signing arithmetic and is required anyway. That is the point: its - /// only producer is `SpendJournal::begin`, so demanding one makes a `pending` audit entry a - /// precondition of a signature that the type system enforces. Removing this parameter would - /// remove the audit guarantee while changing no observable behaviour — which is exactly why it is - /// spelled out here rather than left to a reviewer to notice. + /// The record is opened HERE, from the spends, and exactly one is opened per signature. Its + /// intent — amount, store, fee — is derived by [`MirrorSpends::intent`] and is not something a + /// caller can state, so the account of a spend cannot disagree with the spend. The caller + /// resolves the returned [`RecordedSpend`] as the bundle is submitted and confirms (§23.3). /// - /// Refuses a fee above [`MIRROR_SPEND_FEE_CEILING_MOJOS`] before signing anything. The fee read - /// is `spends`' own — see the module doc for why it is deliberately not a parameter here. + /// Two refusals come BEFORE anything is written or signed, so a refused spend leaves no + /// `pending` entry for a spend that never happened: + /// + /// * spends belonging to any wallet but this one ([`SignError::NotThisWallet`]); + /// * a fee above [`MIRROR_SPEND_FEE_CEILING_MOJOS`] — read from `spends` themselves, never from + /// a parameter. See the module doc for why that distinction is the whole bound. pub fn sign( &self, spends: &MirrorSpends, - _recorded: &RecordedSpend, - ) -> Result { + journal: &SpendJournal, + ) -> Result<(SpendBundle, RecordedSpend), SignError> { + if spends.owner_puzzle_hash() != self.wallet.owner_puzzle_hash() { + return Err(SignError::NotThisWallet); + } + let fee_mojos = spends.fee_mojos(); if fee_mojos > MIRROR_SPEND_FEE_CEILING_MOJOS { return Err(SignError::FeeAboveCeiling { @@ -141,6 +170,8 @@ impl MirrorSigner { }); } + let recorded = journal.begin(spends.intent()); + let coin_spends = spends.coin_spends().to_vec(); let signature = self .wallet @@ -148,14 +179,14 @@ impl MirrorSigner { .sign(&coin_spends) .map_err(|e| SignError::Signing(e.to_string()))?; - Ok(SpendBundle::new(coin_spends, signature)) + Ok((SpendBundle::new(coin_spends, signature), recorded)) } } #[cfg(test)] mod tests { use super::*; - use crate::spend_audit::{kinds, Asset, Authority, SpendIntent, SpendJournal, SpendLog}; + use crate::spend_audit::{kinds, SpendJournal, SpendLog}; const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ @@ -167,23 +198,14 @@ abandon abandon abandon art"; ) } - fn journal(dir: &std::path::Path) -> SpendJournal { - SpendJournal::new(SpendLog::at(dir.join("spend-audit.jsonl"))) + fn journal(dir: &std::path::Path) -> (SpendJournal, SpendLog) { + let log = SpendLog::at(dir.join("spend-audit.jsonl")); + (SpendJournal::new(log.clone()), log) } - fn intent() -> SpendIntent { - SpendIntent { - kind: crate::spend_audit::SpendKind::new(kinds::MIRROR_COIN), - purpose: "collateralise a held capsule".to_string(), - authority: Authority { - principal: "node".to_string(), - grant: "mirror-collateral".to_string(), - }, - asset: Asset::Dig, - amount_mojos: 1_000, - fee_mojos: 0, - store_id: Some("store".to_string()), - } + /// Spends this signer's own wallet owns, paying `fee_mojos`. + fn own(signer: &MirrorSigner, fee_mojos: u64) -> super::super::spends::MirrorSpends { + super::super::spends::empty_for_tests(fee_mojos, signer.owner_puzzle_hash()) } /// The mirror signer holds its wallet and hands nothing back. @@ -208,20 +230,19 @@ abandon abandon abandon art"; /// /// Both sides, because a bound tested only from above passes for an implementation with no bound /// at all, and one tested only at the bound passes for an implementation that refuses everything. + /// The fee here travels ON the spends; that it cannot be overridden by a caller is proven + /// against a REAL bundle in `tests/mirror_fee_ceiling.rs`, which an empty spend set cannot show. #[test] fn the_fee_ceiling_is_exact_in_both_directions() { let dir = tempfile::tempdir().expect("tempdir"); - let journal = journal(dir.path()); - let recorded = journal.begin(intent()); + let (journal, _log) = journal(dir.path()); let signer = signer(); - let over = signer.sign( - &super::super::spends::empty_for_tests(MIRROR_SPEND_FEE_CEILING_MOJOS + 1), - &recorded, - ); assert_eq!( - over, - Err(SignError::FeeAboveCeiling { + signer + .sign(&own(&signer, MIRROR_SPEND_FEE_CEILING_MOJOS + 1), &journal) + .err(), + Some(SignError::FeeAboveCeiling { requested_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS + 1, ceiling_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS, }), @@ -230,38 +251,94 @@ abandon abandon abandon art"; assert!( signer - .sign( - &super::super::spends::empty_for_tests(MIRROR_SPEND_FEE_CEILING_MOJOS), - &recorded - ) + .sign(&own(&signer, MIRROR_SPEND_FEE_CEILING_MOJOS), &journal) .is_ok(), "exactly at the ceiling is permitted, so the refusal above is not unconditional" ); } - /// Signing requires a journaled spend, and the journal entry exists BEFORE the signature. + /// Spends belonging to another wallet are refused, and this signer's own are not. /// - /// The type system already makes a `RecordedSpend` unobtainable without `SpendJournal::begin`, so - /// this test cannot fail while compiling — which is the property being demonstrated. What it does - /// check is the observable half: that `begin` has actually written a `pending` line by the time a - /// signature is possible, rather than deferring the write to some later resolution. + /// The failure direction was already safe — a bundle signed by the wrong key does not make it + /// through the network — but §25.2's destination bound is supposed to hold by construction, and + /// "the network catches it" is a different guarantee. The control is what makes this a test of + /// the comparison rather than of a signer that refuses everything. #[test] - fn a_pending_audit_entry_exists_before_a_signature_can_be_produced() { + fn spends_belonging_to_another_wallet_are_refused() { let dir = tempfile::tempdir().expect("tempdir"); - let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); - let journal = SpendJournal::new(log.clone()); + let (journal, log) = journal(dir.path()); + let signer = signer(); - let recorded = journal.begin(intent()); - let ledger = log.ledger().expect("ledger readable"); + let foreign = super::super::spends::empty_for_tests(0, Bytes32::from([0x99u8; 32])); assert_eq!( - ledger.records.len(), + signer.sign(&foreign, &journal).err(), + Some(SignError::NotThisWallet) + ); + assert!( + signer.sign(&own(&signer, 0), &journal).is_ok(), + "the signer's own spends still sign" + ); + + assert_eq!( + log.ledger().expect("ledger readable").records.len(), 1, - "the record is written by `begin`, not by whatever happens next" + "the refused spend wrote no record: nothing happened, so nothing is accounted for" + ); + } + + /// Each signature opens exactly ONE audit entry, and it is `pending` before the signature. + /// + /// The count is the point. When `sign` took an already-opened `&RecordedSpend` it took it by + /// shared borrow and never consumed it, so one `begin()` could back a loop of signatures — N + /// unattended spends accounted for as one, which is the shape an executor written the obvious + /// way produces. Signing twice here and asserting TWO records is what distinguishes the current + /// shape from that one; asserting only that a record exists would pass under both. + #[test] + fn every_signature_opens_exactly_one_pending_record() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + let signer = signer(); + + let (_bundle, recorded) = signer.sign(&own(&signer, 0), &journal).expect("signs"); + + let ledger = log.ledger().expect("ledger readable"); + assert_eq!(ledger.records.len(), 1); + assert_eq!( + ledger.records[0].status.token(), + "pending", + "the record is open by the time a signature exists, not resolved later by someone else" + ); + assert_eq!(ledger.records[0].id, recorded.id()); + + signer.sign(&own(&signer, 0), &journal).expect("signs"); + assert_eq!( + log.ledger().expect("ledger readable").records.len(), + 2, + "a second signature is a second record -- one record can never stand for two spends" ); - assert_eq!(ledger.records[0].status.token(), "pending"); + } - signer() - .sign(&super::super::spends::empty_for_tests(0), &recorded) - .expect("an empty spend set signs to an empty aggregate"); + /// The recorded intent is derived from the spends, so the two cannot disagree. + /// + /// The fee is the field a caller used to supply, and it is the one asserted here: the record + /// reports the fee the bundle pays because it reads it from the bundle. `tests/mirror_fee_ceiling.rs` + /// carries the amount and store half against a real build, which an empty spend set cannot. + #[test] + fn the_recorded_intent_comes_from_the_spends() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + let signer = signer(); + + signer + .sign(&own(&signer, MIRROR_SPEND_FEE_CEILING_MOJOS), &journal) + .expect("signs"); + + let ledger = log.ledger().expect("ledger readable"); + assert_eq!( + ledger.records[0].fee_mojos, MIRROR_SPEND_FEE_CEILING_MOJOS, + "the entry names the fee the spends pay, with no caller in a position to say otherwise" + ); + assert_eq!(ledger.records[0].kind.as_str(), kinds::MIRROR_COIN); + assert_eq!(ledger.records[0].authority.grant, "mirror-collateral"); } } diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index 7ea176e3..b845a74d 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -12,7 +12,7 @@ //! So the constraint is the argument type. [`MirrorSpends`] has no public constructor, no //! `Default`, and no way to be built from arbitrary spends. The only two producers are in this //! module, and each is a thin wrapper over the corresponding `dig_mirror_coin` builder. The signer -//! ([`super::key::MirrorOperatingKey::sign`]) takes one and nothing else will type-check. +//! ([`super::signer::MirrorSigner::sign`]) takes one and nothing else will type-check. //! //! That is a compile-time property of the API surface rather than a claim about its behaviour: to //! widen the authority you would have to add a producer here, which is a visible, reviewable edit to @@ -25,10 +25,13 @@ use chia_bls::PublicKey; use chia_protocol::{Bytes32, Coin, CoinSpend}; -use chia_sdk_driver::Cat; +use chia_sdk_driver::{Cat, StandardLayer}; +use clvm_utils::ToTreeHash; use dig_mirror_coin::{MirrorAdvertisement, MirrorCoin, MirrorError}; use num_bigint::BigInt; +use crate::spend_audit::{kinds, Asset, Authority, SpendIntent, SpendKind}; + /// What a mirror spend is FOR. Carried alongside the spends so the audit entry and any log can name /// the operation without re-deriving it from the CLVM. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -60,6 +63,11 @@ pub struct MirrorSpends { operation: MirrorOperation, spends: Vec, fee_mojos: u64, + owner_puzzle_hash: Bytes32, + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: BigInt, + collateral_dig_base_units: u64, } impl MirrorSpends { @@ -86,6 +94,47 @@ impl MirrorSpends { pub fn fee_mojos(&self) -> u64 { self.fee_mojos } + + /// The puzzle hash these spends belong to — the key a create was built for, or the on-chain + /// owner of the coin a reclaim releases. + /// + /// `MirrorSigner::sign` refuses any bundle whose owner is not its own wallet's. Without it, + /// §25.2's destination bound would rest on every call site having passed the right key: the + /// builders take a `synthetic_key` while the signer signs with `self.wallet`, and nothing + /// related the two. The failure direction was safe — the network rejects the mismatch — but + /// "the network catches it" is not the same guarantee as "it cannot be built", and this module's + /// thesis is the second one. + pub fn owner_puzzle_hash(&self) -> Bytes32 { + self.owner_puzzle_hash + } + + /// The audit intent these spends imply, derived wholly from the spends themselves. + /// + /// This is deliberately not something a caller supplies. An intent passed ALONGSIDE the spends + /// is a claim about them: it can name a different amount, a different store, or a different fee + /// than the bundle actually moves, and the resulting record is confidently wrong — which is + /// worse than no record, because §908's carve-out is bought precisely with the record being + /// true. Deriving it here makes the two unable to disagree. + pub(crate) fn intent(&self) -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: format!( + "{} a mirror coin for store {} at root {} in epoch {}", + self.operation.as_str(), + hex::encode(self.store_launcher_id), + hex::encode(self.root_hash), + self.epoch, + ), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: self.collateral_dig_base_units, + fee_mojos: self.fee_mojos, + store_id: Some(hex::encode(self.store_launcher_id)), + } + } } /// Build the spends that lock `collateral_dig_base_units` of $DIG as a mirror for one `(store, root, @@ -116,7 +165,7 @@ pub fn build_create( MirrorAdvertisement { store_launcher_id, root_hash, - epoch, + epoch: epoch.clone(), urls, collateral: collateral_dig_base_units, }, @@ -130,6 +179,14 @@ pub fn build_create( operation: MirrorOperation::Create, spends, fee_mojos: fee, + // Re-derived from the key the spends were built for, by the same standard derivation + // `dig_mirror_coin::reclaim` uses to decide ownership — so the recorded owner is a property + // of the bundle rather than a second thing a caller could state. + owner_puzzle_hash: StandardLayer::new(synthetic_key).tree_hash().into(), + store_launcher_id, + root_hash, + epoch, + collateral_dig_base_units, }) } @@ -155,6 +212,17 @@ pub fn build_reclaim( operation: MirrorOperation::Reclaim, spends, fee_mojos: fee, + // The coin's own owner, read from its lineage proof rather than from the caller. `reclaim` + // has already refused (`NotOwner`) any coin this key does not own, so by the time there are + // spends at all these two agree. + owner_puzzle_hash: mirror.owner_puzzle_hash(), + store_launcher_id: mirror.store_launcher_id(), + root_hash: mirror.root_hash(), + epoch: mirror.epoch().clone(), + // What the coin actually locked, which is exactly what a reclaim returns -- not the current + // epoch's requirement. A coin bonded under a previous epoch's amount is reclaimed at that + // amount (SPEC.md 25.3). + collateral_dig_base_units: mirror.collateral(), }) } @@ -165,14 +233,20 @@ pub fn build_reclaim( /// module exists to guarantee. It carries [`MirrorOperation::Create`] because a `MirrorSpends` always /// names an operation; the operation is irrelevant to an empty spend set. /// -/// `fee_mojos` is a parameter rather than zero so a signer test can exercise the ceiling without a -/// chain. The fee is the one thing about these spends the signer reads, so a fixture that could not -/// vary it could not test the bound at all. +/// `fee_mojos` and `owner_puzzle_hash` are parameters rather than fixed values so a signer test can +/// exercise the ceiling and the wallet binding without a chain. They are the two things about these +/// spends the signer reads, and a fixture that could not vary them could not test either bound: a +/// double that can only hold one value cannot express the disagreement being guarded against. #[cfg(test)] -pub(crate) fn empty_for_tests(fee_mojos: u64) -> MirrorSpends { +pub(crate) fn empty_for_tests(fee_mojos: u64, owner_puzzle_hash: Bytes32) -> MirrorSpends { MirrorSpends { operation: MirrorOperation::Create, spends: Vec::new(), fee_mojos, + owner_puzzle_hash, + store_launcher_id: Bytes32::default(), + root_hash: Bytes32::default(), + epoch: BigInt::from(0), + collateral_dig_base_units: 0, } } diff --git a/crates/dig-node-service/tests/mirror_fee_ceiling.rs b/crates/dig-node-service/tests/mirror_fee_ceiling.rs index e0ef764f..1c5087f4 100644 --- a/crates/dig-node-service/tests/mirror_fee_ceiling.rs +++ b/crates/dig-node-service/tests/mirror_fee_ceiling.rs @@ -18,10 +18,8 @@ use chia_protocol::{Bytes32, Coin}; use dig_mirror_coin::MirrorCoin; use dig_node_service::mirror::signer::{MirrorSigner, SignError, MIRROR_SPEND_FEE_CEILING_MOJOS}; use dig_node_service::mirror::spends::build_reclaim; -use dig_node_service::spend_audit::{ - kinds, Asset, Authority, RecordedSpend, SpendIntent, SpendJournal, SpendKind, SpendLog, -}; -use support::{creating_spend, mirror_memos, root_1, store_a, wallet, Wallet}; +use dig_node_service::spend_audit::{SpendJournal, SpendLog}; +use support::{creating_spend, mirror_memos, root_1, store_a, Wallet}; const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ @@ -51,19 +49,32 @@ fn signer() -> MirrorSigner { ) } -fn recorded(journal: &SpendJournal) -> RecordedSpend { - journal.begin(SpendIntent { - kind: SpendKind::new(kinds::MIRROR_COIN), - purpose: "reclaim a mirror coin".to_string(), - authority: Authority { - principal: "node".to_string(), - grant: "mirror-collateral".to_string(), - }, - asset: Asset::Dig, - amount_mojos: support::COLLATERAL, - fee_mojos: 0, - store_id: Some("store".to_string()), - }) +/// The signer's OWN wallet, as a fixture wallet — so the coins below genuinely belong to it. +/// +/// This matters to every test here: `sign` now refuses spends whose owner is not its own wallet's, +/// and that refusal is checked BEFORE the fee ceiling. A fixture built for an unrelated key would +/// make every assertion below pass for the wrong reason, reporting the wallet guard as though it +/// were the fee guard. The `assert_eq!` is what rules that out. +fn signers_own_wallet() -> Wallet { + let keys = digstore_chain::keys::derive_wallet_keys(PHRASE).expect("the phrase derives"); + let owner = Wallet { + public_key: keys.synthetic_sk.public_key(), + puzzle_hash: keys.owner_puzzle_hash, + }; + + assert_eq!( + owner.puzzle_hash, + signer().owner_puzzle_hash(), + "the fixture must be the signer's own wallet, or these tests measure the wallet guard" + ); + + owner +} + +/// A journal and the log behind it, so a test can read back what signing wrote. +fn journal(dir: &std::path::Path) -> (SpendJournal, SpendLog) { + let log = SpendLog::at(dir.join("spend-audit.jsonl")); + (SpendJournal::new(log.clone()), log) } /// A genuine mirror coin this wallet owns, built from a real CAT spend that is executed to produce @@ -84,7 +95,7 @@ fn owned_mirror_coin(owner: &Wallet) -> MirrorCoin { /// the ceiling saw the argument, and the argument was not the thing being signed. #[test] fn a_reclaim_built_above_the_ceiling_is_refused_even_though_no_caller_says_so() { - let owner = wallet(3); + let owner = signers_own_wallet(); let coin = owned_mirror_coin(&owner); let spends = build_reclaim( @@ -96,16 +107,21 @@ fn a_reclaim_built_above_the_ceiling_is_refused_even_though_no_caller_says_so() .expect("a reclaim at any fee builds; refusing it is the signer's job"); let dir = tempfile::tempdir().expect("tempdir"); - let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + let (journal, log) = journal(dir.path()); assert_eq!( - signer().sign(&spends, &recorded(&journal)), - Err(SignError::FeeAboveCeiling { + signer().sign(&spends, &journal).err(), + Some(SignError::FeeAboveCeiling { requested_mojos: RUINOUS_FEE_MOJOS, ceiling_mojos: MIRROR_SPEND_FEE_CEILING_MOJOS, }), "the ceiling must read the fee the spends actually pay" ); + + assert!( + log.ledger().expect("ledger readable").records.is_empty(), + "a refused spend leaves no pending entry for a spend that never happened" + ); } /// The control: the same path at a legal fee signs. @@ -114,7 +130,7 @@ fn a_reclaim_built_above_the_ceiling_is_refused_even_though_no_caller_says_so() /// the fee bound by making the module useless rather than by making it correct. #[test] fn the_same_reclaim_at_a_legal_fee_signs() { - let owner = wallet(3); + let owner = signers_own_wallet(); let coin = owned_mirror_coin(&owner); let spends = build_reclaim( @@ -126,26 +142,38 @@ fn the_same_reclaim_at_a_legal_fee_signs() { .expect("builds"); let dir = tempfile::tempdir().expect("tempdir"); - let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + let (journal, log) = journal(dir.path()); assert!( - signer().sign(&spends, &recorded(&journal)).is_ok(), + signer().sign(&spends, &journal).is_ok(), "exactly at the ceiling is permitted, so the refusal above is not unconditional" ); + + // The record describes the bundle, because it was derived from it. The amount is the coin's own + // collateral and the store is the coin's own store -- neither was available to be misstated, + // which is the property the derivation buys. + let ledger = log.ledger().expect("ledger readable"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].fee_mojos, MIRROR_SPEND_FEE_CEILING_MOJOS); + assert_eq!(ledger.records[0].amount_mojos, coin.collateral()); + assert_eq!( + ledger.records[0].store_id.as_deref(), + Some(hex::encode(store_a()).as_str()) + ); } /// A zero-fee reclaim signs — the case §25.4 requires to work when the wallet holds no XCH. #[test] fn a_zero_fee_reclaim_signs() { - let owner = wallet(3); + let owner = signers_own_wallet(); let coin = owned_mirror_coin(&owner); let spends = build_reclaim(&coin, owner.public_key, fee_coins(&owner, 0), 0).expect("builds"); let dir = tempfile::tempdir().expect("tempdir"); - let journal = SpendJournal::new(SpendLog::at(dir.path().join("spend-audit.jsonl"))); + let (journal, _log) = journal(dir.path()); assert!( - signer().sign(&spends, &recorded(&journal)).is_ok(), + signer().sign(&spends, &journal).is_ok(), "a wallet with no XCH must still be able to recover what it has locked" ); } diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index ead683f1..8659798a 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -31,7 +31,10 @@ //! //! [`OperatorWallet`] is a value a caller holds; it is not registered anywhere. In particular it is //! never passed to `WalletBackend::with_signer`, so `WalletBackend::current_signer()` answers exactly -//! what it answered before — see the guard test at the bottom of this file. That matters beyond +//! what it answered before — see the guard test +//! `sage::rpc::tests::opening_the_operator_wallet_installs_no_signer_on_the_general_surface`, in +//! `sage/rpc.rs`, which is where a `WalletBackend` can be built and so where the mistake would be +//! made. That matters beyond //! tidiness: installing a signer on the general backend would silently activate every other //! node-custodied spend path, including default-on auto-tipping, as a side effect of enabling //! collateralisation.