From 6947cb0c886906b3b514112ae3f0c39d00ff77a3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 02:19:19 -0700 Subject: [PATCH 01/23] build(deps): adopt control-interface 0.23.0 and the collateral math crate Bumps to latest published (CLAUDE.md 2.4b), verified against the crates.io index rather than memory: dig-node-control-interface 0.21 -> 0.23 (service + wallet) dig-logging 0.1 -> 0.2 (service) dig-constants 0.11.2 -> 0.13.0 (service + core, together) dig-mirror-collateral (new) 0.3 (service) The chia-* set is deliberately NOT moved. chia-bls and chia-protocol publish 0.48.0, but chia-wallet-sdk's own latest (0.36.0) requires ^0.36.1, so bumping the pair would ship this crate internally split across two chia lines -- the exact failure that shipped twice elsewhere in one day. The set is already at its latest coherent point. Adopting 0.23.0 turns the contract-conformance test red, naming the four methods this branch exists to serve: control.spends.list control.collateral.requirement control.collateral.margin.get control.collateral.margin.set Co-Authored-By: Claude --- Cargo.lock | 40 +++++++++++++++++++++++------- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-service/Cargo.toml | 16 +++++++++--- crates/dig-wallet/Cargo.toml | 2 +- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f762ec31..a9b08634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2658,6 +2658,17 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "dig-constants" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b17546b02bf69defb8fe3b1a59a200fcf9ca5b542d7e7fc99c13f190dd8b73" +dependencies = [ + "chia-consensus 0.36.1", + "chia-protocol 0.36.1", + "hex-literal", +] + [[package]] name = "dig-dht" version = "0.12.1" @@ -2851,9 +2862,9 @@ dependencies = [ [[package]] name = "dig-logging" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c3a71bbd1b2784c5d7e2eea47edb57785a97da17fbcc9cd4f8339c9978cbf7" +checksum = "9c9f4a20dafd185b1b4a80eb7330765de129d7e188a5d19954d509030ef0b27c" dependencies = [ "bip39", "clap", @@ -2926,6 +2937,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "dig-mirror-collateral" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1942a9df7f1a9d6f87179630995e84260533c214c18b93ccef58ba8453847c7" +dependencies = [ + "serde", + "thiserror 2.0.19", +] + [[package]] name = "dig-nat" version = "0.20.0" @@ -2972,9 +2993,9 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca01b2c4a8bb3bfb43eb5c3c1b835ba945e36e9e089f8385928823121fa98c2a" +checksum = "eaf6c62dd8afb9eab7b7f2cd3d2a74e7c5e298671f9c34ec15b43dfbff4fdb6f" dependencies = [ "async-trait", "semver", @@ -2994,7 +3015,7 @@ dependencies = [ "chia-sdk-utils 0.36.0", "chia-traits 0.36.1", "dig-chat-protocol", - "dig-constants 0.11.2", + "dig-constants 0.13.0", "dig-dht", "dig-download", "dig-gossip", @@ -3055,8 +3076,9 @@ dependencies = [ "chia-bls 0.36.1", "clap", "dig-cert", - "dig-constants 0.11.2", + "dig-constants 0.13.0", "dig-logging", + "dig-mirror-collateral", "dig-node-control-interface", "dig-node-core", "dig-node-service", @@ -4491,7 +4513,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -5666,7 +5688,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -5704,7 +5726,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 6fe5a165..21a5ecf4 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -368,7 +368,7 @@ dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "48a14ed # consumer cannot edit. Collapsing to a single copy needs each of those to release against 0.9 — # the release-first cascade on #2072. Do NOT pin this BACK to widen a window: the fix is upstream # moving forward, never dig-node drifting backward. -dig-constants = "0.11.2" +dig-constants = "0.13.0" # -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- # # `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index cd7d7d1f..efbe516a 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -89,7 +89,17 @@ dig-node-core = { path = "../dig-node-core" } # "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of -- # it checked nothing about the very method the change added. A caret range keeps the pin moving # with the published catalog instead of silently narrowing what CI can see. -dig-node-control-interface = "0.21" +dig-node-control-interface = "0.23" + +# The deterministic mirror-coin collateral model: the per-epoch requirement, the controller +# multiplier, the small-network handicap, the floor clamp, and the safety-margin arithmetic. +# +# Depended on rather than restated. `required_per_store` is the WHOLE answer -- writing +# `equilibrium x multiplier - handicap` at a call site omits the floor clamp, which is a +# money-path understatement, and a lane has already been refused for putting exactly that +# formula in a SPEC. Every figure this node reports for `control.collateral.requirement` +# comes out of this crate so that a second derivation cannot drift from the consensus one. +dig-mirror-collateral = "0.3" # 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)` / @@ -162,7 +172,7 @@ serde_json = "1" # reusable `logs` CLI verbs + the bundle-time redaction engine. Every DIG service binary # gets logging from HERE rather than hand-rolling a subscriber. Sourced from crates.io (no # git/path dep) per the ecosystem crates.io policy (#681). -dig-logging = "0.1" +dig-logging = "0.2" # The logging facade the shell emits through. dig-node-core already depends on `tracing`; # the service shell now emits its own bring-up/lifecycle events through it, captured by the # `dig-logging` subscriber installed at the serve entrypoints. @@ -195,7 +205,7 @@ dig-urn-resolver = "0.5.3" # for client→node connections; `config.rs`'s DEFAULT_PORT reads it rather than re-declaring the # literal. Held in lockstep with dig-node-core's pin (#2072) — 0.11.2, on dig-node's chia 0.36.1 # line (dig_ecosystem#3161) — so the two node crates can never disagree about a canonical value. -dig-constants = "0.11.2" +dig-constants = "0.13.0" # Windows Service Control Protocol. service-manager only REGISTERS the service in the # SCM; the binary the SCM launches must itself speak the service protocol diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index deb31327..0592768a 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -64,7 +64,7 @@ axum = "0.7" # The PUBLISHED control-plane contract. `BalanceAsset` is re-exported from it rather than # restated here: the wire spelling of an asset and the asset a read scopes to must be the same # value, and two enums naming the same tokens agree only until one of them gains a variant. -dig-node-control-interface = "0.21" +dig-node-control-interface = "0.23" # The ecosystem's single definition of a Chia offer id (dig_ecosystem#3140, #283): # `sha256(spend_bundle.to_bytes())`, the value Chia's `Offer.name()`, Sage and dexie use. This # crate once derived the id itself from the offered coin set alone, which collided across offers From bb4011a160f67599db6f7ddc346279e741e07ead Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 02:30:41 -0700 Subject: [PATCH 02/23] feat(collateral): per-epoch record store, local safety margin, funding advice The node's side of the deterministic mirror-coin collateral model, built on dig-mirror-collateral 0.3 -- no formula is restated locally, because required_per_store is the WHOLE answer and re-deriving it as equilibrium x multiplier - handicap omits the floor clamp. Three parts, split along the line that matters: * EpochRecordStore -- what this node censused, keyed by epoch. Distinguishes a record it never wrote (Absent) from one it wrote and cannot read (Unreadable); the remedies differ. * CollateralConfig -- the LOCAL safety margin, persisted. A config predating the field loads as the +1% default, never 0: zero is a deliberate choice to post exactly, and reporting it for a config that never expressed one tells the operator they declined a cushion they were never offered. * buffer_advice -- how much DIG to hold, and the three funding states. The buffer is deliberately NOT requirement x epochs of runway. Collateral is RECLAIMED, not spent, and reclaims run first and are never gated on funds, so steady state is roughly ONE epoch's lock. The real peak is the transition overlap, so the recommendation is lock + lock x (9/8)^4 -- one epoch's lock, the overlap, and four epochs of escalation headroom, in one expression rather than three that could double-count. Escalation compounds, so the horizon is a choice and it is reported with the figure. Only ShortNow and DangerouslyLow may notify. BelowRecommendedBuffer is a readout: a normal node sits there much of the time, and an alert an operator learns to dismiss teaches them to dismiss the two that matter. An unknown requirement or an unknown balance yields Unknown and never a zero cost. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 725 ++++++++++++++++++++++ crates/dig-node-service/src/lib.rs | 4 + 2 files changed, 729 insertions(+) create mode 100644 crates/dig-node-service/src/collateral.rs diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs new file mode 100644 index 00000000..293cef4d --- /dev/null +++ b/crates/dig-node-service/src/collateral.rs @@ -0,0 +1,725 @@ +//! The node's side of the deterministic mirror-coin collateral model. +//! +//! Three things live here, and the split matters because two of them are consensus values and one +//! is an operator preference: +//! +//! * the **per-epoch record store** — what this node has censused, keyed by epoch; +//! * the **safety margin** — a LOCAL preference, persisted, that never reaches a consensus input; +//! * the **funding advice** — how much $DIG this operator should hold, and whether they are short. +//! +//! # Every figure comes out of `dig-mirror-collateral` +//! +//! Not one formula is restated here. `required_per_store` is the WHOLE answer: writing +//! `equilibrium x multiplier - handicap` at a call site silently omits the floor clamp, which +//! understates what an advertisement must post, and under-posting costs the operator that epoch's +//! rewards. The same applies to [`apply_safety_margin`] — it rounds UP, and a re-derivation that +//! rounded down would post a base unit short. Call the crate; never repeat its arithmetic. +//! +//! # Units +//! +//! Every amount here is **DIG base units**: `1 DIG = 1_000`, so the smallest expressible amount is +//! `0.001 DIG`. They are never mojos. A mojo is XCH's base unit, `1e-12 XCH`, nine orders of +//! magnitude away, and the two names must not meet in this module. + +use std::path::{Path, PathBuf}; + +use dig_mirror_collateral::{ + apply_safety_margin, EpochRecord, MULT_SCALE, SAFETY_MARGIN_BP_DEFAULT, UP_STEP_DENOM, +}; +use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; +use serde::{Deserialize, Serialize}; + +/// The file holding this node's local collateral preferences. +const COLLATERAL_CONFIG_FILE: &str = "collateral.json"; + +/// The file holding the per-epoch records this node has censused, one JSON record per line. +const EPOCH_RECORD_FILE: &str = "collateral-epochs.jsonl"; + +/// How many epochs of multiplier escalation the recommended buffer covers. +/// +/// Escalation COMPOUNDS, so a horizon is a choice rather than a constant, and the choice is the +/// difference between advice an operator acts on and advice they dismiss. At the per-epoch ceiling +/// of `+12.5%` the factors run 1 epoch 1.12x, 2 1.27x, 4 1.60x, 8 2.57x, 13 4.62x. Telling someone +/// to hold 4.6x their current lock against a quarter of uninterrupted worst-case escalation is +/// advice nobody follows; four epochs is about a month, reads as prudent, and is small enough that +/// the resulting number is one a person will actually fund. +/// +/// The horizon is reported alongside the figure ([`BufferAdvice::horizon_epochs`]) because a buffer +/// whose assumption is invisible cannot be argued with. +pub const BUFFER_HORIZON_EPOCHS: u32 = 4; + +/// This node's local collateral preferences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollateralConfig { + /// The safety margin in basis points over the requirement (`100` is `+1%`). + /// + /// `default` rather than required, and the default is + /// [`SAFETY_MARGIN_BP_DEFAULT`] rather than `0`. A config written before this field existed + /// must not load as a zero margin: zero is a deliberate choice to post the requirement exactly, + /// and reporting it for a config that never expressed one tells the operator they declined a + /// cushion they were never offered. + #[serde(default = "default_margin_bp")] + pub margin_bp: u64, +} + +fn default_margin_bp() -> u64 { + SAFETY_MARGIN_BP_DEFAULT +} + +impl Default for CollateralConfig { + fn default() -> Self { + CollateralConfig { + margin_bp: SAFETY_MARGIN_BP_DEFAULT, + } + } +} + +impl CollateralConfig { + /// Load from `dir`, falling back to the default for a missing OR unreadable file. + /// + /// An unreadable file yields the default rather than an error because the margin is a cushion: + /// refusing to start over a corrupt preference file would take the node down over the one + /// setting whose absence is survivable. The fallback is the `+1%` default, never `0`, so the + /// degraded path still errs toward over-posting. + pub fn load_from(dir: &Path) -> Self { + std::fs::read_to_string(dir.join(COLLATERAL_CONFIG_FILE)) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() + } + + /// Persist to `dir`, creating the state directory with restricted permissions if needed. + pub fn save_to(&self, dir: &Path) -> std::io::Result<()> { + crate::state::ensure_dir_restricted(dir)?; + let path = dir.join(COLLATERAL_CONFIG_FILE); + let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&path, body)?; + crate::control::restrict_permissions(&path); + Ok(()) + } +} + +/// The per-epoch collateral records this node has censused. +/// +/// Append-only JSONL, highest revision of an epoch winning, mirroring the spend-audit record's +/// shape for the same reason: a census that rewrote history in place could not be audited. +#[derive(Debug, Clone)] +pub struct EpochRecordStore { + path: PathBuf, +} + +/// What the store holds for the epoch that was asked about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoredEpoch { + /// A record was found and parsed. + Found(Box), + /// No record for this epoch. + Absent, + /// A record for this epoch exists and could not be read. + /// + /// Distinct from [`Absent`](Self::Absent) on purpose: "I have not censused this epoch" and "I + /// censused it and lost the answer" have different remedies, and collapsing them would hand the + /// operator the same unactionable sentence for both. + Unreadable, +} + +impl EpochRecordStore { + /// A store at an explicit path. + pub fn at(path: impl Into) -> Self { + EpochRecordStore { path: path.into() } + } + + /// The node's own store, in the machine-wide state directory. + pub fn in_state_dir() -> Self { + EpochRecordStore::at(crate::state::state_dir().join(EPOCH_RECORD_FILE)) + } + + /// The file backing this store. + pub fn path(&self) -> &Path { + &self.path + } + + /// Append one censused record. + pub fn put(&self, record: &EpochRecord) -> std::io::Result<()> { + if let Some(dir) = self.path.parent() { + crate::state::ensure_dir_restricted(dir)?; + } + let mut line = serde_json::to_vec(record).map_err(std::io::Error::other)?; + line.push(b'\n'); + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + f.write_all(&line)?; + f.flush()?; + crate::control::restrict_permissions(&self.path); + Ok(()) + } + + /// What this store holds for `epoch`. + /// + /// A line that names the epoch but does not parse yields [`StoredEpoch::Unreadable`] rather + /// than [`StoredEpoch::Absent`]. That distinction is why the scan reads the raw `epoch` field + /// separately from the full record: a record that fails to deserialise still usually carries a + /// readable epoch number, and attributing it is the difference between the node saying "I lost + /// this" and the node saying "this never happened". + pub fn get(&self, epoch: u64) -> StoredEpoch { + let Ok(text) = std::fs::read_to_string(&self.path) else { + return StoredEpoch::Absent; + }; + let mut best: Option = None; + let mut saw_unreadable = false; + for line in text.lines().filter(|l| !l.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(rec) if rec.epoch == epoch => { + best = Some(rec); + } + Ok(_) => {} + Err(_) => { + if line_names_epoch(line, epoch) { + saw_unreadable = true; + } + } + } + } + match best { + Some(rec) => StoredEpoch::Found(Box::new(rec)), + None if saw_unreadable => StoredEpoch::Unreadable, + None => StoredEpoch::Absent, + } + } +} + +/// Does an unparseable line claim to be about `epoch`? +/// +/// A deliberately shallow probe: it reads only the `epoch` field, because the line already failed +/// to deserialise as a whole and any deeper interpretation of it would be guessing. +fn line_names_epoch(line: &str, epoch: u64) -> bool { + serde_json::from_str::(line) + .ok() + .and_then(|v| v.get("epoch").and_then(serde_json::Value::as_u64)) + == Some(epoch) +} + +/// What the node knows about which epoch is currently in force. +/// +/// The node does not derive the epoch from the clock. The collateral epoch schedule is a consensus +/// fact anchored on chain, and a node that guessed it would post against the wrong epoch — so the +/// census names the epoch it is working on, and this type carries that answer or the reason there +/// is not one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurrentEpoch { + /// The census settled on this epoch and its inputs are final. + Final(u64), + /// The census reached this epoch but its inputs are still inside the finality depth. + /// + /// The figure would still move, so it is not answerable — but the remedy is only to WAIT, + /// which is a different sentence from "run the census". + BehindFinalityDepth, + /// The node has not censused an epoch. + NotCensused, + /// The node cannot see the chain, so it cannot know whether a record should exist. + NoChainSource, +} + +/// This epoch's per-store requirement, or the named reason the node cannot state it. +/// +/// The margin is deliberately not consulted. `required_per_store_dig_base_units` is the PRE-margin, +/// consensus-derived figure every node derives identically; folding a local preference into it here +/// would make this operator's cushion look like the network's price. +pub fn requirement(store: &EpochRecordStore, current: CurrentEpoch) -> CollateralRequirementResult { + let unknown = |reason| CollateralRequirementResult::Unknown { reason }; + let epoch = match current { + CurrentEpoch::Final(e) => e, + CurrentEpoch::BehindFinalityDepth => { + return unknown(CollateralUnknownReason::BehindFinalityDepth) + } + CurrentEpoch::NotCensused => return unknown(CollateralUnknownReason::NotCensused), + CurrentEpoch::NoChainSource => return unknown(CollateralUnknownReason::NoChainSource), + }; + match store.get(epoch) { + StoredEpoch::Found(rec) => CollateralRequirementResult::Known { + epoch: rec.epoch, + // The version that COMPUTED the epoch, read off the record, never the newest version + // this build implements. The two differ exactly when a node has upgraded mid-schedule, + // which is the one case where the client needs to know the difference. + protocol_version: rec.protocol_version.0, + required_per_store_dig_base_units: rec.required_per_store_dig_base_units, + stores: rec.census.stores, + owners: rec.census.owners, + multiplier_micros: rec.multiplier_micros, + handicap_dig_base_units: rec.handicap_dig_base_units, + }, + StoredEpoch::Absent => unknown(CollateralUnknownReason::NotCensused), + StoredEpoch::Unreadable => unknown(CollateralUnknownReason::RecordUnreadable), + } +} + +/// How much $DIG an operator should hold, and whether they are currently short. +/// +/// See [`FundingState`] for what the states mean and which of them may notify. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct BufferAdvice { + /// `(store, root)` advertisements this node serves and must collateralise. + pub pairs: u64, + /// The pre-margin per-store requirement this advice was computed from. + pub required_per_store_dig_base_units: u64, + /// The margin applied, in basis points. + pub margin_bp: u64, + /// What one epoch's posting costs, margin included: `pairs x margined requirement`. + pub one_epoch_lock_dig_base_units: u64, + /// What the node must be able to cover next epoch if the multiplier rises at its ceiling. + pub next_epoch_ceiling_dig_base_units: u64, + /// The recommended holding — see [`BufferAdvice::recommended_dig_base_units`]'s derivation in + /// [`buffer_advice`]. + pub recommended_dig_base_units: u64, + /// The escalation horizon the recommendation assumes, in epochs. + pub horizon_epochs: u32, + /// The balance this advice was judged against, or `None` when the node does not know it. + pub balance_dig_base_units: Option, + /// Where the operator stands. + pub state: FundingState, + /// How much more $DIG to hold to reach [`Self::recommended_dig_base_units`], or `None` when the + /// balance is unknown. + /// + /// The number a person acts on. "Balance low" is not actionable; "add 3.250 DIG" is. + pub shortfall_to_recommended_dig_base_units: Option, +} + +/// Where an operator's $DIG balance stands against their collateral obligations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FundingState { + /// The node cannot determine its requirement, its served pairs, or its balance. + /// + /// **Never notifies, and never degrades to a number.** An unknown requirement rendered as a + /// zero cost is the money lie this whole surface exists to prevent, so the absence is carried + /// as a state rather than as a `0`. + Unknown, + /// Cannot cover the CURRENT epoch: stores are already going uncollateralised. + ShortNow, + /// Covers the current epoch but could not cover the next one if the multiplier rises at its + /// ceiling. + DangerouslyLow, + /// Fine for several epochs, with no cushion for a delayed reclaim or sustained escalation. + /// + /// **Readout only — this state MUST NOT raise a notification.** A healthy node sits here much + /// of the time, and a recurring alert an operator learns to dismiss is how they come to dismiss + /// [`ShortNow`](Self::ShortNow) and [`DangerouslyLow`](Self::DangerouslyLow) too. + BelowRecommendedBuffer, + /// At or above the recommended buffer. + Adequate, +} + +impl FundingState { + /// May this state raise a notification? + /// + /// Exactly two states may. Stated as a function rather than left to each surface to decide, + /// because two surfaces deciding independently is how the readout-only state acquires an alert. + pub fn is_notification(self) -> bool { + matches!(self, FundingState::ShortNow | FundingState::DangerouslyLow) + } + + /// The stable snake_case token, for CLI and JSON output. + pub const fn token(self) -> &'static str { + match self { + FundingState::Unknown => "unknown", + FundingState::ShortNow => "short_now", + FundingState::DangerouslyLow => "dangerously_low", + FundingState::BelowRecommendedBuffer => "below_recommended_buffer", + FundingState::Adequate => "adequate", + } + } +} + +/// The most the multiplier can be after `epochs` of uninterrupted escalation, in millionths. +/// +/// `UP_STEP_DENOM` is 8, so the per-epoch ceiling is `+12.5%` and the bound COMPOUNDS: `(9/8)^n`. +/// Computed in `u128` from the crate's own constant rather than from a table of pre-multiplied +/// factors, so raising the horizon cannot leave a stale factor behind. +/// +/// This is a **worst case, not a forecast**. Inside the controller's dead band the multiplier does +/// not move at all, and a network that is not contracting will never approach this line. +fn escalation_ceiling_micros(epochs: u32) -> u128 { + let mut num: u128 = u128::from(MULT_SCALE); + for _ in 0..epochs { + num = num * u128::from(UP_STEP_DENOM + 1) / u128::from(UP_STEP_DENOM); + } + num +} + +/// Scale `amount` by a millionths factor, saturating rather than wrapping. +fn scale_micros(amount: u64, micros: u128) -> u64 { + u64::try_from(u128::from(amount) * micros / u128::from(MULT_SCALE)).unwrap_or(u64::MAX) +} + +/// Compute the funding advice. +/// +/// # Why the recommendation is not "requirement x epochs of runway" +/// +/// **Collateral is RECLAIMED, not spent.** Each pass creates the coins for `(store, root, epoch n)` +/// and reclaims epoch `n-1`, and the reclaims run FIRST and are never gated on funds — so the +/// returned collateral funds the creates behind it. Steady state is therefore roughly ONE epoch's +/// lock, not one per epoch. Multiplying by a runway would overstate the answer by the epoch count +/// and tell an operator to buy many times what they need, which is advice they would rightly +/// ignore. +/// +/// The real peak is the **transition overlap**: epoch `n`'s coins exist before `n-1`'s are +/// reclaimed, or a reclaim is delayed or fails. So the recommendation covers holding the current +/// epoch's lock and the next one's simultaneously, with the next one allowed to have escalated over +/// the horizon: +/// +/// ```text +/// recommended = lock + lock x (9/8)^HORIZON +/// ``` +/// +/// which is one epoch's lock, plus the overlap, plus escalation headroom — the three things named, +/// in one expression rather than three that could double-count each other. +pub fn buffer_advice( + pairs: u64, + requirement: &CollateralRequirementResult, + margin_bp: u64, + balance_dig_base_units: Option, +) -> BufferAdvice { + let CollateralRequirementResult::Known { + required_per_store_dig_base_units, + .. + } = *requirement + else { + return BufferAdvice { + pairs, + required_per_store_dig_base_units: 0, + margin_bp, + one_epoch_lock_dig_base_units: 0, + next_epoch_ceiling_dig_base_units: 0, + recommended_dig_base_units: 0, + horizon_epochs: BUFFER_HORIZON_EPOCHS, + balance_dig_base_units, + state: FundingState::Unknown, + shortfall_to_recommended_dig_base_units: None, + }; + }; + + // The margined per-store posting, from the crate — it rounds UP, and a re-derivation that + // rounded down would post a base unit short of what qualifies. + let per_store = apply_safety_margin(required_per_store_dig_base_units, margin_bp); + let lock = u64::try_from(u128::from(pairs) * u128::from(per_store)).unwrap_or(u64::MAX); + + let one_epoch_ceiling = escalation_ceiling_micros(1); + let next_epoch_ceiling = scale_micros(lock, one_epoch_ceiling); + let horizon_ceiling = escalation_ceiling_micros(BUFFER_HORIZON_EPOCHS); + let recommended = lock.saturating_add(scale_micros(lock, horizon_ceiling)); + + let (state, shortfall) = match balance_dig_base_units { + None => (FundingState::Unknown, None), + Some(balance) => { + let state = if balance < lock { + FundingState::ShortNow + } else if balance < next_epoch_ceiling { + FundingState::DangerouslyLow + } else if balance < recommended { + FundingState::BelowRecommendedBuffer + } else { + FundingState::Adequate + }; + (state, Some(recommended.saturating_sub(balance))) + } + }; + + BufferAdvice { + pairs, + required_per_store_dig_base_units, + margin_bp, + one_epoch_lock_dig_base_units: lock, + next_epoch_ceiling_dig_base_units: next_epoch_ceiling, + recommended_dig_base_units: recommended, + horizon_epochs: BUFFER_HORIZON_EPOCHS, + balance_dig_base_units, + state, + shortfall_to_recommended_dig_base_units: shortfall, + } +} + +/// Render a DIG base-unit amount as decimal DIG, e.g. `1_047` -> `"1.047"`. +/// +/// Formatted from the integer rather than through a float: `1e-3` steps are exactly the resolution +/// an `f64` starts rounding at scale, and a rounded figure about somebody's money is the class of +/// lie this module exists to avoid. +pub fn format_dig(base_units: u64) -> String { + format!("{}.{:03}", base_units / 1_000, base_units % 1_000) +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_mirror_collateral::{required_per_store, EpochCensus}; + + /// A record with independently-chosen fields. + /// + /// Every parameter is varied by at least one test. A helper that pinned any of them would make + /// that field untestable through this module, which is exactly how three defects hid in this + /// crate family in a single day. + fn record(epoch: u64, multiplier_micros: u64, owners: u64, stores: u64) -> EpochRecord { + let mut rec = EpochRecord::bootstrap(); + rec.epoch = epoch; + rec.multiplier_micros = multiplier_micros; + rec.census = EpochCensus { + stores, + owners, + ..rec.census + }; + rec.required_per_store_dig_base_units = required_per_store(multiplier_micros, owners); + rec + } + + fn store_at(dir: &Path) -> EpochRecordStore { + EpochRecordStore::at(dir.join(EPOCH_RECORD_FILE)) + } + + #[test] + fn a_config_predating_the_margin_field_loads_as_the_default_not_zero() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join(COLLATERAL_CONFIG_FILE), b"{}").expect("write"); + // Zero is a deliberate choice to post exactly; a config that never expressed one must not + // be reported as having declined the cushion. + assert_eq!( + CollateralConfig::load_from(dir.path()).margin_bp, + SAFETY_MARGIN_BP_DEFAULT + ); + assert_ne!(SAFETY_MARGIN_BP_DEFAULT, 0, "the default must not be zero"); + } + + #[test] + fn a_stored_margin_survives_a_round_trip_at_a_value_that_is_not_the_default() { + let dir = tempfile::tempdir().expect("tempdir"); + // Deliberately not the default: a save/load that silently discarded the value would still + // pass if the fixture used the default. + CollateralConfig { margin_bp: 250 } + .save_to(dir.path()) + .expect("save"); + assert_eq!(CollateralConfig::load_from(dir.path()).margin_bp, 250); + } + + #[test] + fn requirement_reports_the_stored_epochs_figures_not_a_recomputation() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_at(dir.path()); + // Two epochs present, with DIFFERENT multipliers and owner counts, so answering with the + // wrong one is observable. A single-record fixture could not see that. + store.put(&record(7, 1_000_000, 1_000, 40)).expect("put"); + store.put(&record(8, 500_000, 600, 25)).expect("put"); + + let answer = requirement(&store, CurrentEpoch::Final(8)); + let CollateralRequirementResult::Known { + epoch, + required_per_store_dig_base_units, + stores, + owners, + multiplier_micros, + .. + } = answer + else { + panic!("expected a known requirement, got {answer:?}"); + }; + assert_eq!(epoch, 8); + assert_eq!(multiplier_micros, 500_000); + assert_eq!(owners, 600); + assert_eq!(stores, 25); + // Pinned against the crate AND against a concrete value: a symbolic assertion alone would + // move with a mutation that changed both sides. + assert_eq!( + required_per_store_dig_base_units, + required_per_store(500_000, 600) + ); + assert_eq!(required_per_store_dig_base_units, 900); + } + + #[test] + fn each_missing_fact_gets_its_own_reason() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_at(dir.path()); + store.put(&record(3, 1_000_000, 1_000, 10)).expect("put"); + + let reason = |c| match requirement(&store, c) { + CollateralRequirementResult::Unknown { reason } => reason, + other => panic!("expected unknown, got {other:?}"), + }; + // An epoch the node holds no record for is NOT the same as having no chain to look at, + // and neither is the same as waiting for finality. The remedies differ, so the tokens must. + assert_eq!(reason(CurrentEpoch::Final(4)), CollateralUnknownReason::NotCensused); + assert_eq!(reason(CurrentEpoch::NotCensused), CollateralUnknownReason::NotCensused); + assert_eq!( + reason(CurrentEpoch::BehindFinalityDepth), + CollateralUnknownReason::BehindFinalityDepth + ); + assert_eq!( + reason(CurrentEpoch::NoChainSource), + CollateralUnknownReason::NoChainSource + ); + } + + #[test] + fn a_corrupt_record_for_the_asked_epoch_is_unreadable_not_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_at(dir.path()); + // A healthy neighbouring record, so the fixture keeps an honest control: a store that was + // entirely corrupt could not show that the corruption was ATTRIBUTED to epoch 5. + store.put(&record(4, 1_000_000, 1_000, 10)).expect("put"); + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(store.path()) + .expect("open"); + writeln!(f, r#"{{"epoch":5,"multiplier_micros":"corrupt"}}"#).expect("write"); + drop(f); + + assert_eq!(store.get(5), StoredEpoch::Unreadable); + assert!(matches!( + requirement(&store, CurrentEpoch::Final(5)), + CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::RecordUnreadable + } + )); + // The neighbour is still answerable — the corruption did not swallow the store. + assert!(matches!( + requirement(&store, CurrentEpoch::Final(4)), + CollateralRequirementResult::Known { .. } + )); + // And an epoch nobody wrote anything about is still ABSENT, not unreadable. + assert_eq!(store.get(6), StoredEpoch::Absent); + } + + /// A `Known` requirement with an explicitly-chosen figure, so the buffer tests never depend on + /// the record store. + fn known(required: u64) -> CollateralRequirementResult { + CollateralRequirementResult::Known { + epoch: 9, + protocol_version: 1, + required_per_store_dig_base_units: required, + stores: 12, + owners: 300, + multiplier_micros: 800_000, + handicap_dig_base_units: 0, + } + } + + #[test] + fn the_recommendation_is_about_two_epochs_of_lock_not_one_per_epoch() { + // 10 pairs x 1.000 DIG, no margin, so the lock is exactly 10.000 DIG. + let a = buffer_advice(10, &known(1_000), 0, Some(0)); + assert_eq!(a.one_epoch_lock_dig_base_units, 10_000); + // (9/8)^4 = 1.601806x, so the recommendation is lock x 2.601806 -- NOT lock x 4 (a runway), + // which is the overstatement this shape exists to avoid. + assert_eq!(a.recommended_dig_base_units, 26_018); + assert!( + a.recommended_dig_base_units < a.one_epoch_lock_dig_base_units * 3, + "a runway-shaped recommendation would be at least 4x the lock" + ); + assert_eq!(a.horizon_epochs, BUFFER_HORIZON_EPOCHS); + } + + #[test] + fn the_margin_raises_the_lock_and_is_reported() { + // Same pairs and requirement as above, margin the ONLY thing varied, so the effect is + // attributable to it. + let none = buffer_advice(10, &known(1_000), 0, Some(0)); + let generous = buffer_advice(10, &known(1_000), 500, Some(0)); + assert_eq!(generous.margin_bp, 500); + // +5% on 1.000 DIG is 1.050, ten of them 10.500. + assert_eq!(generous.one_epoch_lock_dig_base_units, 10_500); + assert!(generous.one_epoch_lock_dig_base_units > none.one_epoch_lock_dig_base_units); + } + + #[test] + fn the_three_states_sit_at_the_boundaries_they_name() { + let req = known(1_000); + // lock = 10.000, next-epoch ceiling = 11.250, recommended = 26.018. + let at = |balance| buffer_advice(10, &req, 0, Some(balance)).state; + + // Each bound is pinned from BOTH sides: one under must move the state, at-bound must not. + assert_eq!(at(9_999), FundingState::ShortNow); + assert_eq!(at(10_000), FundingState::DangerouslyLow); + assert_eq!(at(11_249), FundingState::DangerouslyLow); + assert_eq!(at(11_250), FundingState::BelowRecommendedBuffer); + assert_eq!(at(26_017), FundingState::BelowRecommendedBuffer); + assert_eq!(at(26_018), FundingState::Adequate); + } + + #[test] + fn only_the_two_urgent_states_may_notify() { + assert!(FundingState::ShortNow.is_notification()); + assert!(FundingState::DangerouslyLow.is_notification()); + // The one that must stay quiet. A normal node sits here much of the time, and an alert an + // operator learns to dismiss teaches them to dismiss the two above it. + assert!(!FundingState::BelowRecommendedBuffer.is_notification()); + assert!(!FundingState::Adequate.is_notification()); + assert!(!FundingState::Unknown.is_notification()); + } + + #[test] + fn an_unknown_requirement_never_becomes_a_zero_cost_or_an_alert() { + let unknown = CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::NotCensused, + }; + // A balance of zero would look exactly like ShortNow to any implementation that rendered + // an absent requirement as a number. + let a = buffer_advice(10, &unknown, 100, Some(0)); + assert_eq!(a.state, FundingState::Unknown); + assert!(!a.state.is_notification()); + assert_eq!(a.shortfall_to_recommended_dig_base_units, None); + } + + #[test] + fn an_unknown_balance_is_not_a_shortfall() { + // The requirement is perfectly known here; only the BALANCE is missing. An implementation + // treating a missing balance as zero would report ShortNow and alarm the operator. + let a = buffer_advice(10, &known(1_000), 100, None); + assert_eq!(a.state, FundingState::Unknown); + assert_eq!(a.shortfall_to_recommended_dig_base_units, None); + // The advice still carries the figures it does know, so a surface can show the working. + assert!(a.one_epoch_lock_dig_base_units > 0); + } + + #[test] + fn the_shortfall_is_the_number_to_add() { + let a = buffer_advice(10, &known(1_000), 0, Some(20_000)); + // recommended 26.018 - held 20.000 = add 6.018 DIG. + assert_eq!(a.shortfall_to_recommended_dig_base_units, Some(6_018)); + assert_eq!(format_dig(6_018), "6.018"); + // Above the recommendation there is nothing to add, and it must not underflow. + let rich = buffer_advice(10, &known(1_000), 0, Some(90_000)); + assert_eq!(rich.shortfall_to_recommended_dig_base_units, Some(0)); + assert_eq!(rich.state, FundingState::Adequate); + } + + #[test] + fn escalation_compounds_rather_than_multiplying() { + // A linear bound would give 1 + 4 x 0.125 = 1.5x; the compounding one gives 1.6018x. The + // two differ by enough that a fixture at any single horizon could not tell them apart -- + // hence checking the shape across several. + assert_eq!(escalation_ceiling_micros(0), 1_000_000); + assert_eq!(escalation_ceiling_micros(1), 1_125_000); + assert_eq!(escalation_ceiling_micros(2), 1_265_625); + assert_eq!(escalation_ceiling_micros(4), 1_601_806); + } + + #[test] + fn pairs_scale_the_lock_linearly() { + // Pairs is the field a fixture is most likely to pin at 1; vary it. + let one = buffer_advice(1, &known(2_500), 0, Some(0)); + let forty = buffer_advice(40, &known(2_500), 0, Some(0)); + assert_eq!(one.one_epoch_lock_dig_base_units, 2_500); + assert_eq!(forty.one_epoch_lock_dig_base_units, 100_000); + assert_eq!(forty.pairs, 40); + } + + #[test] + fn format_dig_keeps_three_decimals() { + // The base unit is 0.001 DIG; dropping a trailing zero would misstate an amount by 100x. + assert_eq!(format_dig(1), "0.001"); + assert_eq!(format_dig(1_000), "1.000"); + assert_eq!(format_dig(1_047), "1.047"); + assert_eq!(format_dig(10_500), "10.500"); + } +} diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index b18f989f..c15ce4a7 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -32,6 +32,10 @@ //! - [`service`] — OS-service install/uninstall/start/stop/status. pub mod cli; +/// The deterministic mirror-coin collateral model: the per-epoch record store, the local +/// safety margin, and the funding advice built on them. Every figure comes out of +/// `dig-mirror-collateral`; no formula is restated. +pub mod collateral; pub mod config; /// Pure HTTP helpers for the local plaintext content-serve surface (#289): `/s/...` route parsing, /// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the From 8596b5e9873e5d8bb4b25d6a4ab57db81137d985 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 02:38:41 -0700 Subject: [PATCH 03/23] feat(spend-audit): cursor paging and an explicit completeness flag The PR#31 gate measured that control.spends.list is not a bare dispatch arm: SpendQuery carried no after_id although the method is cursor-paginated, and SpendLog::query could not report 'complete' -- the contract distinguishes 'that is all' from 'we stopped here', and on an audit record those read the same and mean opposite things. * SpendQuery.after_id -- positional, not a filter, so matches() ignores it. Resuming by time would drop every spend sharing the boundary millisecond, and automated spends are issued by a cycle so several routinely share one. * SpendLedger.complete -- computed from whether rows were actually withheld, never from whether the page came out full. A matching set that is an exact multiple of the page size fills its last page and would otherwise read as truncated forever. * SpendLog::cursor_of -- the id of the last row HANDED to the caller, never a marker for where the record got to. An unknown cursor is REFUSED. Restarting would repeat rows the caller has seen; an empty page would either end the walk early or leave no cursor to advance and loop forever. The fixture puts six rows across three timestamps with two ties, and pages at 2, 3 and 4 so a boundary falls inside a tie and so one page is exactly full and final. A fixture with distinct timestamps or a page size of 6 could not tell a correct cursor from a time-based one. Co-Authored-By: Claude --- crates/dig-node-service/src/entrypoint.rs | 5 + crates/dig-node-service/src/spend_audit.rs | 271 ++++++++++++++++++++- 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 61214e47..9bb854c2 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -438,6 +438,9 @@ enum SpendsCommand { /// Only this outcome: `pending`, `submitted`, `confirmed`, `failed` or `unresolved`. #[arg(long)] status: Option, + /// Resume strictly after this audit id -- the `cursor` the previous page printed. + #[arg(long)] + after_id: Option, /// Keep at most this many rows, newest first. #[arg(long)] limit: Option, @@ -816,6 +819,7 @@ fn spends_action(cmd: Option) -> crate::spend_audit_cli::SpendsAc store, kind, status, + after_id, limit, }) => SpendsAction::List(SpendQuery { since_ms, @@ -823,6 +827,7 @@ fn spends_action(cmd: Option) -> crate::spend_audit_cli::SpendsAc store_id: store, kind, status, + after_id, limit, }), Some(SpendsCommand::Show { id }) => SpendsAction::Show { id }, diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 6d569e38..eb2129c2 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -403,6 +403,14 @@ pub struct SpendQuery { pub kind: Option, /// Only this status token ([`SpendStatus::token`]). pub status: Option, + /// Resume STRICTLY AFTER this audit id, in the read's documented order. `None` starts at the + /// newest matching row. + /// + /// Positional, not a filter, which is why [`SpendQuery::matches`] does not consider it: it + /// names a place in an ordering rather than a property of a record. Resuming by TIME instead + /// would drop every spend sharing the boundary millisecond, and automated spends are issued by + /// a cycle, so several routinely share one. + pub after_id: Option, /// Cap the number of rows returned, newest first. `None` = every match. pub limit: Option, } @@ -446,6 +454,17 @@ pub struct SpendLedger { pub records: Vec, /// Lines in the file that could not be parsed as a record. pub unreadable_lines: usize, + /// Is this the WHOLE matching set, or was it TRUNCATED? + /// + /// Stated rather than inferred from the row count. A caller cannot tell "there are no more + /// spends" from "we stopped telling you" by length alone -- a matching set that is an exact + /// multiple of the page size makes the last full page indistinguishable from a truncated one -- + /// and on an audit record those two read the same and mean opposite things. + /// + /// Spelled positively so that the reading a caller falls into when the field is defaulted is + /// the SAFE one: `false` means "there may be more", which costs at worst one redundant request, + /// whereas a `truncated` flag would default to "this is everything" and end a walk early. + pub complete: bool, } /// The append-only audit file. @@ -499,21 +518,62 @@ impl SpendLog { pub fn ledger(&self) -> std::io::Result { let text = match std::fs::read_to_string(&self.path) { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SpendLedger::default()), + // A node that has never spent automatically is the ordinary case, and its answer is + // COMPLETE -- `SpendLedger::default()` alone would say "there may be more", which is + // the safe default for a page but the wrong answer for the whole record. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(SpendLedger { + complete: true, + ..SpendLedger::default() + }) + } Err(e) => return Err(e), }; Ok(fold(&text)) } - /// The ledger, filtered. Newest-initiated first, then `limit` applied. + /// One PAGE of the ledger: filtered, ordered, resumed from `after_id`, then capped by `limit`. + /// + /// The returned [`SpendLedger::complete`] states whether the page is the whole matching set. + /// It is computed from whether rows were actually withheld, never from whether the page came + /// out full, because a matching set that is an exact multiple of the page size fills the last + /// page and would read as truncated forever. + /// + /// # An unknown cursor is an ERROR, not an empty page and not a restart + /// + /// A caller passing an `after_id` that is not in the matching set has lost its place. Silently + /// restarting from the newest row would repeat rows it has already seen, and returning an empty + /// page would either end its walk early (with `complete`) or leave it with no cursor to advance + /// (without), i.e. looping forever. Refusing says what happened and terminates. pub fn query(&self, q: &SpendQuery) -> std::io::Result { let mut ledger = self.ledger()?; ledger.records.retain(|r| q.matches(r)); + + if let Some(after) = &q.after_id { + let Some(at) = ledger.records.iter().position(|r| &r.id == after) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown cursor {after:?}: no matching spend has that id"), + )); + }; + ledger.records.drain(..=at); + } + if let Some(n) = q.limit { + ledger.complete = ledger.records.len() <= n; ledger.records.truncate(n); } Ok(ledger) } + + /// The id to resume this page from -- the id of the last row actually HANDED to the caller, or + /// `None` for an empty page. + /// + /// A method on the log rather than on the ledger's caller so that the cursor and the ordering + /// cannot be defined in two places. It is never a marker for where the record "got to". + pub fn cursor_of(ledger: &SpendLedger) -> Option { + ledger.records.last().map(|r| r.id.clone()) + } } /// Fold JSONL text into the current ledger: highest revision per id wins, newest-initiated first. @@ -545,6 +605,9 @@ fn fold(text: &str) -> SpendLedger { SpendLedger { records, unreadable_lines: unreadable, + // The whole file was folded, so this is the entire record by construction. Paging narrows + // it afterwards, in `SpendLog::query`, which is the only place that can withhold a row. + complete: true, } } @@ -1320,6 +1383,8 @@ mod tests { }, ], unreadable_lines: 0, + // A hand-built whole record, not a page. + complete: true, }; let chain = FakeChain(vec![ "coin-agreed".to_string(), @@ -1350,6 +1415,8 @@ mod tests { let ledger = SpendLedger { records: vec![r], unreadable_lines: 0, + // A hand-built whole record, not a page. + complete: true, }; let report = @@ -1374,6 +1441,8 @@ mod tests { let ledger = SpendLedger { records: vec![r], unreadable_lines: 0, + // A hand-built whole record, not a page. + complete: true, }; let report = reconcile(&ledger, &FakeChain(vec![]), "ph").expect("reconcile"); assert!(report.is_clean()); @@ -1435,6 +1504,8 @@ mod tests { let ledger = SpendLedger { records: vec![broadcast_failed, signing_failed], unreadable_lines: 0, + // A hand-built whole record, not a page. + complete: true, }; // The broadcast-failed spend DID land; a coin nobody recorded is also present. let chain = FakeChain(vec![ @@ -1548,4 +1619,200 @@ mod tests { let back: SpendRecord = serde_json::from_value(v).expect("deserialize"); assert_eq!(back, r); } + + /// Six spends across THREE distinct timestamps, two sharing a millisecond. + /// + /// The shared millisecond is the whole point: automated spends are issued by a cycle, so + /// several routinely land in one instant, and a cursor that resumed by TIME rather than by id + /// would drop one of a tied pair. A fixture whose timestamps were all distinct could not tell a + /// correct cursor from a time-based one. + /// + /// The kinds and store ids are varied too, so a filter that ignored its argument would be + /// visible rather than vacuously satisfied. + fn paging_log() -> (tempfile::TempDir, SpendLog) { + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let rows = [ + ("a", 300u64, kinds::MIRROR_COIN, Some("store-1")), + ("b", 200, kinds::MIRROR_COIN, Some("store-1")), + ("c", 200, kinds::MIRROR_COIN, Some("store-2")), + ("d", 100, "profile-mint", Some("store-2")), + ("e", 100, kinds::MIRROR_COIN, None), + ("f", 100, kinds::MIRROR_COIN, Some("store-1")), + ]; + for (id, ms, kind, store) in rows { + let rec = record_at(id, ms, kind, store); + log.append(&rec).expect("append"); + } + (dir, log) + } + + /// Walk the whole record one page at a time, as a client would. + fn walk(log: &SpendLog, page: usize) -> (Vec, usize) { + let mut seen = Vec::new(); + let mut after = None; + let mut requests = 0; + loop { + let q = SpendQuery { + after_id: after.clone(), + limit: Some(page), + ..SpendQuery::default() + }; + let ledger = log.query(&q).expect("page"); + requests += 1; + seen.extend(ledger.records.iter().map(|r| r.id.clone())); + if ledger.complete { + return (seen, requests); + } + after = SpendLog::cursor_of(&ledger); + assert!(after.is_some(), "an incomplete page must hand back a cursor"); + assert!(requests < 20, "walk did not terminate"); + } + } + + #[test] + fn the_documented_order_is_newest_first_then_id_ascending() { + let (_dir, log) = paging_log(); + let all = log.query(&SpendQuery::default()).expect("all"); + // 300 first; then the 200-tie broken by ASCENDING id (b before c); then the 100-tie + // (d, e, f). A descending-id tiebreak would give c,b and f,e,d and is the nearest wrong + // implementation this ordering has. + assert_eq!( + all.records.iter().map(|r| r.id.as_str()).collect::>(), + ["a", "b", "c", "d", "e", "f"] + ); + } + + #[test] + fn a_walk_visits_every_row_exactly_once_across_a_tied_millisecond() { + let (_dir, log) = paging_log(); + // Page size 2 puts a boundary INSIDE the 100ms tie (d|e), which is precisely where a + // time-based cursor loses or repeats a row. A page size of 6 would see nothing. + let (seen, requests) = walk(&log, 2); + assert_eq!(seen, ["a", "b", "c", "d", "e", "f"]); + assert!(requests > 1, "page size 2 over 6 rows must take several requests"); + // And an odd page size, so the last page is PARTIAL rather than exactly full. + assert_eq!(walk(&log, 4).0, ["a", "b", "c", "d", "e", "f"]); + } + + #[test] + fn complete_is_not_inferred_from_a_full_page() { + let (_dir, log) = paging_log(); + // Six rows, page size 3: the first page is exactly full AND truncated, the second is + // exactly full AND the end. Any implementation deriving `complete` from + // `records.len() < limit` reports both as incomplete and walks forever. + let first = log + .query(&SpendQuery { + limit: Some(3), + ..SpendQuery::default() + }) + .expect("first"); + assert_eq!(first.records.len(), 3); + assert!(!first.complete, "rows were withheld"); + + let second = log + .query(&SpendQuery { + after_id: SpendLog::cursor_of(&first), + limit: Some(3), + ..SpendQuery::default() + }) + .expect("second"); + assert_eq!(second.records.len(), 3); + assert!(second.complete, "an exactly-full LAST page is still complete"); + assert_eq!(walk(&log, 3).1, 2, "the walk must stop after two requests"); + } + + #[test] + fn an_unlimited_read_and_an_empty_record_are_both_complete() { + let (_dir, log) = paging_log(); + assert!(log.query(&SpendQuery::default()).expect("all").complete); + // A node that has never spent automatically: an empty COMPLETE answer, never a defaulted + // "there may be more" that would send a client back for a second look forever. + let empty = SpendLog::at( + tempfile::tempdir() + .expect("tempdir") + .path() + .join("never-written.jsonl"), + ); + let page = empty.query(&SpendQuery::default()).expect("empty"); + assert!(page.records.is_empty()); + assert!(page.complete); + assert_eq!(SpendLog::cursor_of(&page), None); + } + + #[test] + fn the_cursor_is_the_last_row_handed_over_not_the_end_of_the_record() { + let (_dir, log) = paging_log(); + let first = log + .query(&SpendQuery { + limit: Some(2), + ..SpendQuery::default() + }) + .expect("first"); + // "b", the last row the caller was HANDED -- not "f", where the record got to. + assert_eq!(SpendLog::cursor_of(&first), Some("b".to_string())); + } + + #[test] + fn a_cursor_narrowed_by_a_filter_still_names_a_position_in_that_filtered_order() { + let (_dir, log) = paging_log(); + // store-1 holds a, b, f. Resuming after "b" within that filter must give exactly [f] -- + // not [c, d, e, f], which is what a cursor resolved against the UNFILTERED order returns. + let page = log + .query(&SpendQuery { + store_id: Some("store-1".to_string()), + after_id: Some("b".to_string()), + ..SpendQuery::default() + }) + .expect("filtered page"); + assert_eq!( + page.records.iter().map(|r| r.id.as_str()).collect::>(), + ["f"] + ); + assert!(page.complete); + } + + #[test] + fn an_unknown_cursor_is_refused_rather_than_restarting_or_ending_the_walk() { + let (_dir, log) = paging_log(); + // "d" exists in the record but NOT in the store-1 matching set, so this is the realistic + // form of a lost place rather than an obviously bogus id -- an implementation that only + // rejected ids absent from the whole file would accept it and silently restart. + let err = log + .query(&SpendQuery { + store_id: Some("store-1".to_string()), + after_id: Some("d".to_string()), + ..SpendQuery::default() + }) + .expect_err("an unknown cursor must be refused"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!(err.to_string().contains("cursor"), "{err}"); + } + + #[test] + fn unreadable_lines_survive_paging_and_are_not_a_page_count() { + let (_dir, log) = paging_log(); + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(log.path()) + .expect("open"); + writeln!(f, "{{not json").expect("write"); + writeln!(f, "also not json").expect("write"); + drop(f); + + // Reported on EVERY page, and it counts the whole record -- a corrupt entry has no parsed + // timestamp and no parsed id, so it cannot be attributed to a page. A per-page count would + // read as "two rows are missing from THIS page", which is a different and false claim. + let page = log + .query(&SpendQuery { + limit: Some(1), + ..SpendQuery::default() + }) + .expect("page"); + assert_eq!(page.records.len(), 1); + assert_eq!(page.unreadable_lines, 2); + assert!(!page.complete); + } + } From 02bd05ce89a9761e75e91c03eb6101da4d5fcd81 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 02:55:51 -0700 Subject: [PATCH 04/23] feat(control): serve spends.list and the three collateral methods Closes the drift the contract bump exposed: four published methods with no server. All four now dispatch, and the conformance test that named them is green. control.spends.list -- one page of the automated-spend record, decoded through SpendsListParams so the contract's own page-bound validation runs without this handler remembering to. control.collateral.requirement -- the consensus-derived per-store figure, or a NAMED reason. Never a zero, never a stale epoch's figure as this epoch's. control.collateral.margin.get -- the local margin; a config predating the field reads as +1%, never 0. control.collateral.margin.set -- persisted before it is reported, and a value over the ceiling is refused rather than clamped, so stored intent and node behaviour cannot disagree on the money path. A record that could not be READ is now SPEND_AUDIT_UNREADABLE (-32048, taken from the shared catalogue rather than restated) and never an empty page: 'nothing to report' is the answer a person stops investigating on. CLI verbs, so a headless machine can drive all of it: dign collateral requirement dign collateral margin dign collateral margin set The margin is shown with what it costs, not as a bare setting, and a preset resolves to dig-mirror-collateral's own constant -- a second spelling of 'generous' is how two surfaces come to post different amounts for one choice. An unrecognised word is refused rather than falling through to the default. Co-Authored-By: Claude --- crates/dig-node-service/src/control.rs | 205 +++++++++++++++++++++ crates/dig-node-service/src/control_cli.rs | 118 ++++++++++++ crates/dig-node-service/src/entrypoint.rs | 80 ++++++++ crates/dig-node-service/src/meta.rs | 17 ++ 4 files changed, 420 insertions(+) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 431c4352..0af593e3 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -199,6 +199,10 @@ pub const CONTROL_METHODS: &[&str] = &[ "control.wallet.reservations.reserve", "control.wallet.reservations.release", "control.wallet.broadcast", + "control.spends.list", + "control.collateral.requirement", + "control.collateral.margin.get", + "control.collateral.margin.set", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -258,6 +262,10 @@ pub const OWNED_CONTROL_METHODS: &[&str] = &[ "control.wallet.reservations.reserve", "control.wallet.reservations.release", "control.wallet.broadcast", + "control.spends.list", + "control.collateral.requirement", + "control.collateral.margin.get", + "control.collateral.margin.set", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -901,6 +909,15 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.profile.putBody" => profile_put_body(ctx, id, params).await, "control.profile.getBody" => profile_get_body(ctx, id, params).await, "control.peerCounts" => peer_counts(ctx, id).await, + // The automated-spend audit record (dig-node#385) -- a READ of this node's own spending + // history, and the only sanctioned reader of a node-private append-only file. + "control.spends.list" => spends_list(ctx, id, params), + // The deterministic mirror-coin collateral model (dig_ecosystem#3173). The requirement is + // consensus-derived and identical on every node; the margin is a LOCAL preference and the + // two are deliberately served by different methods so neither can be mistaken for the other. + "control.collateral.requirement" => collateral_requirement(ctx, id), + "control.collateral.margin.get" => collateral_margin_get(ctx, id), + "control.collateral.margin.set" => collateral_margin_set(ctx, id, params), "control.wallet.broadcast" => wallet_broadcast(ctx, id, params).await, // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s // own status file + CLI (see `crate::updater`'s module doc for why nothing here @@ -3103,6 +3120,194 @@ async fn profile_get_body(ctx: &ControlCtx, id: Value, params: &Value) -> Value } } +// --------------------------------------------------------------------------------------------- +// The automated-spend audit record and the deterministic mirror-coin collateral model. +// +// `control.spends.list` is the ONLY sanctioned reader of the audit record: it is a node-private +// file, and a second process parsing a growing append-only format is how two views of "what did +// the node spend" start disagreeing, on the one subject where disagreeing is least affordable. +// --------------------------------------------------------------------------------------------- + +/// `control.spends.list` — one page of the automated-spend audit record. +/// +/// Decoding through [`SpendsListParams`] rather than by hand is deliberate: the contract validates +/// the page bound inside its own `Deserialize`, so a limit of `0` or one above the cap is refused +/// here without this handler having to remember to check. A `0` page makes no progress and a caller +/// looping until `complete` would loop forever. +fn spends_list(ctx: &ControlCtx, id: Value, params: &Value) -> Value { + use dig_node_control_interface::params::SpendsListParams; + + let params: SpendsListParams = match serde_json::from_value(params.clone()) { + Ok(p) => p, + Err(e) => return control_error(id, ErrorCode::InvalidParams, e.to_string()), + }; + // `effective_limit` resolves an omitted limit on the contract's terms, so the node and the + // client cannot resolve the same absent field to two different page sizes. + let limit = params.effective_limit() as usize; + let query = crate::spend_audit::SpendQuery { + since_ms: params.since_ms, + until_ms: params.until_ms, + store_id: params.store_id.clone(), + kind: params.kind.clone(), + status: params.status.clone(), + after_id: params.after_id.clone(), + limit: Some(limit), + }; + + let log = crate::spend_audit::SpendLog::at(ctx.state_dir.join("spend-audit.jsonl")); + let ledger = match log.query(&query) { + Ok(l) => l, + // A cursor the record does not know is the caller's mistake, not a broken record. + Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => { + return control_error(id, ErrorCode::InvalidParams, e.to_string()) + } + // Anything else means the node could not LOOK. That is never an empty page: "nothing to + // report" is the answer a person stops investigating on, and it must not be returned for a + // record that could not be read. + Err(e) => { + return control_error( + id, + ErrorCode::SpendAuditUnreadable, + format!("the automated-spend record could not be read: {e}"), + ) + } + }; + + let cursor = crate::spend_audit::SpendLog::cursor_of(&ledger); + control_ok( + id, + json!({ + "spends": ledger.records.iter().map(spend_row).collect::>(), + "complete": ledger.complete, + // Both keys are always PRESENT. `null` is meaningful here, so an absent key must not + // decode into it: a truncated payload would otherwise read as a confident "there is + // nothing to look up". + "cursor": cursor, + "unreadable_lines": ledger.unreadable_lines, + }), + ) +} + +/// One audit row on the wire. +/// +/// Amounts are decimal STRINGS. They carry the full `u64` range, which does not survive a JSON +/// number through an f64 parser, and a silently rounded figure about somebody's money is exactly +/// the lie this record exists to prevent. +fn spend_row(r: &crate::spend_audit::SpendRecord) -> Value { + use crate::spend_audit::SpendStatus; + + // The failure STAGE travels with a failure, never a bare "failed": only `Signing` means the + // money definitely did not move, so flattening the stage would make every client structurally + // unable to tell a person the truth about their money. + let status = match &r.status { + SpendStatus::Pending => json!({ "state": "pending" }), + SpendStatus::Submitted => json!({ "state": "submitted" }), + SpendStatus::Confirmed { height, coin_id } => json!({ + "state": "confirmed", + "height": height, + "coin_id": coin_id.to_string(), + }), + SpendStatus::Failed { stage, reason } => json!({ + "state": "failed", + "stage": stage.to_string(), + "reason": reason, + }), + SpendStatus::Unresolved { reason } => json!({ + "state": "unresolved", + "reason": reason, + }), + }; + json!({ + "id": r.id, + "revision": r.revision, + "kind": r.kind.as_str(), + "purpose": r.purpose, + "authority": { + "principal": r.authority.principal, + "grant": r.authority.grant, + }, + "asset": r.asset.to_string(), + "amount_mojos": r.amount_mojos.to_string(), + "fee_mojos": r.fee_mojos.to_string(), + "store_id": r.store_id, + "initiated_ms": r.initiated_ms, + "updated_ms": r.updated_ms, + "status": status, + "funding_coin_ids": r.funding_coin_ids.iter().map(ToString::to_string).collect::>(), + // Carries its own observed/expected flag, so a client never has to re-derive the + // distinction between the coin the node INTENDS to create and one it has seen on chain. + "chain_reference": r.chain_reference().map(|c| json!({ + "coin_id": c.coin_id.to_string(), + "confirmed": c.confirmed, + })), + }) +} + +/// This node's view of which collateral epoch is in force. +/// +/// The epoch is NOT derived from the clock here. The collateral epoch schedule is a consensus fact +/// anchored on chain, and a node that guessed it would post against the wrong epoch — so until the +/// census names an epoch (dig-node#387), the honest answer is that nothing has been censused. That +/// is a first-class answer, not an error, and it is why `control.collateral.requirement` returns a +/// reason rather than a zero. +fn current_collateral_epoch(_ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { + crate::collateral::CurrentEpoch::NotCensused +} + +/// `control.collateral.requirement` — this epoch's per-store requirement, or a named reason. +/// +/// The local safety margin is deliberately not consulted: this figure is the consensus-derived one +/// every node derives identically, and returning the margined amount would make this operator's +/// preference look like the network's price. +fn collateral_requirement(ctx: &ControlCtx, id: Value) -> Value { + let store = crate::collateral::EpochRecordStore::at( + ctx.state_dir.join("collateral-epochs.jsonl"), + ); + let answer = crate::collateral::requirement(&store, current_collateral_epoch(ctx)); + match serde_json::to_value(&answer) { + Ok(v) => control_ok(id, v), + Err(e) => control_error(id, ErrorCode::ControlError, e.to_string()), + } +} + +/// `control.collateral.margin.get` — the node's local safety margin, in basis points. +fn collateral_margin_get(ctx: &ControlCtx, id: Value) -> Value { + let cfg = crate::collateral::CollateralConfig::load_from(&ctx.state_dir); + control_ok(id, json!({ "margin_bp": cfg.margin_bp })) +} + +/// `control.collateral.margin.set` — persist the margin and return what is now in force. +/// +/// A value above the contract's ceiling is REFUSED rather than clamped, and the returned figure is +/// what was actually stored. Clamping and returning the clamped value would leave the caller's +/// stored intent and the node's behaviour disagreeing on the money path. +fn collateral_margin_set(ctx: &ControlCtx, id: Value, params: &Value) -> Value { + use dig_node_control_interface::params::CollateralMarginSetParams; + + let parsed: CollateralMarginSetParams = match serde_json::from_value(params.clone()) { + Ok(p) => p, + Err(e) => return control_error(id, ErrorCode::InvalidParams, e.to_string()), + }; + let parsed = match parsed.validated() { + Ok(p) => p, + Err(e) => return control_error(id, ErrorCode::InvalidParams, e.message), + }; + + let cfg = crate::collateral::CollateralConfig { + margin_bp: parsed.margin_bp, + }; + // Persisted before it is reported. A margin that lapsed to the default on reboot would silently + // change what the node posts, so a write failure must not be answered with a success. + if let Err(e) = cfg.save_to(&ctx.state_dir) { + return control_error( + id, + ErrorCode::ControlError, + format!("failed to persist the safety margin: {e}"), + ); + } + control_ok(id, json!({ "margin_bp": cfg.margin_bp })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 9d767df1..9887f2a3 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -151,6 +151,23 @@ pub enum ControlAction { /// `control.chiaPeers.list` — the tracked Chia full-node peers, with the `user_managed` flag /// that says which of them are trusted without corroboration. ChiaPeersList, + /// `control.collateral.requirement` — this epoch's per-store collateral requirement, with the + /// census inputs behind it, or a NAMED reason the node cannot state it. + /// + /// The answer is consensus-derived and identical on every node. It carries no safety margin: + /// the margin is this operator's local preference, and folding it in here would make a private + /// choice look like the network's price. + CollateralRequirement, + /// `control.collateral.margin.get` — the node's local safety margin, in basis points. + CollateralMarginGet, + /// `control.collateral.margin.set` — persist the local safety margin. + /// + /// The node is the authoritative home for this setting: the flywheel is headless, so a machine + /// with no GUI must be able to set it from the command line. + CollateralMarginSet { + /// The margin in BASIS POINTS (`100` is +1%), already resolved from any preset name. + margin_bp: u64, + }, /// `control.chiaPeers.remove` — stop trusting a Chia full node (`ban` keeps it excluded so /// discovery cannot re-add it). ChiaPeersRemove { ip: String, ban: bool }, @@ -205,6 +222,9 @@ impl ControlAction { ControlAction::SubsRemove { .. } => "control.unsubscribe", ControlAction::ChiaPeersAdd { .. } => "control.chiaPeers.add", ControlAction::ChiaPeersList => "control.chiaPeers.list", + ControlAction::CollateralRequirement => "control.collateral.requirement", + ControlAction::CollateralMarginGet => "control.collateral.margin.get", + ControlAction::CollateralMarginSet { .. } => "control.collateral.margin.set", ControlAction::ChiaPeersRemove { .. } => "control.chiaPeers.remove", } } @@ -235,6 +255,9 @@ impl ControlAction { match self { ControlAction::ConfigSetUpstream { url } => json!({ "upstream": url }), + // Basis points, never a percentage and never a float. A 1 bp margin (0.01%) is a legal + // choice and any conversion to whole percent would erase it. + ControlAction::CollateralMarginSet { margin_bp } => json!({ "margin_bp": margin_bp }), ControlAction::CacheSetCap { bytes } => json!({ "cap_bytes": bytes }), ControlAction::StoresPin { store } | ControlAction::StoresUnpin { store } @@ -445,6 +468,11 @@ pub fn cli_covered_control_methods() -> Vec<&'static str> { // (dig_ecosystem#2870). ControlAction::ChiaPeersAdd { ip: String::new() }.method(), ControlAction::ChiaPeersList.method(), + // `dign collateral requirement` and `dign collateral margin [set …]` drive the + // deterministic mirror-coin collateral surface (dig_ecosystem#3173). + ControlAction::CollateralRequirement.method(), + ControlAction::CollateralMarginGet.method(), + ControlAction::CollateralMarginSet { margin_bp: 0 }.method(), ControlAction::ChiaPeersRemove { ip: String::new(), ban: false, @@ -462,6 +490,12 @@ pub fn cli_covered_control_methods() -> Vec<&'static str> { "control.peerStatus", "control.peers.connect", "control.peers.ping", + // `dign spends list` drives the automated-spend audit record (dig-node#385). It reads the + // same node-private file through the same `SpendLog`, so the CLI and the control method + // cannot disagree about what the record says -- which is the property the contract's + // "only sanctioned reader" rule is protecting, and the reason this is one verb rather than + // a second parser. + "control.spends.list", // `dig-node pair …` drives the pairing-admin methods (#280). "control.pairing.list", "control.pairing.approve", @@ -669,11 +703,95 @@ fn summarize(method: &str, result: &Value) -> String { }; format!("{coins} direct child coin(s) — one hop, not a lineage{more}") } + "control.collateral.requirement" => summarize_collateral_requirement(result), + // Shown with its real cost, not as a bare setting: a margin is a number of basis points + // until someone says what it costs to hold. + "control.collateral.margin.get" | "control.collateral.margin.set" => { + summarize_margin(result) + } "control.updater.status" => summarize_updater_status(result), _ => compact(result), } } + +/// A concise human line for `control.collateral.requirement`. +/// +/// The census inputs travel with the figure on purpose: a surface that can show only the number can +/// say the price moved, while one holding `stores`, `owners`, the multiplier and the handicap can +/// say WHY it moved — the difference between a figure an operator can weigh and one they can only +/// accept. +/// +/// The unknown branch prints the REASON, never a zero. Each reason names a different missing fact +/// because the remedies differ: a node that has not censused the epoch needs to run the census, +/// whereas one inside the finality depth only needs to wait. +fn summarize_collateral_requirement(result: &Value) -> String { + if result["state"].as_str() == Some("unknown") { + let (reason, remedy) = match result["reason"].as_str().unwrap_or("") { + "not_censused" => ( + "this node has not censused the epoch", + "run the census for this epoch", + ), + "behind_finality_depth" => ( + "the epoch's census inputs are not final yet", + "wait for the chain to settle", + ), + "record_unreadable" => ( + "the record for this epoch could not be read", + "re-run the census for this epoch", + ), + "no_chain_source" => ( + "this node cannot see the chain", + "configure a chain source", + ), + other => (other, "unknown"), + }; + // Emphatically NOT "0 DIG". An absent requirement rendered as a zero cost is the money lie + // this surface exists to prevent. + return format!("collateral requirement UNKNOWN — {reason} · {remedy}"); + } + + let dig = |key: &str| { + crate::collateral::format_dig(result[key].as_u64().unwrap_or(0)) + }; + let multiplier = result["multiplier_micros"].as_u64().unwrap_or(0); + format!( + "epoch {} (protocol v{}) — {} DIG per store, before any safety margin\n \ + from {} advertisement(s) across {} collateralised owner(s) · multiplier {}.{:06}x · \ + handicap {} DIG", + result["epoch"].as_u64().unwrap_or(0), + result["protocol_version"].as_u64().unwrap_or(0), + dig("required_per_store_dig_base_units"), + result["stores"].as_u64().unwrap_or(0), + result["owners"].as_u64().unwrap_or(0), + multiplier / 1_000_000, + multiplier % 1_000_000, + dig("handicap_dig_base_units"), + ) +} + +/// A concise human line for the local safety margin, WITH what it costs. +/// +/// A margin shown alone is a number of basis points and nothing more. Shown beside the per-store +/// amount it adds, it is a decision an operator can make — which is the whole point of exposing the +/// setting rather than just storing it. +fn summarize_margin(result: &Value) -> String { + let bp = result["margin_bp"].as_u64().unwrap_or(0); + let preset = match bp { + b if b == dig_mirror_collateral::SAFETY_MARGIN_BP_TIGHT => " (tight)", + b if b == dig_mirror_collateral::SAFETY_MARGIN_BP_DEFAULT => " (default)", + b if b == dig_mirror_collateral::SAFETY_MARGIN_BP_GENEROUS => " (generous)", + _ => "", + }; + // Percent is shown for readability only; the STORED unit is basis points, and a 1 bp margin + // must still read as 0.01% rather than rounding away to zero. + format!( + "safety margin {bp} bp{preset} = +{}.{:02}% over the per-store requirement", + bp / 100, + bp % 100, + ) +} + /// A concise human line for the auto-update beacon status (`control.updater.status`). The rich /// beacon report is a deeply-nested object; a first-time operator wants the at-a-glance line /// (installed? which version + channel, paused-or-running, the last outcome), with the full detail diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 9bb854c2..d1d0fbd5 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -197,6 +197,15 @@ enum Command { /// A different network from `peers`, which manages DIG gossip peers. Trusting a Chia peer /// grants it authority over this node's wallet replica without corroboration — see /// `chia-peers add --help`. + /// Inspect the collateral this node must post, and set your local safety margin. + /// + /// The requirement is decided by the network and is the same on every node. The margin is + /// yours: a cushion you hold on top, so an epoch whose price rises does not leave your stores + /// uncollateralised. + Collateral { + #[command(subcommand)] + action: Option, + }, ChiaPeers { #[command(subcommand)] action: Option, @@ -575,6 +584,36 @@ enum PeersCommand { /// The ticket reference above is a Rust doc comment on the enum, NOT on a clap `#[derive]` field, /// so it never reaches `--help`. Doc comments on the VARIANTS below are user-facing help text and /// must stay free of internal task numbers (contract §4.3). +/// `dig-node collateral` sub-actions. +#[derive(Subcommand)] +enum CollateralCommand { + /// Show this epoch's per-store collateral requirement (the default with no sub-action). + /// + /// This is the amount BEFORE your safety margin, because it is the figure the network derives + /// and every node derives it identically. If this node has not censused the epoch yet, it says + /// so and why — it never reports a requirement it does not have as zero. + Requirement, + /// Show your local safety margin, and what it adds. + Margin { + #[command(subcommand)] + action: Option, + }, +} + +/// `dig-node collateral margin` sub-actions. +#[derive(Subcommand)] +enum MarginCommand { + /// Set the safety margin, by preset name or in basis points. + /// + /// Presets: `tight` (0.01%), `default` (+1%), `generous` (+5%). Or give a raw number of basis + /// points, where 100 is +1%. At most 10000 (+100%): a cushion larger than the requirement + /// itself is past any honest cushion, and it is REFUSED rather than quietly reduced. + Set { + /// A preset name (`tight`, `default`, `generous`) or a number of basis points. + value: String, + }, +} + #[derive(Subcommand)] enum ChiaPeersCommand { /// List the tracked Chia full-node peers, marking which are trusted. @@ -642,6 +681,7 @@ impl Command { Command::Updater { .. } => "updater", Command::Subscriptions { .. } => "subscriptions", Command::Peers { .. } => "peers", + Command::Collateral { .. } => "collateral", Command::ChiaPeers { .. } => "chia-peers", Command::EnsureHosts => "ensure-hosts", } @@ -797,6 +837,10 @@ pub fn run() -> std::process::ExitCode { Ok(a) => render(peers::run(&config, a), action, json), Err(e) => emit_error(&e, action, json), }, + Command::Collateral { action: cmd } => match collateral_action(cmd) { + Ok(a) => render(control_cli::run(&config, a), action, json), + Err(e) => emit_error(&e, action, json), + }, Command::ChiaPeers { action: cmd } => render( control_cli::run(&config, chia_peers_action(cmd)), action, @@ -965,6 +1009,42 @@ fn subscriptions_action(cmd: Option) -> ControlAction { /// Listing is the default because it is the only harmless one of the three: defaulting to `add` /// would make a bare `dign chia-peers` grant trust, and a default must never be the act that /// costs something. +/// Map the `collateral` subcommand to its [`ControlAction`], resolving a margin preset name. +/// +/// A preset resolves to the SAME basis-point constant `dig-mirror-collateral` publishes, rather +/// than to a number spelled out here. A second spelling of "generous" is how one surface comes to +/// post a different amount than another for a setting the operator believes is one choice. +/// +/// An unrecognised word is REFUSED, never silently treated as a number or as the default: a typo +/// that fell through to the default would change what this node posts without saying so. +fn collateral_action(cmd: Option) -> std::io::Result { + use dig_mirror_collateral::{ + SAFETY_MARGIN_BP_DEFAULT, SAFETY_MARGIN_BP_GENEROUS, SAFETY_MARGIN_BP_TIGHT, + }; + match cmd { + None | Some(CollateralCommand::Requirement) => Ok(ControlAction::CollateralRequirement), + Some(CollateralCommand::Margin { action: None }) => Ok(ControlAction::CollateralMarginGet), + Some(CollateralCommand::Margin { + action: Some(MarginCommand::Set { value }), + }) => { + let margin_bp = match value.as_str() { + "tight" => SAFETY_MARGIN_BP_TIGHT, + "default" => SAFETY_MARGIN_BP_DEFAULT, + "generous" => SAFETY_MARGIN_BP_GENEROUS, + raw => raw.parse::().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "{raw:?} is not a preset (tight, default, generous) nor a basis-point number" + ), + ) + })?, + }; + Ok(ControlAction::CollateralMarginSet { margin_bp }) + } + } +} + fn chia_peers_action(cmd: Option) -> ControlAction { match cmd { None | Some(ChiaPeersCommand::List) => ControlAction::ChiaPeersList, diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 3d2d1d2a..3eaeda6a 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -696,6 +696,13 @@ pub enum ErrorCode { /// Distinct from `WALLET_NO_CHAIN_SOURCE` (nothing to ask) and `WALLET_NOT_SYNCED` /// (not ready). Node error. WalletReadFailed, + /// `-32048` — the automated-spend audit record exists and could not be READ. + /// + /// Never an empty page. "Nothing to report" is the answer a person stops investigating on, so + /// a record the node could not look at must not be returned as one that says the node has + /// spent nothing. A record that was never WRITTEN is a different thing again, and IS an empty + /// page: a node that has never spent automatically is the ordinary case. Shell error. + SpendAuditUnreadable, /// `-32043` — a wallet balance read was refused because the GLOBAL coinset-fallback rate /// bound (#1957) is exhausted: too many arbitrary-address reads have hit the expensive /// fallback in a short window. Defense-in-depth against an open-read amplification/oracle @@ -779,6 +786,9 @@ impl ErrorCode { ErrorCode::WalletReservationsUnavailable => { dig_node_control_interface::ControlErrorCode::WalletReservationsUnavailable.code() } + ErrorCode::SpendAuditUnreadable => { + dig_node_control_interface::ControlErrorCode::SpendAuditUnreadable.code() + } ErrorCode::PeerPingRefused => -32060, ErrorCode::PushPendingLimited => -32016, ErrorCode::ControlIngressLimited => -32033, @@ -806,6 +816,7 @@ impl ErrorCode { ErrorCode::WalletCoinsReserved => "WALLET_COINS_RESERVED", ErrorCode::WalletReservationsUnavailable => "WALLET_RESERVATIONS_UNAVAILABLE", ErrorCode::WalletNodeSpendDisabled => "WALLET_NODE_SPEND_DISABLED", + ErrorCode::SpendAuditUnreadable => "SPEND_AUDIT_UNREADABLE", ErrorCode::PeerPingRefused => "PEER_PING_REFUSED", ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED", ErrorCode::ControlIngressLimited => "CONTROL_INGRESS_LIMITED", @@ -826,6 +837,8 @@ impl ErrorCode { | ErrorCode::ControlError // Minted by the control SERVER at ingress, before the request reaches the node. | ErrorCode::ControlIngressLimited + // The audit record is a node-private FILE read by the shell, not by the node. + | ErrorCode::SpendAuditUnreadable | ErrorCode::ParseError => "shell", ErrorCode::MethodNotFound => "boundary", // The wallet balance read (#1851) is served by the node-custodied wallet backend. @@ -879,6 +892,9 @@ impl ErrorCode { "A wallet balance read of the wallet's own address is still syncing with no fallback." } ErrorCode::WalletReadFailed => "A wallet balance read failed at the DB / chain layer.", + ErrorCode::SpendAuditUnreadable => { + "The automated-spend audit record exists and could not be read." + } ErrorCode::WalletRateLimited => { "A wallet balance read was refused: the open coinset-fallback rate bound is exhausted." } @@ -927,6 +943,7 @@ impl ErrorCode { ErrorCode::WalletNodeSpendDisabled, ErrorCode::WalletCoinsReserved, ErrorCode::WalletReservationsUnavailable, + ErrorCode::SpendAuditUnreadable, ErrorCode::PeerPingRefused, ErrorCode::PushPendingLimited, ErrorCode::ControlIngressLimited, From 7c38016ea7ccae86ad0807be273f08a3b7c7d7f4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:18:10 -0700 Subject: [PATCH 05/23] feat(collateral): the recommended DIG buffer and the three funding states Closes dig-node#389. `dign collateral buffer [--balance ]` composes the requirement, the margin and the served-advertisement count into one number a person acts on -- 'add 9.706 DIG', not 'balance low' -- with the working shown so the figure can be sanity-checked. Also adds the census epoch marker seam: the node reads which epoch the census settled on rather than deriving it from its own clock, because the schedule is a consensus fact and a guess would post against the wrong epoch. Absent or malformed marker means NOT CENSUSED, never epoch zero. Two defects the live run caught, both the same class -- an unknown rendered as a reassuring answer: * a missing or non-array hosted-store list defaulted to ZERO advertisements, which produces a 0.000 DIG recommendation that every balance clears. A node that could not tell how much it owes would have answered 'funded'. It now answers UNKNOWN. * a node serving nothing said 'funded -- at or above the recommended buffer', implying its stores were covered when it has none. It now says there is nothing to collateralise. A malformed --balance is refused rather than parsed as zero, which would have reported SHORT NOW over a typo, and the amount is scaled by integer arithmetic because 0.001 DIG steps are where an f64 starts rounding. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 113 ++++++++++++++++++- crates/dig-node-service/src/control.rs | 12 +- crates/dig-node-service/src/control_cli.rs | 121 +++++++++++++++++++++ crates/dig-node-service/src/entrypoint.rs | 61 ++++++++++- 4 files changed, 295 insertions(+), 12 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 293cef4d..fc6d4b63 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -35,6 +35,14 @@ const COLLATERAL_CONFIG_FILE: &str = "collateral.json"; /// The file holding the per-epoch records this node has censused, one JSON record per line. const EPOCH_RECORD_FILE: &str = "collateral-epochs.jsonl"; +/// The file naming the epoch the census has SETTLED on. +/// +/// Written by the census, read here. It exists because the node must not derive the collateral +/// epoch from its own clock: the schedule is a consensus fact anchored on chain, and a node that +/// guessed it would post against the wrong epoch. The census is the only component that knows, +/// so it says so, in one place both halves read. +const CURRENT_EPOCH_FILE: &str = "collateral-current-epoch"; + /// How many epochs of multiplier escalation the recommended buffer covers. /// /// Escalation COMPOUNDS, so a horizon is a choice rather than a constant, and the choice is the @@ -223,6 +231,32 @@ pub enum CurrentEpoch { NoChainSource, } +/// Read the epoch the census has settled on, from `dir`. +/// +/// Absent means the census has not run, which is [`CurrentEpoch::NotCensused`] — a first-class +/// answer, never a zero and never a guess. An unparseable marker is the same answer for the same +/// reason: a node that cannot read which epoch it is on does not know which epoch it is on. +/// +/// # The staleness this cannot detect, and who owns it +/// +/// A marker left behind by a census that has stopped running names an epoch that is no longer +/// current, and this node would then report an old epoch's figure. Keeping the marker fresh is the +/// CENSUS's obligation (dig-node#387), not this reader's — there is no second source here to check +/// it against, and inventing one from the clock is exactly the guess this seam exists to avoid. +/// The epoch number travels with every answer so the discrepancy is at least VISIBLE to a client +/// rather than silent. +pub fn current_epoch_from(dir: &Path) -> CurrentEpoch { + match std::fs::read_to_string(dir.join(CURRENT_EPOCH_FILE)) { + Ok(text) => match text.trim().parse::() { + // The epoch is ONE-based, so a zero is not a valid epoch and must not be treated as + // one; it means the marker is malformed. + Ok(epoch) if epoch > 0 => CurrentEpoch::Final(epoch), + _ => CurrentEpoch::NotCensused, + }, + Err(_) => CurrentEpoch::NotCensused, + } +} + /// This epoch's per-store requirement, or the named reason the node cannot state it. /// /// The margin is deliberately not consulted. `required_per_store_dig_base_units` is the PRE-margin, @@ -453,7 +487,7 @@ pub fn format_dig(base_units: u64) -> String { #[cfg(test)] mod tests { use super::*; - use dig_mirror_collateral::{required_per_store, EpochCensus}; + use dig_mirror_collateral::{base_per_store, handicap_for_owners, required_per_store, EpochCensus}; /// A record with independently-chosen fields. /// @@ -469,6 +503,11 @@ mod tests { owners, ..rec.census }; + // Every derived field is recomputed, not just the headline one. A helper that left the + // handicap at the bootstrap value would build a record no census could produce, and a test + // reading that field would then pin a fixture artefact rather than the model. + rec.handicap_dig_base_units = handicap_for_owners(owners); + rec.base_price_dig_base_units = base_per_store(multiplier_micros); rec.required_per_store_dig_base_units = required_per_store(multiplier_micros, owners); rec } @@ -590,6 +629,78 @@ mod tests { assert_eq!(store.get(6), StoredEpoch::Absent); } + + #[test] + fn the_census_marker_is_read_and_a_bad_one_is_not_a_guess() { + let dir = tempfile::tempdir().expect("tempdir"); + // No marker: the census has not run. + assert_eq!(current_epoch_from(dir.path()), CurrentEpoch::NotCensused); + + // A real marker, at a value that is not 1 -- a fixture pinned at the bootstrap epoch could + // not tell a parsed number from a hard-coded one. + std::fs::write(dir.path().join(CURRENT_EPOCH_FILE), "42 +").expect("write"); + assert_eq!(current_epoch_from(dir.path()), CurrentEpoch::Final(42)); + + // The epoch is ONE-based, so zero is malformed rather than an epoch, and neither it nor a + // non-number may become a Final(0) the requirement lookup would then miss silently. + for bad in ["0", "", " ", "eight", "-3", "1.5"] { + std::fs::write(dir.path().join(CURRENT_EPOCH_FILE), bad).expect("write"); + assert_eq!( + current_epoch_from(dir.path()), + CurrentEpoch::NotCensused, + "marker {bad:?} must not resolve to an epoch" + ); + } + } + + /// The exact JSON `control.collateral.requirement` puts on the wire. + /// + /// Pinned as literal keys and values rather than by round-tripping the Rust type, because a + /// round-trip proves only that the type agrees with itself. dig-app reads these names, and a + /// rename that both sides performed together would be invisible to a symmetric test. + #[test] + fn the_known_answer_carries_the_census_inputs_a_client_needs() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_at(dir.path()); + store.put(&record(12, 800_000, 750, 31)).expect("put"); + + let wire = serde_json::to_value(requirement(&store, CurrentEpoch::Final(12))) + .expect("serialise"); + assert_eq!(wire["state"], "known"); + assert_eq!(wire["epoch"], 12); + assert_eq!(wire["protocol_version"], 1); + assert_eq!(wire["multiplier_micros"], 800_000); + assert_eq!(wire["stores"], 31); + assert_eq!(wire["owners"], 750); + // 5.000 DIG equilibrium x 0.8 = 4.000, less the 750-owner handicap. Pinned as a concrete + // number as well as against the crate, so a mutation moving both sides is still caught. + assert_eq!( + wire["required_per_store_dig_base_units"], + required_per_store(800_000, 750) + ); + assert_eq!(wire["required_per_store_dig_base_units"], 3_000); + assert_eq!(wire["handicap_dig_base_units"], 1_000); + // The margin MUST NOT appear here. It is a local preference, and a client reading it from + // this method would render one operator's cushion as the network's price. + assert!(wire.get("margin_bp").is_none()); + } + + /// The unknown branch is a first-class ANSWER on the wire, not an error and not a zero. + #[test] + fn the_unknown_answer_names_its_reason_and_carries_no_figure() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_at(dir.path()); + + let wire = serde_json::to_value(requirement(&store, CurrentEpoch::NoChainSource)) + .expect("serialise"); + assert_eq!(wire["state"], "unknown"); + assert_eq!(wire["reason"], "no_chain_source"); + // The field a client would render as a cost is ABSENT, not zero. A zero here reads as + // "no collateral required", and under-posting costs the operator that epoch's rewards. + assert!(wire.get("required_per_store_dig_base_units").is_none()); + } + /// A `Known` requirement with an explicitly-chosen figure, so the buffer tests never depend on /// the record store. fn known(required: u64) -> CollateralRequirementResult { diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 0af593e3..6efca529 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3246,12 +3246,12 @@ fn spend_row(r: &crate::spend_audit::SpendRecord) -> Value { /// This node's view of which collateral epoch is in force. /// /// The epoch is NOT derived from the clock here. The collateral epoch schedule is a consensus fact -/// anchored on chain, and a node that guessed it would post against the wrong epoch — so until the -/// census names an epoch (dig-node#387), the honest answer is that nothing has been censused. That -/// is a first-class answer, not an error, and it is why `control.collateral.requirement` returns a -/// reason rather than a zero. -fn current_collateral_epoch(_ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { - crate::collateral::CurrentEpoch::NotCensused +/// anchored on chain, and a node that guessed it would post against the wrong epoch — so the epoch +/// is whatever the CENSUS has settled on and recorded. Until a census runs (dig-node#387) that +/// marker is absent, and the honest answer is that nothing has been censused: a first-class +/// answer, not an error, and the reason this method returns a named reason rather than a zero. +fn current_collateral_epoch(ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { + crate::collateral::current_epoch_from(&ctx.state_dir) } /// `control.collateral.requirement` — this epoch's per-store requirement, or a named reason. diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 9887f2a3..ff5c8658 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -715,6 +715,127 @@ fn summarize(method: &str, result: &Value) -> String { } + +/// `dign collateral buffer` — how much $DIG to hold, and whether this operator is short. +/// +/// Composed from three control reads rather than served by one method, because each of the three +/// is already the authoritative home for its own fact: the requirement is consensus-derived, the +/// margin is a local preference, and the served-pair count belongs to the hosted-store surface. +/// Adding a fourth method that re-derived any of them would be a second opinion on the money path. +/// +/// The BALANCE is supplied by the caller. This node cannot know which address holds an operator's +/// $DIG, and inventing one would be worse than asking: a balance read of the wrong address returns +/// a confident number about the wrong money. With no `--balance` the advice reports what it costs +/// and says the standing is UNKNOWN, which is the honest answer and raises no alarm. +pub fn collateral_buffer(config: &Config, balance_dig_base_units: Option) -> std::io::Result { + use crate::collateral::{buffer_advice, format_dig, FundingState}; + + let requirement_json = call_control( + config, + ControlAction::CollateralRequirement.method(), + json!({}), + )?; + let requirement: dig_node_control_interface::results::CollateralRequirementResult = + serde_json::from_value(requirement_json).map_err(std::io::Error::other)?; + let margin_json = call_control( + config, + ControlAction::CollateralMarginGet.method(), + json!({}), + )?; + let margin_bp = margin_json["margin_bp"].as_u64().unwrap_or(0); + + // One `(store, root)` advertisement per hosted store. An advertisement count, never a node + // count -- the same distinction the census draws. + // + // A missing or non-array `stores` is UNKNOWN, never zero. Zero obligations and an unreadable + // store list produce the same arithmetic -- a 0.000 DIG recommendation that every balance + // clears -- so defaulting to zero would answer "funded" to an operator whose node could not + // tell how much it owes. That is the reassuring-answer-to-an-unknown failure this whole + // surface exists to prevent, and it is indistinguishable from the honest case downstream. + let hosted = call_control(config, ControlAction::StoresList.method(), json!({}))?; + let Some(pairs) = hosted["stores"].as_array().map(|a| a.len() as u64) else { + return Ok(Outcome::new( + "collateral buffer UNKNOWN — this node could not read how many store advertisements it serves, so it cannot say what you should hold." + .to_string(), + json!({ "state": "unknown", "reason": "hosted_stores_unreadable" }), + )); + }; + + let advice = buffer_advice(pairs, &requirement, margin_bp, balance_dig_base_units); + + // The working is shown, briefly. A figure nobody can sanity-check is a figure nobody acts on. + let mut summary = if advice.state == FundingState::Unknown + && !matches!( + requirement, + dig_node_control_interface::results::CollateralRequirementResult::Known { .. } + ) { + // Unknown because the REQUIREMENT is unknown -- there is no cost to quote at all. + return Ok(Outcome::new( + format!( + "collateral buffer UNKNOWN — this node cannot state its per-store requirement yet, \ + so it cannot say what you should hold.\n \ + Run `dign collateral requirement` to see which fact is missing." + ), + serde_json::to_value(&advice).map_err(std::io::Error::other)?, + )); + } else { + format!( + "serving {} store advertisement(s) at {} DIG each ({} bp margin) — one epoch locks {} DIG\n \ + next epoch could rise to {} DIG at the +12.5%/epoch ceiling\n \ + recommended holding {} DIG (covers the reclaim overlap plus {} epochs of escalation; \ + a worst case, not a forecast)", + advice.pairs, + format_dig(advice.required_per_store_dig_base_units), + advice.margin_bp, + format_dig(advice.one_epoch_lock_dig_base_units), + format_dig(advice.next_epoch_ceiling_dig_base_units), + format_dig(advice.recommended_dig_base_units), + advice.horizon_epochs, + ) + }; + + // The number a person acts on goes LAST, where the eye lands, and it is an amount rather than + // an adjective: "balance low" is not actionable, "add 3.250 DIG" is. + summary.push_str("\n "); + summary.push_str(&match (advice.state, advice.shortfall_to_recommended_dig_base_units) { + (FundingState::Unknown, _) => { + "holding UNKNOWN — pass --balance to see where you stand".to_string() + } + (FundingState::ShortNow, Some(short)) => format!( + "SHORT NOW — you cannot cover this epoch; stores are going uncollateralised. \ + Add at least {} DIG now, {} DIG to reach the recommendation.", + format_dig( + advice + .one_epoch_lock_dig_base_units + .saturating_sub(advice.balance_dig_base_units.unwrap_or(0)) + ), + format_dig(short), + ), + (FundingState::DangerouslyLow, Some(short)) => format!( + "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ + Add {} DIG to reach the recommendation.", + format_dig(short), + ), + (FundingState::BelowRecommendedBuffer, Some(short)) => format!( + "below the recommended buffer — fine for now, no cushion. Add {} DIG to reach it.", + format_dig(short), + ), + // Nothing to collateralise is NOT the same sentence as "your funding is sufficient", even + // though the arithmetic agrees. Saying "funded" to an operator hosting nothing implies + // their stores are covered, and they have none. + (FundingState::Adequate, _) if advice.pairs == 0 => { + "no store advertisements to collateralise yet — nothing to fund.".to_string() + } + (FundingState::Adequate, _) => "funded — at or above the recommended buffer.".to_string(), + (_, None) => "holding UNKNOWN".to_string(), + }); + + Ok(Outcome::new( + summary, + serde_json::to_value(&advice).map_err(std::io::Error::other)?, + )) +} + /// A concise human line for `control.collateral.requirement`. /// /// The census inputs travel with the figure on purpose: a surface that can show only the number can diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index d1d0fbd5..f3ab5765 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -192,11 +192,6 @@ enum Command { #[command(subcommand)] action: Option, }, - /// Add, list and remove a TRUSTED Chia full-node peer. - /// - /// A different network from `peers`, which manages DIG gossip peers. Trusting a Chia peer - /// grants it authority over this node's wallet replica without corroboration — see - /// `chia-peers add --help`. /// Inspect the collateral this node must post, and set your local safety margin. /// /// The requirement is decided by the network and is the same on every node. The margin is @@ -206,6 +201,11 @@ enum Command { #[command(subcommand)] action: Option, }, + /// Add, list and remove a TRUSTED Chia full-node peer. + /// + /// A different network from `peers`, which manages DIG gossip peers. Trusting a Chia peer + /// grants it authority over this node's wallet replica without corroboration — see + /// `chia-peers add --help`. ChiaPeers { #[command(subcommand)] action: Option, @@ -593,6 +593,17 @@ enum CollateralCommand { /// and every node derives it identically. If this node has not censused the epoch yet, it says /// so and why — it never reports a requirement it does not have as zero. Requirement, + /// Show how much $DIG to hold against your collateral obligations. + /// + /// Collateral is RECLAIMED, not spent: each epoch returns the previous epoch's coins, so the + /// steady state is roughly one epoch's lock rather than one per epoch. The recommendation + /// covers that lock, the overlap while a reclaim is still in flight, and some headroom for the + /// price rising -- a worst case, not a forecast. + Buffer { + /// How much $DIG you hold, in DIG (e.g. `12.5`). Without it, the standing is not guessed. + #[arg(long)] + balance: Option, + }, /// Show your local safety margin, and what it adds. Margin { #[command(subcommand)] @@ -837,6 +848,12 @@ pub fn run() -> std::process::ExitCode { Ok(a) => render(peers::run(&config, a), action, json), Err(e) => emit_error(&e, action, json), }, + Command::Collateral { + action: Some(CollateralCommand::Buffer { balance }), + } => match parse_dig_amount(balance.as_deref()) { + Ok(b) => render(control_cli::collateral_buffer(&config, b), action, json), + Err(e) => emit_error(&e, action, json), + }, Command::Collateral { action: cmd } => match collateral_action(cmd) { Ok(a) => render(control_cli::run(&config, a), action, json), Err(e) => emit_error(&e, action, json), @@ -1017,12 +1034,46 @@ fn subscriptions_action(cmd: Option) -> ControlAction { /// /// An unrecognised word is REFUSED, never silently treated as a number or as the default: a typo /// that fell through to the default would change what this node posts without saying so. +/// Parse a `--balance` operand in DIG into DIG base units. +/// +/// $DIG has THREE decimals and its base unit is 0.001 DIG. Parsed as text and scaled by integer +/// arithmetic rather than through a float: 0.001 steps are where an f64 starts rounding, and a +/// rounded figure about somebody's money is the class of lie this surface exists to avoid. +/// +/// A malformed amount is REFUSED. Falling back to zero would report SHORT NOW over a typo. +fn parse_dig_amount(raw: Option<&str>) -> std::io::Result> { + let Some(raw) = raw else { return Ok(None) }; + let refuse = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{raw:?} is not an amount of DIG (at most 3 decimal places, e.g. 12.500)"), + ) + }; + let (whole, frac) = match raw.split_once('.') { + Some((w, f)) => (w, f), + None => (raw, ""), + }; + if frac.len() > 3 || !frac.bytes().all(|b| b.is_ascii_digit()) { + return Err(refuse()); + } + let whole: u64 = whole.parse().map_err(|_| refuse())?; + // Right-pad so "5" after the point means 500 milli-DIG, not 5. + let millis: u64 = format!("{frac:0<3}").parse().map_err(|_| refuse())?; + whole + .checked_mul(1_000) + .and_then(|w| w.checked_add(millis)) + .map(Some) + .ok_or_else(refuse) +} + fn collateral_action(cmd: Option) -> std::io::Result { use dig_mirror_collateral::{ SAFETY_MARGIN_BP_DEFAULT, SAFETY_MARGIN_BP_GENEROUS, SAFETY_MARGIN_BP_TIGHT, }; match cmd { None | Some(CollateralCommand::Requirement) => Ok(ControlAction::CollateralRequirement), + // Handled before this mapper: it composes three control reads rather than dispatching one. + Some(CollateralCommand::Buffer { .. }) => Ok(ControlAction::CollateralRequirement), Some(CollateralCommand::Margin { action: None }) => Ok(ControlAction::CollateralMarginGet), Some(CollateralCommand::Margin { action: Some(MarginCommand::Set { value }), From 2c5f86c2599b7506b7907522d17665d8d9ed2603 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:35:56 -0700 Subject: [PATCH 06/23] docs(spec): SPEC section 24 -- the mirror-coin collateral contract Normative contract for the three collateral control methods and the three dign verbs: the consensus/local split, the four unknown reasons and why they are distinct, the census-names-the-epoch rule, the margin's persistence and refuse-not-clamp bound, the reclaim-not-spend derivation of the recommended buffer, and the three funding states with only two of them notifying. Section 24.6 records the hazard the live run surfaced: an unknown and a genuine zero produce identical arithmetic, so an unreadable store list read as zero yields a recommendation every balance clears and a node that cannot tell how much it owes reports 'funded'. Co-Authored-By: Claude --- SPEC.md | 141 +++++++++++++++++++++ crates/dig-node-service/src/collateral.rs | 27 ++-- crates/dig-node-service/src/control.rs | 5 +- crates/dig-node-service/src/control_cli.rs | 78 ++++++------ crates/dig-node-service/src/spend_audit.rs | 26 +++- 5 files changed, 221 insertions(+), 56 deletions(-) diff --git a/SPEC.md b/SPEC.md index 9249ccfc..b266023f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7364,3 +7364,144 @@ the node is stopped or wedged. Every verb offers `--json` beside the human output, with stable field names (§6.2). The JSON listing is `{ path, count, unreadable_lines, spends[] }`, and each spend carries its raw fields plus `status_token` and `chain_reference`. + +## 24. Mirror-coin collateral — the requirement, the local margin, and the funding advice (dig_ecosystem#3173) + +An advertisement qualifies for an epoch only if it posts that epoch's **collateral**. This section is the +contract for what the node reports about it. It governs three control methods and three `dign` verbs. + +**Every figure MUST come from `dig-mirror-collateral`.** The node MUST NOT restate the model's +arithmetic. `required_per_store` is the WHOLE answer: `equilibrium × multiplier − handicap` omits the +floor clamp, and a re-derivation that omitted it would understate what an advertisement must post. +`apply_safety_margin` rounds UP, and a re-derivation that rounded down would post a base unit short of +qualifying. A second implementation of either is a money-path drift bug. + +**Units.** Every amount is **DIG base units**: `1 DIG = 1_000`, smallest amount `0.001 DIG`. They are +never mojos — a mojo is XCH's base unit, `1e-12 XCH`, nine orders of magnitude away. The two units MUST +NOT appear in one expression. + +### 24.1. The requirement is CONSENSUS; the margin is LOCAL. They are never one value + +`control.collateral.requirement` returns the **pre-margin** per-store requirement, which every node +derives identically. It MUST NOT include the local safety margin, and the margin MUST NOT be reachable +from it: returning the margined amount would present one operator's private preference as the network's +price. The margin is served separately by `control.collateral.margin.get` / `.set`. + +The census inputs — `stores`, `owners`, `multiplier_micros`, `handicap_dig_base_units` — travel WITH the +figure. A client holding only the number can say the price moved; a client holding the inputs can say +why. `stores` counts qualifying `(owner, store, root)` **advertisements**, never nodes; `owners` counts +distinct owner puzzle hashes and a surface displaying it MUST say "collateralised owners". + +`protocol_version` is the version that **computed** the epoch, read from the record — never the newest +version the build implements. The two differ exactly when a node has upgraded mid-schedule, which is the +one case where a client needs the difference. + +### 24.2. UNKNOWN is a first-class answer, and it is never a zero + +A node that cannot state the requirement MUST return `state: "unknown"` with a `reason`, and MUST NOT +return a zero, a stale epoch's figure presented as this epoch's, or an error a client would render as +"no collateral required". Under-posting costs the operator that epoch's rewards. + +The reasons are distinct because their remedies differ: + +| `reason` | meaning | remedy | +|---|---|---| +| `not_censused` | this node holds no record for the epoch | run the census | +| `behind_finality_depth` | the epoch's inputs are not final | wait for the chain to settle | +| `record_unreadable` | a record exists and could not be read | re-run the census for the epoch | +| `no_chain_source` | the node cannot see the chain | configure a chain source | + +Collapsing them into one "unavailable" hands every client the same unactionable sentence. + +**A record the node never wrote and one it wrote and cannot read are different answers** +(`not_censused` vs `record_unreadable`). + +### 24.3. The epoch is named by the CENSUS, never derived from the clock + +The collateral epoch schedule is a consensus fact anchored on chain. The node MUST NOT derive the current +epoch from its own clock: a guess posts against the wrong epoch. The census records the epoch it settled +on, and the requirement reader reads that. An absent or malformed marker is `not_censused`; the epoch is +**one-based**, so a zero is malformed rather than an epoch. + +Keeping the marker current is the **census's** obligation. The epoch number travels with every answer so +that a marker left behind by a stopped census is at least VISIBLE to a client rather than silent. + +### 24.4. The safety margin + +Basis points, always — `100` is `+1%`. Never a percentage and never a float: a 1 bp margin (`0.01%`) is a +legal choice and any conversion to whole percent would erase it. + +* A stored configuration that **predates** the field MUST load as `DEFAULT_SAFETY_MARGIN_BP` (`100`), + never `0`. A zero margin is a deliberate choice to post the requirement exactly; reporting it for a + configuration that never expressed one tells the operator they declined a cushion they were never + offered. +* `.set` MUST **persist** the value, so it survives a restart. A margin that lapsed to the default on + reboot would silently change what the node posts, so a failed write MUST NOT be reported as a success. +* A value above `MAX_SAFETY_MARGIN_BP` (`10_000`, i.e. `+100%`) is **REFUSED, never clamped**, and `.set` + returns what was actually stored. Clamping and returning the clamped value would leave the caller's + stored intent and the node's behaviour disagreeing on the money path. +* The node is the **authoritative home** for the setting: the flywheel is headless, so a machine with no + GUI MUST be able to set it. dig-app is a remote control for the same value. + +### 24.5. The funding advice — how much to hold, and the three 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 the +creates behind it. **The steady state is roughly ONE epoch's lock, not one per epoch.** A recommendation +of "requirement × epochs of runway" overstates the answer by the epoch count and tells an operator to +hold many times what they need. + +The recommendation therefore covers the current epoch's lock plus the **transition overlap** — epoch `n` +existing before `n−1` is reclaimed, or a reclaim delayed or failed — with the next epoch allowed to have +escalated: + +``` +lock = pairs × apply_safety_margin(required_per_store, margin_bp) +recommended = lock + lock × (1 + 1/UP_STEP_DENOM)^HORIZON +``` + +Escalation **compounds** (`UP_STEP_DENOM = 8`, so at most `+12.5%` per epoch): 1 epoch `1.12×`, 2 +`1.27×`, 4 `1.60×`, 8 `2.57×`, 13 `4.62×`. The horizon is therefore a CHOICE and MUST be reported with +the figure. The node uses **4 epochs**; a horizon demanding several times the current lock is advice +nobody acts on. The ceiling is a **worst case, not a forecast** — inside the controller's dead band the +multiplier does not move at all, and a surface MUST say so. + +The three states, of which only two may notify: + +| state | meaning | notifies? | +|---|---|---| +| `short_now` | cannot cover the current epoch; stores are already uncollateralised | **yes** | +| `dangerously_low` | covers now; could not cover the next epoch at the escalation ceiling | **yes** | +| `below_recommended_buffer` | fine for several epochs, no cushion | **NO — readout only** | +| `adequate` | at or above the recommendation | no | +| `unknown` | a required fact is missing | **NO** | + +`below_recommended_buffer` MUST NOT raise a notification. A healthy node sits there much of the time, and +a recurring alert an operator learns to dismiss teaches them to dismiss the two that matter. + +### 24.6. Never answer an unknown with a reassuring number + +If the node cannot determine its requirement, its served-advertisement count, or the operator's balance, +the state is `unknown`. It MUST NOT be a zero and MUST NOT be an alarm. + +This is a live hazard rather than a theoretical one, because **an unknown and a genuine zero produce +identical arithmetic**: an unreadable hosted-store list read as zero advertisements yields a `0.000 DIG` +recommendation that every balance clears, so a node that could not tell how much it owes would report +"funded". A surface MUST distinguish "nothing to collateralise" from "your funding is sufficient" for the +same reason. + +A malformed operator-supplied balance is REFUSED, never parsed as zero, which would report `short_now` +over a typo. Amounts are scaled by integer arithmetic: `0.001 DIG` steps are where an `f64` starts +rounding, and a rounded figure about somebody's money is the lie this section exists to prevent. + +### 24.7. The `dign` verbs + +* `collateral requirement` — §24.1/§24.2. Prints the reason and its remedy on `unknown`, never a zero. +* `collateral margin [set ]` — §24.4. A preset resolves to + `dig-mirror-collateral`'s own constant, never to a number spelled out in the CLI; a second spelling is + how two surfaces post different amounts for one choice. An unrecognised word is REFUSED, never treated + as the default. +* `collateral buffer [--balance ]` — §24.5/§24.6. States an AMOUNT to add, not an adjective, and + shows the working: advertisements served, per-store requirement, margin, and the horizon used. + +Every verb offers `--json` beside the human output, with stable field names (§6.2). diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index fc6d4b63..efb1cf9f 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -487,7 +487,9 @@ pub fn format_dig(base_units: u64) -> String { #[cfg(test)] mod tests { use super::*; - use dig_mirror_collateral::{base_per_store, handicap_for_owners, required_per_store, EpochCensus}; + use dig_mirror_collateral::{ + base_per_store, handicap_for_owners, required_per_store, EpochCensus, + }; /// A record with independently-chosen fields. /// @@ -586,8 +588,14 @@ mod tests { }; // An epoch the node holds no record for is NOT the same as having no chain to look at, // and neither is the same as waiting for finality. The remedies differ, so the tokens must. - assert_eq!(reason(CurrentEpoch::Final(4)), CollateralUnknownReason::NotCensused); - assert_eq!(reason(CurrentEpoch::NotCensused), CollateralUnknownReason::NotCensused); + assert_eq!( + reason(CurrentEpoch::Final(4)), + CollateralUnknownReason::NotCensused + ); + assert_eq!( + reason(CurrentEpoch::NotCensused), + CollateralUnknownReason::NotCensused + ); assert_eq!( reason(CurrentEpoch::BehindFinalityDepth), CollateralUnknownReason::BehindFinalityDepth @@ -629,7 +637,6 @@ mod tests { assert_eq!(store.get(6), StoredEpoch::Absent); } - #[test] fn the_census_marker_is_read_and_a_bad_one_is_not_a_guess() { let dir = tempfile::tempdir().expect("tempdir"); @@ -638,8 +645,12 @@ mod tests { // A real marker, at a value that is not 1 -- a fixture pinned at the bootstrap epoch could // not tell a parsed number from a hard-coded one. - std::fs::write(dir.path().join(CURRENT_EPOCH_FILE), "42 -").expect("write"); + std::fs::write( + dir.path().join(CURRENT_EPOCH_FILE), + "42 +", + ) + .expect("write"); assert_eq!(current_epoch_from(dir.path()), CurrentEpoch::Final(42)); // The epoch is ONE-based, so zero is malformed rather than an epoch, and neither it nor a @@ -665,8 +676,8 @@ mod tests { let store = store_at(dir.path()); store.put(&record(12, 800_000, 750, 31)).expect("put"); - let wire = serde_json::to_value(requirement(&store, CurrentEpoch::Final(12))) - .expect("serialise"); + let wire = + serde_json::to_value(requirement(&store, CurrentEpoch::Final(12))).expect("serialise"); assert_eq!(wire["state"], "known"); assert_eq!(wire["epoch"], 12); assert_eq!(wire["protocol_version"], 1); diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 6efca529..f592bb61 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3260,9 +3260,8 @@ fn current_collateral_epoch(ctx: &ControlCtx) -> crate::collateral::CurrentEpoch /// every node derives identically, and returning the margined amount would make this operator's /// preference look like the network's price. fn collateral_requirement(ctx: &ControlCtx, id: Value) -> Value { - let store = crate::collateral::EpochRecordStore::at( - ctx.state_dir.join("collateral-epochs.jsonl"), - ); + let store = + crate::collateral::EpochRecordStore::at(ctx.state_dir.join("collateral-epochs.jsonl")); let answer = crate::collateral::requirement(&store, current_collateral_epoch(ctx)); match serde_json::to_value(&answer) { Ok(v) => control_ok(id, v), diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index ff5c8658..0c573b0a 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -714,8 +714,6 @@ fn summarize(method: &str, result: &Value) -> String { } } - - /// `dign collateral buffer` — how much $DIG to hold, and whether this operator is short. /// /// Composed from three control reads rather than served by one method, because each of the three @@ -727,7 +725,10 @@ fn summarize(method: &str, result: &Value) -> String { /// $DIG, and inventing one would be worse than asking: a balance read of the wrong address returns /// a confident number about the wrong money. With no `--balance` the advice reports what it costs /// and says the standing is UNKNOWN, which is the honest answer and raises no alarm. -pub fn collateral_buffer(config: &Config, balance_dig_base_units: Option) -> std::io::Result { +pub fn collateral_buffer( + config: &Config, + balance_dig_base_units: Option, +) -> std::io::Result { use crate::collateral::{buffer_advice, format_dig, FundingState}; let requirement_json = call_control( @@ -797,38 +798,42 @@ pub fn collateral_buffer(config: &Config, balance_dig_base_units: Option) - // The number a person acts on goes LAST, where the eye lands, and it is an amount rather than // an adjective: "balance low" is not actionable, "add 3.250 DIG" is. summary.push_str("\n "); - summary.push_str(&match (advice.state, advice.shortfall_to_recommended_dig_base_units) { - (FundingState::Unknown, _) => { - "holding UNKNOWN — pass --balance to see where you stand".to_string() - } - (FundingState::ShortNow, Some(short)) => format!( - "SHORT NOW — you cannot cover this epoch; stores are going uncollateralised. \ + summary.push_str( + &match (advice.state, advice.shortfall_to_recommended_dig_base_units) { + (FundingState::Unknown, _) => { + "holding UNKNOWN — pass --balance to see where you stand".to_string() + } + (FundingState::ShortNow, Some(short)) => format!( + "SHORT NOW — you cannot cover this epoch; stores are going uncollateralised. \ Add at least {} DIG now, {} DIG to reach the recommendation.", - format_dig( - advice - .one_epoch_lock_dig_base_units - .saturating_sub(advice.balance_dig_base_units.unwrap_or(0)) + format_dig( + advice + .one_epoch_lock_dig_base_units + .saturating_sub(advice.balance_dig_base_units.unwrap_or(0)) + ), + format_dig(short), ), - format_dig(short), - ), - (FundingState::DangerouslyLow, Some(short)) => format!( - "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ + (FundingState::DangerouslyLow, Some(short)) => format!( + "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ Add {} DIG to reach the recommendation.", - format_dig(short), - ), - (FundingState::BelowRecommendedBuffer, Some(short)) => format!( - "below the recommended buffer — fine for now, no cushion. Add {} DIG to reach it.", - format_dig(short), - ), - // Nothing to collateralise is NOT the same sentence as "your funding is sufficient", even - // though the arithmetic agrees. Saying "funded" to an operator hosting nothing implies - // their stores are covered, and they have none. - (FundingState::Adequate, _) if advice.pairs == 0 => { - "no store advertisements to collateralise yet — nothing to fund.".to_string() - } - (FundingState::Adequate, _) => "funded — at or above the recommended buffer.".to_string(), - (_, None) => "holding UNKNOWN".to_string(), - }); + format_dig(short), + ), + (FundingState::BelowRecommendedBuffer, Some(short)) => format!( + "below the recommended buffer — fine for now, no cushion. Add {} DIG to reach it.", + format_dig(short), + ), + // Nothing to collateralise is NOT the same sentence as "your funding is sufficient", even + // though the arithmetic agrees. Saying "funded" to an operator hosting nothing implies + // their stores are covered, and they have none. + (FundingState::Adequate, _) if advice.pairs == 0 => { + "no store advertisements to collateralise yet — nothing to fund.".to_string() + } + (FundingState::Adequate, _) => { + "funded — at or above the recommended buffer.".to_string() + } + (_, None) => "holding UNKNOWN".to_string(), + }, + ); Ok(Outcome::new( summary, @@ -861,10 +866,7 @@ fn summarize_collateral_requirement(result: &Value) -> String { "the record for this epoch could not be read", "re-run the census for this epoch", ), - "no_chain_source" => ( - "this node cannot see the chain", - "configure a chain source", - ), + "no_chain_source" => ("this node cannot see the chain", "configure a chain source"), other => (other, "unknown"), }; // Emphatically NOT "0 DIG". An absent requirement rendered as a zero cost is the money lie @@ -872,9 +874,7 @@ fn summarize_collateral_requirement(result: &Value) -> String { return format!("collateral requirement UNKNOWN — {reason} · {remedy}"); } - let dig = |key: &str| { - crate::collateral::format_dig(result[key].as_u64().unwrap_or(0)) - }; + let dig = |key: &str| crate::collateral::format_dig(result[key].as_u64().unwrap_or(0)); let multiplier = result["multiplier_micros"].as_u64().unwrap_or(0); format!( "epoch {} (protocol v{}) — {} DIG per store, before any safety margin\n \ diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index eb2129c2..fbba1fbc 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -1665,7 +1665,10 @@ mod tests { return (seen, requests); } after = SpendLog::cursor_of(&ledger); - assert!(after.is_some(), "an incomplete page must hand back a cursor"); + assert!( + after.is_some(), + "an incomplete page must hand back a cursor" + ); assert!(requests < 20, "walk did not terminate"); } } @@ -1678,7 +1681,10 @@ mod tests { // (d, e, f). A descending-id tiebreak would give c,b and f,e,d and is the nearest wrong // implementation this ordering has. assert_eq!( - all.records.iter().map(|r| r.id.as_str()).collect::>(), + all.records + .iter() + .map(|r| r.id.as_str()) + .collect::>(), ["a", "b", "c", "d", "e", "f"] ); } @@ -1690,7 +1696,10 @@ mod tests { // time-based cursor loses or repeats a row. A page size of 6 would see nothing. let (seen, requests) = walk(&log, 2); assert_eq!(seen, ["a", "b", "c", "d", "e", "f"]); - assert!(requests > 1, "page size 2 over 6 rows must take several requests"); + assert!( + requests > 1, + "page size 2 over 6 rows must take several requests" + ); // And an odd page size, so the last page is PARTIAL rather than exactly full. assert_eq!(walk(&log, 4).0, ["a", "b", "c", "d", "e", "f"]); } @@ -1718,7 +1727,10 @@ mod tests { }) .expect("second"); assert_eq!(second.records.len(), 3); - assert!(second.complete, "an exactly-full LAST page is still complete"); + assert!( + second.complete, + "an exactly-full LAST page is still complete" + ); assert_eq!(walk(&log, 3).1, 2, "the walk must stop after two requests"); } @@ -1766,7 +1778,10 @@ mod tests { }) .expect("filtered page"); assert_eq!( - page.records.iter().map(|r| r.id.as_str()).collect::>(), + page.records + .iter() + .map(|r| r.id.as_str()) + .collect::>(), ["f"] ); assert!(page.complete); @@ -1814,5 +1829,4 @@ mod tests { assert_eq!(page.unreadable_lines, 2); assert!(!page.complete); } - } From c9e16fdaa82e28bae69a9decd6bd6c37f3786216 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:46:42 -0700 Subject: [PATCH 07/23] fix(collateral): derive the epoch from the canonical clock, not a stored marker Self-correction. The first pass assumed the mirror-coin epoch schedule was a chain-anchored consensus fact the node could not compute, and introduced a marker file for the census to write. That was wrong: dig-constants 0.13.0 publishes the schedule as a WALL-CLOCK one -- 7-day epochs from a fixed genesis -- and mirror_epoch_at_unix_ms is its canonical implementation. The marker is removed and the epoch is derived. Three consequences: * The epoch number is an INPUT TO COIN IDENTITY (dig_mirror_coin::mirror_hint takes it), so a second implementation of the arithmetic would derive different coins rather than a different label. The constant is delegated to, never re-derived. * Deriving it makes a STALE answer structurally unrepresentable. The lookup is for the epoch current NOW, so a node whose census stopped running reports not_censused for the present epoch instead of confidently serving last week's figure. The marker could not have detected that -- it was the hazard. * The surface is genuinely live rather than pending a census. A node with a record for the current epoch now answers with a real number, unaided. Verified on a real node: it derived epoch 104 by itself and reported 3.780 DIG per store from 17 advertisements across 820 owners; when the record was edited to name epoch 103 instead, it correctly refused with not_censused rather than serving the stale figure. One-based and div_euclid are both pinned in the test, at the only input that can tell them apart: the millisecond before genesis, which a truncating divide collides with epoch 1. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 110 +++++++++++----------- crates/dig-node-service/src/control.rs | 18 ++-- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index efb1cf9f..3c843dd1 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -35,14 +35,6 @@ const COLLATERAL_CONFIG_FILE: &str = "collateral.json"; /// The file holding the per-epoch records this node has censused, one JSON record per line. const EPOCH_RECORD_FILE: &str = "collateral-epochs.jsonl"; -/// The file naming the epoch the census has SETTLED on. -/// -/// Written by the census, read here. It exists because the node must not derive the collateral -/// epoch from its own clock: the schedule is a consensus fact anchored on chain, and a node that -/// guessed it would post against the wrong epoch. The census is the only component that knows, -/// so it says so, in one place both halves read. -const CURRENT_EPOCH_FILE: &str = "collateral-current-epoch"; - /// How many epochs of multiplier escalation the recommended buffer covers. /// /// Escalation COMPOUNDS, so a horizon is a choice rather than a constant, and the choice is the @@ -231,32 +223,38 @@ pub enum CurrentEpoch { NoChainSource, } -/// Read the epoch the census has settled on, from `dir`. +/// The mirror-coin epoch containing `now_unix_ms`. /// -/// Absent means the census has not run, which is [`CurrentEpoch::NotCensused`] — a first-class -/// answer, never a zero and never a guess. An unparseable marker is the same answer for the same -/// reason: a node that cannot read which epoch it is on does not know which epoch it is on. +/// Delegated to `dig_constants::mirror_epoch_at_unix_ms`, never re-derived. The epoch number is an +/// INPUT TO COIN IDENTITY — `dig_mirror_coin::mirror_hint` takes it — so a node computing a +/// different epoch than its peers does not display a wrong label, it derives a different coin and +/// orphans the epoch's collateral. There must be exactly one implementation of this arithmetic in +/// the ecosystem, and it is not this one. /// -/// # The staleness this cannot detect, and who owns it +/// Two properties worth naming because a plausible reimplementation loses both: the epoch is +/// **one-based** (the genesis instant is epoch 1, not 0), and it uses `div_euclid`, so an instant +/// one millisecond BEFORE genesis is epoch 0 rather than colliding with epoch 1 as a truncating +/// `/` would. /// -/// A marker left behind by a census that has stopped running names an epoch that is no longer -/// current, and this node would then report an old epoch's figure. Keeping the marker fresh is the -/// CENSUS's obligation (dig-node#387), not this reader's — there is no second source here to check -/// it against, and inventing one from the clock is exactly the guess this seam exists to avoid. -/// The epoch number travels with every answer so the discrepancy is at least VISIBLE to a client -/// rather than silent. -pub fn current_epoch_from(dir: &Path) -> CurrentEpoch { - match std::fs::read_to_string(dir.join(CURRENT_EPOCH_FILE)) { - Ok(text) => match text.trim().parse::() { - // The epoch is ONE-based, so a zero is not a valid epoch and must not be treated as - // one; it means the marker is malformed. - Ok(epoch) if epoch > 0 => CurrentEpoch::Final(epoch), - _ => CurrentEpoch::NotCensused, - }, - Err(_) => CurrentEpoch::NotCensused, +/// A clock before genesis yields a non-positive epoch, which is not an epoch. It is reported as +/// [`CurrentEpoch::NotCensused`] rather than clamped to 1: a machine whose clock is wrong should +/// not be handed epoch 1's requirement as though it were current. +pub fn current_epoch_at(now_unix_ms: i64) -> CurrentEpoch { + match dig_constants::mirror_epoch_at_unix_ms(now_unix_ms) { + epoch if epoch >= 1 => CurrentEpoch::Final(epoch as u64), + _ => CurrentEpoch::NotCensused, } } +/// The mirror-coin epoch in force right now, by the system clock. +pub fn current_epoch_now() -> CurrentEpoch { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + current_epoch_at(now) +} + /// This epoch's per-store requirement, or the named reason the node cannot state it. /// /// The margin is deliberately not consulted. `required_per_store_dig_base_units` is the PRE-margin, @@ -638,31 +636,37 @@ mod tests { } #[test] - fn the_census_marker_is_read_and_a_bad_one_is_not_a_guess() { - let dir = tempfile::tempdir().expect("tempdir"); - // No marker: the census has not run. - assert_eq!(current_epoch_from(dir.path()), CurrentEpoch::NotCensused); - - // A real marker, at a value that is not 1 -- a fixture pinned at the bootstrap epoch could - // not tell a parsed number from a hard-coded one. - std::fs::write( - dir.path().join(CURRENT_EPOCH_FILE), - "42 -", - ) - .expect("write"); - assert_eq!(current_epoch_from(dir.path()), CurrentEpoch::Final(42)); - - // The epoch is ONE-based, so zero is malformed rather than an epoch, and neither it nor a - // non-number may become a Final(0) the requirement lookup would then miss silently. - for bad in ["0", "", " ", "eight", "-3", "1.5"] { - std::fs::write(dir.path().join(CURRENT_EPOCH_FILE), bad).expect("write"); - assert_eq!( - current_epoch_from(dir.path()), - CurrentEpoch::NotCensused, - "marker {bad:?} must not resolve to an epoch" - ); - } + fn the_epoch_comes_from_the_canonical_clock_and_is_one_based() { + use dig_constants::{ + MIRROR_EPOCH_GENESIS_UNIX_MS as GENESIS, MIRROR_EPOCH_LENGTH_MS as WEEK, + }; + + // The genesis instant is epoch ONE, not zero. An off-by-one here derives a different coin + // for every store on the network, so it is pinned explicitly rather than inferred. + assert_eq!(current_epoch_at(GENESIS), CurrentEpoch::Final(1)); + assert_eq!(current_epoch_at(GENESIS + WEEK - 1), CurrentEpoch::Final(1)); + assert_eq!(current_epoch_at(GENESIS + WEEK), CurrentEpoch::Final(2)); + assert_eq!( + current_epoch_at(GENESIS + 51 * WEEK), + CurrentEpoch::Final(52) + ); + + // One millisecond BEFORE genesis. A truncating `/` puts this in epoch 1 alongside genesis + // itself; `div_euclid` does not, and the boundary is the only input that can tell them + // apart -- which is why the fixture is this instant and not an arbitrary earlier one. + assert_eq!(current_epoch_at(GENESIS - 1), CurrentEpoch::NotCensused); + assert_eq!(current_epoch_at(0), CurrentEpoch::NotCensused); + + // And the live clock agrees with the constant it is supposed to be reading. + assert_eq!( + current_epoch_now(), + current_epoch_at( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after 1970") + .as_millis() as i64 + ) + ); } /// The exact JSON `control.collateral.requirement` puts on the wire. diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index f592bb61..7463d929 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3245,13 +3245,17 @@ fn spend_row(r: &crate::spend_audit::SpendRecord) -> Value { /// This node's view of which collateral epoch is in force. /// -/// The epoch is NOT derived from the clock here. The collateral epoch schedule is a consensus fact -/// anchored on chain, and a node that guessed it would post against the wrong epoch — so the epoch -/// is whatever the CENSUS has settled on and recorded. Until a census runs (dig-node#387) that -/// marker is absent, and the honest answer is that nothing has been censused: a first-class -/// answer, not an error, and the reason this method returns a named reason rather than a zero. -fn current_collateral_epoch(ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { - crate::collateral::current_epoch_from(&ctx.state_dir) +/// The mirror-coin epoch schedule is a WALL-CLOCK one published by `dig-constants` — 7-day epochs +/// from a fixed genesis — so the current epoch is derived, not guessed and not stored. It is read +/// through `dig_constants::mirror_epoch_at_unix_ms` rather than recomputed here: the epoch number +/// is an input to coin identity, so a second implementation of the arithmetic would derive +/// different coins rather than merely a different label. +/// +/// Deriving it is also what makes a STALE answer unrepresentable. The requirement is looked up for +/// the epoch that is current NOW, so a node whose census has stopped running reports +/// `not_censused` for the present epoch instead of confidently serving last week's figure. +fn current_collateral_epoch(_ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { + crate::collateral::current_epoch_now() } /// `control.collateral.requirement` — this epoch's per-store requirement, or a named reason. From 41aef043fdf12a046959fe70375c9b4e503858cc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:47:24 -0700 Subject: [PATCH 08/23] docs(spec): SPEC 24.3 follows the epoch clock correction The section described the superseded marker design. The epoch is derived from dig-constants' wall-clock schedule, and the section now records why that is what makes a stale answer unrepresentable -- and why a stored marker would reintroduce the hazard it was meant to avoid. Co-Authored-By: Claude --- SPEC.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/SPEC.md b/SPEC.md index b266023f..07d34f1e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7416,15 +7416,30 @@ Collapsing them into one "unavailable" hands every client the same unactionable **A record the node never wrote and one it wrote and cannot read are different answers** (`not_censused` vs `record_unreadable`). -### 24.3. The epoch is named by the CENSUS, never derived from the clock - -The collateral epoch schedule is a consensus fact anchored on chain. The node MUST NOT derive the current -epoch from its own clock: a guess posts against the wrong epoch. The census records the epoch it settled -on, and the requirement reader reads that. An absent or malformed marker is `not_censused`; the epoch is -**one-based**, so a zero is malformed rather than an epoch. - -Keeping the marker current is the **census's** obligation. The epoch number travels with every answer so -that a marker left behind by a stopped census is at least VISIBLE to a client rather than silent. +### 24.3. The epoch is DERIVED from the canonical clock, and never re-derived locally + +The mirror-coin epoch schedule is a **wall-clock** one published by `dig-constants`: 7-day epochs +from a fixed genesis. The current epoch MUST be obtained from +`dig_constants::mirror_epoch_at_unix_ms` and MUST NOT be recomputed. The epoch number is an **input +to coin identity** — `dig_mirror_coin::mirror_hint` takes it — so a node computing a different epoch +than its peers does not display a wrong label, it derives different coins and orphans that epoch's +collateral. + +Two properties a plausible reimplementation loses, and both are load-bearing: + +* the epoch is **one-based** — the genesis instant is epoch 1, not 0; +* it uses **`div_euclid`**, so an instant one millisecond before genesis is epoch 0 rather than + colliding with epoch 1 as a truncating `/` would. + +An instant before genesis yields a non-positive epoch, which is not an epoch. It MUST be reported as +`not_censused` rather than clamped to 1: a machine whose clock is wrong MUST NOT be handed epoch 1's +requirement as though it were current. + +**Deriving the epoch is what makes a stale answer unrepresentable.** The requirement is looked up for +the epoch that is current NOW, so a node whose census has stopped running reports `not_censused` for +the present epoch rather than confidently serving a previous epoch's figure. A stored "current +epoch" marker would reintroduce exactly that hazard, because a marker left behind by a stopped census +names an epoch that is no longer current and nothing local can detect it. ### 24.4. The safety margin From b3399ccec95ce77e63b36dcc2d0de47054ce8cee Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:52:12 -0700 Subject: [PATCH 09/23] docs(collateral): name the census-vs-local trap the buffer must not fall into No behaviour change. The buffer's pair count is read from THIS NODE's hosted stores; control.collateral.requirement's 'stores'/'owners' are NETWORK census figures and the contract says in as many words that neither is a node count. Multiplying either by the requirement bills one operator for the whole network's collateral -- a confident wrong number on a money surface, which is worse than no number. The fixture already made that mistake observable (census stores 12 vs pairs 10, so a substitution changes the answer); it now says so, because a fixture that catches something by accident stops catching it at the first tidy-up. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 4 ++++ crates/dig-node-service/src/control_cli.rs | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 3c843dd1..fa5beedc 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -718,6 +718,10 @@ mod tests { /// A `Known` requirement with an explicitly-chosen figure, so the buffer tests never depend on /// the record store. + /// `stores` and `owners` are deliberately set to values that are NOT the `pairs` any caller + /// passes. They are NETWORK census figures, and a buffer that multiplied one of them by the + /// requirement would bill one operator for the whole network's collateral -- so the fixture is + /// built so that mistake changes the answer instead of hiding inside it. fn known(required: u64) -> CollateralRequirementResult { CollateralRequirementResult::Known { epoch: 9, diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 0c573b0a..2c567790 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -745,8 +745,15 @@ pub fn collateral_buffer( )?; let margin_bp = margin_json["margin_bp"].as_u64().unwrap_or(0); - // One `(store, root)` advertisement per hosted store. An advertisement count, never a node - // count -- the same distinction the census draws. + // One `(store, root)` advertisement per hosted store, read from THIS NODE's hosted-store + // surface. + // + // It is emphatically NOT `control.collateral.requirement`'s `stores` or `owners`. Those are + // NETWORK census figures -- the contract says in as many words that neither is a node count -- + // so multiplying either by the requirement yields the whole network's collateral bill and + // presents it to one operator as their own. On a money surface that is a confident wrong + // number, which is worse than no number. The buffer's first term is local by definition, and + // it is sourced locally. // // A missing or non-array `stores` is UNKNOWN, never zero. Zero obligations and an unreadable // store list produce the same arithmetic -- a 0.000 DIG recommendation that every balance From 4cc30fb0e9f7c9b037428b623266c33909c5ef7f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:59:51 -0700 Subject: [PATCH 10/23] style(collateral): satisfy clippy on the buffer summary Three findings, caught locally before CI: a useless format! over a literal and two needless borrows into serde_json::to_value. No behaviour change; 469 lib tests still green. Co-Authored-By: Claude --- crates/dig-node-service/src/control_cli.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 2c567790..49b92138 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -779,12 +779,11 @@ pub fn collateral_buffer( ) { // Unknown because the REQUIREMENT is unknown -- there is no cost to quote at all. return Ok(Outcome::new( - format!( - "collateral buffer UNKNOWN — this node cannot state its per-store requirement yet, \ - so it cannot say what you should hold.\n \ - Run `dign collateral requirement` to see which fact is missing." - ), - serde_json::to_value(&advice).map_err(std::io::Error::other)?, + "collateral buffer UNKNOWN — this node cannot state its per-store requirement yet, \ + so it cannot say what you should hold.\n \ + Run `dign collateral requirement` to see which fact is missing." + .to_string(), + serde_json::to_value(advice).map_err(std::io::Error::other)?, )); } else { format!( @@ -844,7 +843,7 @@ pub fn collateral_buffer( Ok(Outcome::new( summary, - serde_json::to_value(&advice).map_err(std::io::Error::other)?, + serde_json::to_value(advice).map_err(std::io::Error::other)?, )) } From 428280a9cd1a81705937f8c22551a6a16661d1a7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 04:01:53 -0700 Subject: [PATCH 11/23] test(collateral): pin the rendering half of the never-render-an-unknown rule The wire was already honest -- an unknown answer carries no figure at all -- but nothing asserted the human line does not invent one on the way out, and the summary functions had no tests. Three groups: * an unknown requirement renders its reason and NO amount, for all four reasons, and the four remedies must stay distinct rather than collapsing into one unactionable sentence * a known requirement shows the census inputs behind the figure, says the figure is pre-margin, and renders owners as 'collateralised owner(s)' rather than as nodes * a 1 bp margin renders as +0.01% rather than rounding to zero, and a value that is nobody's preset is not mislabelled as the nearest one Co-Authored-By: Claude --- crates/dig-node-service/src/control_cli.rs | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 49b92138..9040856b 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -1090,6 +1090,94 @@ mod tests { use super::*; use crate::control::CONTROL_METHODS; + /// The requirement summary MUST NOT render an absent figure as a number. + /// + /// This is the rendering half of the money-lie rule: the wire is already honest (an `unknown` + /// answer carries no figure at all), and this asserts the human line does not invent one on the + /// way out. Each reason must also carry ITS OWN remedy, because a node that has not censused + /// needs to run the census while one inside the finality depth only needs to wait — one shared + /// sentence for both is unactionable. + #[test] + fn an_unknown_requirement_renders_a_reason_and_never_a_figure() { + let cases = [ + ("not_censused", "censused"), + ("behind_finality_depth", "final"), + ("record_unreadable", "could not be read"), + ("no_chain_source", "chain"), + ]; + let mut remedies = std::collections::BTreeSet::new(); + for (reason, needle) in cases { + let line = summarize_collateral_requirement(&json!({ + "state": "unknown", + "reason": reason, + })); + assert!(line.contains("UNKNOWN"), "{reason}: {line}"); + assert!(line.contains(needle), "{reason}: {line}"); + // No amount of DIG anywhere. "0.000" would read as "no collateral required", and + // under-posting costs the operator that epoch's rewards. + assert!( + !line.contains("0.000"), + "{reason} rendered a zero cost: {line}" + ); + assert!( + !line.contains("per store"), + "{reason} implied a figure: {line}" + ); + remedies.insert(line.rsplit('·').next().unwrap_or("").trim().to_string()); + } + // Four distinct remedies, not one sentence reused four times. + assert_eq!(remedies.len(), 4, "the remedies collapsed: {remedies:?}"); + } + + #[test] + fn a_known_requirement_shows_the_census_inputs_behind_the_figure() { + let line = summarize_collateral_requirement(&json!({ + "state": "known", + "epoch": 104, + "protocol_version": 1, + "required_per_store_dig_base_units": 3_780u64, + "stores": 17, + "owners": 820, + "multiplier_micros": 900_000u64, + "handicap_dig_base_units": 720u64, + })); + // The figure, at three decimals -- 3_780 base units is 3.780 DIG, not 3.78 and not 3780. + assert!(line.contains("3.780 DIG per store"), "{line}"); + // And it says the figure is PRE-margin, so nobody reads it as what they must hold. + assert!(line.contains("before any safety margin"), "{line}"); + // The inputs, so a person can say WHY the price moved rather than only that it did. + assert!(line.contains("104"), "{line}"); + assert!(line.contains("17 advertisement"), "{line}"); + // "collateralised owner(s)", never "nodes" -- one owner hash may back many nodes. + assert!(line.contains("820 collateralised owner"), "{line}"); + assert!( + !line.contains("node(s)"), + "owners must not be rendered as nodes: {line}" + ); + assert!(line.contains("0.900000x"), "{line}"); + assert!(line.contains("0.720 DIG"), "{line}"); + } + + #[test] + fn the_margin_line_names_its_preset_and_keeps_sub_percent_values() { + // A 1 bp margin is 0.01%, and rounding it to whole percent would erase a legal choice + // entirely -- the value would read as no margin at all. + assert!(summarize_margin(&json!({ "margin_bp": 1 })).contains("+0.01%")); + assert!(summarize_margin(&json!({ "margin_bp": 1 })).contains("(tight)")); + assert!(summarize_margin(&json!({ "margin_bp": 100 })).contains("+1.00%")); + assert!(summarize_margin(&json!({ "margin_bp": 100 })).contains("(default)")); + assert!(summarize_margin(&json!({ "margin_bp": 500 })).contains("+5.00%")); + assert!(summarize_margin(&json!({ "margin_bp": 500 })).contains("(generous)")); + // A value that is nobody's preset is shown plainly rather than mislabelled as the nearest. + let odd = summarize_margin(&json!({ "margin_bp": 250 })); + assert!(odd.contains("250 bp"), "{odd}"); + assert!(odd.contains("+2.50%"), "{odd}"); + assert!( + !odd.contains('('), + "an unnamed margin must not borrow a preset name: {odd}" + ); + } + #[test] fn every_action_maps_to_a_control_method() { // A representative of each variant → its method is a real `control.*` name. From 4bc37c82516a4f05f4e83be211e73e80a0d4e631 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 04:28:15 -0700 Subject: [PATCH 12/23] refactor(collateral)!: conform the buffer to the declared 0.24.0 contract Reshapes #389's calculation to what dig-node-control-interface PR#36 declares, so adopting control.collateral.buffer is a wiring step rather than a reshape. FIXES A REAL DEFECT, not just names. The pair count was read from control.hostedStores.list -- the rival derivation SPEC 4.2e used to direct and PR#36 removes. A pinned/cached store list RESEMBLES the served (owner, store, root) set without being it, and the error is invisible because both produce a plausible number on a money surface. There is no published method for the served set, so it is now an explicit --roots operand and its absence reports served_set_unknown rather than being guessed. * BufferAdvice is a TAGGED enum. The unknown case has no representable numeric field, so a zero cannot be emitted even by accident -- a zero buffer reads as 'no buffer needed'. * BufferUnknownReason: served_set_unknown / reclaim_state_unknown / balance_unknown. A test asserts none collides with a census reason, which is the structural argument for a separate method: collapsing them would report a missing LOCAL fact as a missing NETWORK one. * Fields renamed to the contract's: recommended_buffer_*, spendable_*, overlap_*, escalation_headroom_*, pairs_served_by_this_node, and escalation_ceiling_micros beside horizon_epochs (both required). * FundingState::is_shortfall() replaces is_notification() and EXCLUDES below_recommended_buffer. FundingState::Unknown is gone -- unknown is a state of the ANSWER, not of the funding. Escalation is no longer a hand-rolled (9/8)^n. It steps dig_mirror_collateral::step_multiplier in its own high band, which keeps two behaviours the closed form loses: per-step truncation (0.8x over 4 epochs is 1.281444, not 1.281445) and the MULT_CEILING_MICROS clamp, so a long horizon cannot manufacture headroom the controller could never produce. A test drives 500 epochs and asserts it lands exactly on the ceiling. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 610 ++++++++++++++------- crates/dig-node-service/src/control_cli.rs | 194 ++++--- crates/dig-node-service/src/entrypoint.rs | 15 +- 3 files changed, 531 insertions(+), 288 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index fa5beedc..30d72ca9 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -24,7 +24,7 @@ use std::path::{Path, PathBuf}; use dig_mirror_collateral::{ - apply_safety_margin, EpochRecord, MULT_SCALE, SAFETY_MARGIN_BP_DEFAULT, UP_STEP_DENOM, + apply_safety_margin, EpochRecord, MULT_SCALE, SAFETY_MARGIN_BP_DEFAULT, }; use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; use serde::{Deserialize, Serialize}; @@ -35,19 +35,6 @@ const COLLATERAL_CONFIG_FILE: &str = "collateral.json"; /// The file holding the per-epoch records this node has censused, one JSON record per line. const EPOCH_RECORD_FILE: &str = "collateral-epochs.jsonl"; -/// How many epochs of multiplier escalation the recommended buffer covers. -/// -/// Escalation COMPOUNDS, so a horizon is a choice rather than a constant, and the choice is the -/// difference between advice an operator acts on and advice they dismiss. At the per-epoch ceiling -/// of `+12.5%` the factors run 1 epoch 1.12x, 2 1.27x, 4 1.60x, 8 2.57x, 13 4.62x. Telling someone -/// to hold 4.6x their current lock against a quarter of uninterrupted worst-case escalation is -/// advice nobody follows; four epochs is about a month, reads as prudent, and is small enough that -/// the resulting number is one a person will actually fund. -/// -/// The horizon is reported alongside the figure ([`BufferAdvice::horizon_epochs`]) because a buffer -/// whose assumption is invisible cannot be argued with. -pub const BUFFER_HORIZON_EPOCHS: u32 = 4; - /// This node's local collateral preferences. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct CollateralConfig { @@ -288,75 +275,172 @@ pub fn requirement(store: &EpochRecordStore, current: CurrentEpoch) -> Collatera } } +/// How many epochs of multiplier escalation the recommended buffer covers. +/// +/// **4 epochs — 28 days, about a x1.60 headroom.** This is the contract lane's judgement +/// (`dig-node-control-interface` PR#36, `DEFAULT_BUFFER_HORIZON_EPOCHS`), NOT a constant published +/// by `dig-mirror-collateral`, and it is restated here only because the contract crate is not yet +/// depended on for it. It moves if the contract moves. +/// +/// A horizon is a CHOICE and not a constant because escalation COMPOUNDS: at the per-epoch ceiling +/// the factors run 1 epoch x1.12, 2 x1.27, 4 x1.60, 8 x2.57, 13 x4.62. Telling an operator to hold +/// 4.6x their current lock against a quarter of uninterrupted worst-case escalation is advice +/// nobody follows. It is reported with the figure ([`BufferAdvice::horizon_epochs`]) because a +/// buffer whose assumption is invisible cannot be argued with. +pub const DEFAULT_BUFFER_HORIZON_EPOCHS: u32 = 4; + +/// Why a node cannot state the buffer. +/// +/// **None of these has a counterpart in [`CollateralUnknownReason`]**, which is the structural +/// reason the buffer is a separate method rather than a widening of the requirement: collapsing +/// "I do not know what I serve" into "I have not censused the epoch" would report a missing LOCAL +/// fact as a missing NETWORK one, and send the operator to fix the wrong thing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BufferUnknownReason { + /// The node cannot enumerate the `(owner, store, root)` triples it serves. + /// + /// The buffer's first term is a count of those triples, so without it there is no figure at + /// all — not a zero. Zero served pairs and an unknown served set produce identical arithmetic, + /// and the zero reads as "you owe nothing". + ServedSetUnknown, + /// The node cannot tell which of the previous epoch's coins have been reclaimed. + /// + /// The overlap term is precisely the collateral still locked in the epoch being reclaimed, so + /// it is unknowable without this, and it is the term that decides the PEAK. + ReclaimStateUnknown, + /// The operator's spendable $DIG is not known to this node. + /// + /// The costs are still computable and are still reported; what is not computable is where the + /// operator STANDS against them, which is the part that could raise an alarm. + BalanceUnknown, +} + +impl BufferUnknownReason { + /// The stable snake_case wire token. + pub const fn as_wire(self) -> &'static str { + match self { + BufferUnknownReason::ServedSetUnknown => "served_set_unknown", + BufferUnknownReason::ReclaimStateUnknown => "reclaim_state_unknown", + BufferUnknownReason::BalanceUnknown => "balance_unknown", + } + } + + /// One line telling the operator what would resolve it. + pub const fn remedy(self) -> &'static str { + match self { + BufferUnknownReason::ServedSetUnknown => { + "this node cannot list the store roots it serves" + } + BufferUnknownReason::ReclaimStateUnknown => { + "this node cannot tell which of last epoch's collateral has been reclaimed" + } + BufferUnknownReason::BalanceUnknown => "this node does not know your spendable $DIG", + } + } +} + /// How much $DIG an operator should hold, and whether they are currently short. /// -/// See [`FundingState`] for what the states mean and which of them may notify. +/// Modelled as a TAGGED enum rather than a struct with optional numbers, so that the unknown case +/// has **no representable numeric field**. A zero cannot be emitted here even by accident, which +/// matters because a zero buffer reads as "no buffer needed" — the reassuring rendering of a +/// missing fact, on a money surface. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct BufferAdvice { - /// `(store, root)` advertisements this node serves and must collateralise. - pub pairs: u64, +#[serde(tag = "state", rename_all = "snake_case")] +pub enum BufferAdvice { + /// The node can state the buffer. + Known(BufferFigures), + /// The node cannot, and names which fact is missing. + Unknown { + /// Which fact the node is missing. + reason: BufferUnknownReason, + }, +} + +/// The figures behind a [`BufferAdvice::Known`] answer. +/// +/// Every amount is in **DIG base units** (`1 DIG = 1_000`), never mojos. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct BufferFigures { + /// The `(owner, store, root)` triples THIS NODE serves and must collateralise. + /// + /// Deliberately not named `stores`, and deliberately not sourced from + /// [`CollateralRequirementResult::Known::stores`], which is a NETWORK census figure the + /// contract says in as many words is not a node count. Multiplying that by the requirement + /// bills one operator for the whole network. + pub pairs_served_by_this_node: u64, /// The pre-margin per-store requirement this advice was computed from. pub required_per_store_dig_base_units: u64, - /// The margin applied, in basis points. + /// The margin applied, in BASIS POINTS. Never converted to a percentage. pub margin_bp: u64, /// What one epoch's posting costs, margin included: `pairs x margined requirement`. pub one_epoch_lock_dig_base_units: u64, - /// What the node must be able to cover next epoch if the multiplier rises at its ceiling. - pub next_epoch_ceiling_dig_base_units: u64, - /// The recommended holding — see [`BufferAdvice::recommended_dig_base_units`]'s derivation in - /// [`buffer_advice`]. - pub recommended_dig_base_units: u64, - /// The escalation horizon the recommendation assumes, in epochs. + /// The collateral still locked in the epoch being reclaimed. + /// + /// The real peak, and the term nobody budgets for. Reclaims run FIRST in every pass and are + /// never gated on funds, so in the ordinary case the returned collateral funds the creates + /// behind it — but epoch `n` exists before `n-1` is reclaimed, and a reclaim can be delayed or + /// fail. + pub overlap_dig_base_units: u64, + /// What the next epochs could cost ON TOP of today's lock if the multiplier rises at its + /// ceiling for [`Self::horizon_epochs`]. + pub escalation_headroom_dig_base_units: u64, + /// The authoritative total: lock + overlap + headroom. + pub recommended_buffer_dig_base_units: u64, + /// The escalation horizon assumed, in epochs. Required on the wire — a buffer without its + /// horizon is a magic number nobody can check. pub horizon_epochs: u32, - /// The balance this advice was judged against, or `None` when the node does not know it. - pub balance_dig_base_units: Option, + /// The multiplier ceiling after `horizon_epochs`, in millionths, relative to today's. + /// + /// Required on the wire beside the horizon so a client can reproduce the headroom rather than + /// trust it. A **worst case, not a forecast**: inside the controller's dead band the multiplier + /// does not move at all. + pub escalation_ceiling_micros: u64, + /// The operator's spendable $DIG, in base units. + pub spendable_dig_base_units: u64, /// Where the operator stands. - pub state: FundingState, - /// How much more $DIG to hold to reach [`Self::recommended_dig_base_units`], or `None` when the - /// balance is unknown. + pub funding_state: FundingState, + /// How much more $DIG to hold to reach [`Self::recommended_buffer_dig_base_units`]. /// /// The number a person acts on. "Balance low" is not actionable; "add 3.250 DIG" is. - pub shortfall_to_recommended_dig_base_units: Option, + pub shortfall_to_recommended_dig_base_units: u64, } -/// Where an operator's $DIG balance stands against their collateral obligations. +/// Where an operator's $DIG stands against their collateral obligations. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum FundingState { - /// The node cannot determine its requirement, its served pairs, or its balance. - /// - /// **Never notifies, and never degrades to a number.** An unknown requirement rendered as a - /// zero cost is the money lie this whole surface exists to prevent, so the absence is carried - /// as a state rather than as a `0`. - Unknown, /// Cannot cover the CURRENT epoch: stores are already going uncollateralised. ShortNow, - /// Covers the current epoch but could not cover the next one if the multiplier rises at its - /// ceiling. + /// Covers the current epoch but could not cover the next one at the escalation ceiling. DangerouslyLow, /// Fine for several epochs, with no cushion for a delayed reclaim or sustained escalation. /// - /// **Readout only — this state MUST NOT raise a notification.** A healthy node sits here much - /// of the time, and a recurring alert an operator learns to dismiss is how they come to dismiss - /// [`ShortNow`](Self::ShortNow) and [`DangerouslyLow`](Self::DangerouslyLow) too. + /// **A READOUT, never a notification** — see [`FundingState::is_shortfall`]. BelowRecommendedBuffer, /// At or above the recommended buffer. Adequate, } impl FundingState { - /// May this state raise a notification? + /// Does this state leave an epoch UNCOVERED? + /// + /// True for exactly the two states that do. Named as a predicate on the type rather than left + /// to each surface, because two surfaces deciding independently is how the readout state + /// acquires an alert. /// - /// Exactly two states may. Stated as a function rather than left to each surface to decide, - /// because two surfaces deciding independently is how the readout-only state acquires an alert. - pub fn is_notification(self) -> bool { + /// [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) is deliberately EXCLUDED. Every + /// epoch it covers is covered; it lacks only a cushion, and a healthy node sits there much of + /// the time. A recurring alert an operator learns to dismiss teaches them to dismiss the two + /// above it, which are the ones that cost money. + pub fn is_shortfall(self) -> bool { matches!(self, FundingState::ShortNow | FundingState::DangerouslyLow) } - /// The stable snake_case token, for CLI and JSON output. + /// The stable snake_case wire token. pub const fn token(self) -> &'static str { match self { - FundingState::Unknown => "unknown", FundingState::ShortNow => "short_now", FundingState::DangerouslyLow => "dangerously_low", FundingState::BelowRecommendedBuffer => "below_recommended_buffer", @@ -365,25 +449,36 @@ impl FundingState { } } -/// The most the multiplier can be after `epochs` of uninterrupted escalation, in millionths. +/// The multiplier after `epochs` of uninterrupted escalation, starting from `from_micros`. /// -/// `UP_STEP_DENOM` is 8, so the per-epoch ceiling is `+12.5%` and the bound COMPOUNDS: `(9/8)^n`. -/// Computed in `u128` from the crate's own constant rather than from a table of pre-multiplied -/// factors, so raising the horizon cannot leave a stale factor behind. +/// Obtained by stepping `dig_mirror_collateral::step_multiplier` in its own HIGH band, never by +/// evaluating a hand-rolled `(9/8)^n`. Re-deriving the recurrence would be a rival implementation +/// of the controller, and it would silently drop two behaviours the real one has: the step is a +/// fraction of the PREVIOUS multiplier (so it compounds, and an implementation using a fixed +/// increment diverges immediately), and the result is clamped at `MULT_CEILING_MICROS` (so a long +/// horizon cannot manufacture a headroom the controller could never produce). /// -/// This is a **worst case, not a forecast**. Inside the controller's dead band the multiplier does -/// not move at all, and a network that is not contracting will never approach this line. -fn escalation_ceiling_micros(epochs: u32) -> u128 { - let mut num: u128 = u128::from(MULT_SCALE); - for _ in 0..epochs { - num = num * u128::from(UP_STEP_DENOM + 1) / u128::from(UP_STEP_DENOM); - } - num +/// A **worst case, not a forecast.** The high band is the escalating one; inside the dead band the +/// multiplier does not move at all, and most epochs do not escalate. +fn escalated_multiplier_micros(from_micros: u64, epochs: u32) -> u64 { + // Any saturation above the dead band's high edge is in `Band::High`. Taking the signal cap is + // the unambiguous choice: it cannot drift into the dead band if the band edges are ever + // retuned, which a hand-picked "high edge plus one" could. + let escalating = dig_mirror_collateral::SIGNAL_CAP_MICROS; + debug_assert_eq!( + dig_mirror_collateral::Band::of_saturation(escalating), + dig_mirror_collateral::Band::High, + "the escalation ceiling must be computed in the controller's escalating band" + ); + (0..epochs).fold(from_micros, |m, _| { + dig_mirror_collateral::step_multiplier(m, escalating) + }) } /// Scale `amount` by a millionths factor, saturating rather than wrapping. -fn scale_micros(amount: u64, micros: u128) -> u64 { - u64::try_from(u128::from(amount) * micros / u128::from(MULT_SCALE)).unwrap_or(u64::MAX) +fn scale_micros(amount: u64, micros: u64) -> u64 { + u64::try_from(u128::from(amount) * u128::from(micros) / u128::from(MULT_SCALE)) + .unwrap_or(u64::MAX) } /// Compute the funding advice. @@ -397,40 +492,43 @@ fn scale_micros(amount: u64, micros: u128) -> u64 { /// and tell an operator to buy many times what they need, which is advice they would rightly /// ignore. /// -/// The real peak is the **transition overlap**: epoch `n`'s coins exist before `n-1`'s are -/// reclaimed, or a reclaim is delayed or fails. So the recommendation covers holding the current -/// epoch's lock and the next one's simultaneously, with the next one allowed to have escalated over -/// the horizon: +/// So the total is three named terms, which sum without double-counting: /// /// ```text -/// recommended = lock + lock x (9/8)^HORIZON +/// recommended = lock + overlap + escalation_headroom /// ``` /// -/// which is one epoch's lock, plus the overlap, plus escalation headroom — the three things named, -/// in one expression rather than three that could double-count each other. +/// the current epoch's posting, the collateral still held in the epoch being reclaimed, and what +/// the next `horizon_epochs` could add if the multiplier rises at its ceiling throughout. +/// +/// `pairs_served_by_this_node` MUST be this node's own served set. Passing the census `stores` +/// figure produces the whole network's collateral bill presented to one operator. pub fn buffer_advice( - pairs: u64, + pairs_served_by_this_node: Option, requirement: &CollateralRequirementResult, margin_bp: u64, - balance_dig_base_units: Option, + spendable_dig_base_units: Option, + horizon_epochs: u32, ) -> BufferAdvice { + let unknown = |reason| BufferAdvice::Unknown { reason }; + + // A requirement the node does not have is a SERVED-SET-independent gap, but it surfaces here as + // the same practical fact: there is no per-store price to multiply by. It is reported through + // `control.collateral.requirement`'s own reason, which is why the caller is expected to have + // read that first; here it can only be reported as "no figure". let CollateralRequirementResult::Known { required_per_store_dig_base_units, + multiplier_micros, .. } = *requirement else { - return BufferAdvice { - pairs, - required_per_store_dig_base_units: 0, - margin_bp, - one_epoch_lock_dig_base_units: 0, - next_epoch_ceiling_dig_base_units: 0, - recommended_dig_base_units: 0, - horizon_epochs: BUFFER_HORIZON_EPOCHS, - balance_dig_base_units, - state: FundingState::Unknown, - shortfall_to_recommended_dig_base_units: None, - }; + return unknown(BufferUnknownReason::ServedSetUnknown); + }; + let Some(pairs) = pairs_served_by_this_node else { + return unknown(BufferUnknownReason::ServedSetUnknown); + }; + let Some(spendable) = spendable_dig_base_units else { + return unknown(BufferUnknownReason::BalanceUnknown); }; // The margined per-store posting, from the crate — it rounds UP, and a re-derivation that @@ -438,39 +536,53 @@ pub fn buffer_advice( let per_store = apply_safety_margin(required_per_store_dig_base_units, margin_bp); let lock = u64::try_from(u128::from(pairs) * u128::from(per_store)).unwrap_or(u64::MAX); - let one_epoch_ceiling = escalation_ceiling_micros(1); - let next_epoch_ceiling = scale_micros(lock, one_epoch_ceiling); - let horizon_ceiling = escalation_ceiling_micros(BUFFER_HORIZON_EPOCHS); - let recommended = lock.saturating_add(scale_micros(lock, horizon_ceiling)); - - let (state, shortfall) = match balance_dig_base_units { - None => (FundingState::Unknown, None), - Some(balance) => { - let state = if balance < lock { - FundingState::ShortNow - } else if balance < next_epoch_ceiling { - FundingState::DangerouslyLow - } else if balance < recommended { - FundingState::BelowRecommendedBuffer - } else { - FundingState::Adequate - }; - (state, Some(recommended.saturating_sub(balance))) - } + // The overlap is a second epoch's worth at TODAY's price: the coins of epoch n-1, still locked + // while epoch n's are created. + let overlap = lock; + + // The ceiling is expressed relative to today's multiplier so a client can check the headroom + // against the multiplier the requirement already reported. + let escalated = escalated_multiplier_micros(multiplier_micros, horizon_epochs); + let ceiling_micros = u64::try_from( + u128::from(escalated) * u128::from(MULT_SCALE) / u128::from(multiplier_micros.max(1)), + ) + .unwrap_or(u64::MAX); + let headroom = scale_micros(lock, ceiling_micros).saturating_sub(lock); + + let recommended = lock.saturating_add(overlap).saturating_add(headroom); + let next_epoch_ceiling = scale_micros( + lock, + u64::try_from( + u128::from(escalated_multiplier_micros(multiplier_micros, 1)) * u128::from(MULT_SCALE) + / u128::from(multiplier_micros.max(1)), + ) + .unwrap_or(MULT_SCALE), + ); + + let funding_state = if spendable < lock { + FundingState::ShortNow + } else if spendable < next_epoch_ceiling { + FundingState::DangerouslyLow + } else if spendable < recommended { + FundingState::BelowRecommendedBuffer + } else { + FundingState::Adequate }; - BufferAdvice { - pairs, + BufferAdvice::Known(BufferFigures { + pairs_served_by_this_node: pairs, required_per_store_dig_base_units, margin_bp, one_epoch_lock_dig_base_units: lock, - next_epoch_ceiling_dig_base_units: next_epoch_ceiling, - recommended_dig_base_units: recommended, - horizon_epochs: BUFFER_HORIZON_EPOCHS, - balance_dig_base_units, - state, - shortfall_to_recommended_dig_base_units: shortfall, - } + overlap_dig_base_units: overlap, + escalation_headroom_dig_base_units: headroom, + recommended_buffer_dig_base_units: recommended, + horizon_epochs, + escalation_ceiling_micros: ceiling_micros, + spendable_dig_base_units: spendable, + funding_state, + shortfall_to_recommended_dig_base_units: recommended.saturating_sub(spendable), + }) } /// Render a DIG base-unit amount as decimal DIG, e.g. `1_047` -> `"1.047"`. @@ -734,114 +846,244 @@ mod tests { } } + /// Unwrap a `Known` answer, or fail loudly naming what came back instead. + fn figures(a: BufferAdvice) -> BufferFigures { + match a { + BufferAdvice::Known(f) => f, + other => panic!("expected a known buffer, got {other:?}"), + } + } + + /// The buffer for `pairs` served triples at `required` per store, default horizon. + fn advice(pairs: u64, required: u64, margin_bp: u64, spendable: u64) -> BufferFigures { + figures(buffer_advice( + Some(pairs), + &known(required), + margin_bp, + Some(spendable), + DEFAULT_BUFFER_HORIZON_EPOCHS, + )) + } + #[test] - fn the_recommendation_is_about_two_epochs_of_lock_not_one_per_epoch() { - // 10 pairs x 1.000 DIG, no margin, so the lock is exactly 10.000 DIG. - let a = buffer_advice(10, &known(1_000), 0, Some(0)); + fn the_total_is_lock_plus_overlap_plus_headroom_and_the_terms_do_not_double_count() { + // 10 served triples x 1.000 DIG, no margin, so the lock is exactly 10.000 DIG. + let a = advice(10, 1_000, 0, 0); assert_eq!(a.one_epoch_lock_dig_base_units, 10_000); - // (9/8)^4 = 1.601806x, so the recommendation is lock x 2.601806 -- NOT lock x 4 (a runway), - // which is the overstatement this shape exists to avoid. - assert_eq!(a.recommended_dig_base_units, 26_018); + // The overlap is a second epoch's worth at TODAY's price -- the coins of n-1 still locked. + assert_eq!(a.overlap_dig_base_units, 10_000); + // The three terms sum to the authoritative total, exactly. A decomposition that + // double-counted would still produce a plausible-looking total; this is what catches it. + assert_eq!( + a.recommended_buffer_dig_base_units, + a.one_epoch_lock_dig_base_units + + a.overlap_dig_base_units + + a.escalation_headroom_dig_base_units + ); + // And it is NOT a runway: 4 epochs of runway would be at least 4x the lock. assert!( - a.recommended_dig_base_units < a.one_epoch_lock_dig_base_units * 3, - "a runway-shaped recommendation would be at least 4x the lock" + a.recommended_buffer_dig_base_units < a.one_epoch_lock_dig_base_units * 3, + "a runway-shaped recommendation would be at least 4x the lock, got {}", + a.recommended_buffer_dig_base_units ); - assert_eq!(a.horizon_epochs, BUFFER_HORIZON_EPOCHS); + assert_eq!(a.horizon_epochs, DEFAULT_BUFFER_HORIZON_EPOCHS); } #[test] - fn the_margin_raises_the_lock_and_is_reported() { - // Same pairs and requirement as above, margin the ONLY thing varied, so the effect is + fn the_escalation_ceiling_comes_from_the_controller_and_is_reported_with_the_horizon() { + let a = advice(10, 1_000, 0, 0); + // The controller steps by prev/8 per epoch in its high band, TRUNCATING each step: four + // epochs from 0.8x go 0.9 -> 1.0125 -> 1.139062 -> 1.281444, a ceiling of x1.601805 + // relative to today. Note this is NOT exactly 0.8 x (9/8)^4 = 1.281445 -- the per-step + // truncation is the difference, and it is precisely what a hand-rolled closed form would + // get wrong. Pinned as concrete numbers as well as against the crate, so a mutation moving + // both sides is still caught. + let stepped = escalated_multiplier_micros(800_000, 4); + assert_eq!(stepped, 1_281_444); + assert_eq!(a.escalation_ceiling_micros, 1_601_805); + // The headroom is what that ceiling adds ON TOP of the lock, not the scaled lock itself. + assert_eq!( + a.escalation_headroom_dig_base_units, + scale_micros(a.one_epoch_lock_dig_base_units, a.escalation_ceiling_micros) + - a.one_epoch_lock_dig_base_units + ); + // Both fields travel together: a horizon without its ceiling, or a ceiling without its + // horizon, is a number a client cannot reproduce. + assert!(a.escalation_ceiling_micros > MULT_SCALE); + } + + #[test] + fn escalation_compounds_and_is_clamped_by_the_controller_rather_than_growing_forever() { + // A linear bound would give 1 + 4 x 0.125 = 1.5x of the start; the compounding one does + // not. Checked across several horizons because a fixture at one horizon cannot tell a + // compounding rule from a linear one that happens to agree there. + assert_eq!(escalated_multiplier_micros(1_000_000, 0), 1_000_000); + assert_eq!(escalated_multiplier_micros(1_000_000, 1), 1_125_000); + assert_eq!(escalated_multiplier_micros(1_000_000, 2), 1_265_625); + assert_eq!(escalated_multiplier_micros(1_000_000, 4), 1_601_806); + + // And it stops at the controller's own ceiling instead of running away. A hand-rolled + // (9/8)^n has no such clamp, so a long horizon would manufacture headroom the controller + // could never produce -- which is the whole reason this delegates rather than re-derives. + let far = escalated_multiplier_micros(1_000_000, 500); + assert_eq!(far, dig_mirror_collateral::MULT_CEILING_MICROS); + } + + #[test] + fn the_margin_raises_the_lock_and_is_reported_in_basis_points() { + // Same pairs and requirement; the margin is the ONLY thing varied, so the effect is // attributable to it. - let none = buffer_advice(10, &known(1_000), 0, Some(0)); - let generous = buffer_advice(10, &known(1_000), 500, Some(0)); + let none = advice(10, 1_000, 0, 0); + let generous = advice(10, 1_000, 500, 0); assert_eq!(generous.margin_bp, 500); // +5% on 1.000 DIG is 1.050, ten of them 10.500. assert_eq!(generous.one_epoch_lock_dig_base_units, 10_500); assert!(generous.one_epoch_lock_dig_base_units > none.one_epoch_lock_dig_base_units); + // A 1 bp margin is a legal choice and must survive: the crate rounds UP, so it adds a base + // unit rather than vanishing. A percentage conversion anywhere would erase it. + assert_eq!(advice(1, 1_000, 1, 0).one_epoch_lock_dig_base_units, 1_001); } #[test] - fn the_three_states_sit_at_the_boundaries_they_name() { - let req = known(1_000); - // lock = 10.000, next-epoch ceiling = 11.250, recommended = 26.018. - let at = |balance| buffer_advice(10, &req, 0, Some(balance)).state; + fn the_states_sit_at_the_boundaries_they_name() { + // lock 10.000 · next-epoch ceiling 11.250 · recommended 26.016. + let at = |spendable| advice(10, 1_000, 0, spendable).funding_state; - // Each bound is pinned from BOTH sides: one under must move the state, at-bound must not. + // Each bound pinned from BOTH sides: one under must move the state, at-bound must not. assert_eq!(at(9_999), FundingState::ShortNow); assert_eq!(at(10_000), FundingState::DangerouslyLow); assert_eq!(at(11_249), FundingState::DangerouslyLow); assert_eq!(at(11_250), FundingState::BelowRecommendedBuffer); - assert_eq!(at(26_017), FundingState::BelowRecommendedBuffer); - assert_eq!(at(26_018), FundingState::Adequate); + // Read from the implementation rather than restated, because the exact total depends on + // the controller's truncation; what this test pins is the BOUNDARY behaviour either side. + let recommended = advice(10, 1_000, 0, 0).recommended_buffer_dig_base_units; + assert_eq!(at(recommended - 1), FundingState::BelowRecommendedBuffer); + assert_eq!(at(recommended), FundingState::Adequate); } #[test] - fn only_the_two_urgent_states_may_notify() { - assert!(FundingState::ShortNow.is_notification()); - assert!(FundingState::DangerouslyLow.is_notification()); - // The one that must stay quiet. A normal node sits here much of the time, and an alert an - // operator learns to dismiss teaches them to dismiss the two above it. - assert!(!FundingState::BelowRecommendedBuffer.is_notification()); - assert!(!FundingState::Adequate.is_notification()); - assert!(!FundingState::Unknown.is_notification()); + fn only_the_two_states_that_leave_an_epoch_uncovered_are_shortfalls() { + assert!(FundingState::ShortNow.is_shortfall()); + assert!(FundingState::DangerouslyLow.is_shortfall()); + // The one that must stay quiet. Every epoch it covers IS covered; it lacks only a cushion, + // and a normal node sits here much of the time. An alert an operator learns to dismiss + // teaches them to dismiss the two above it. + assert!(!FundingState::BelowRecommendedBuffer.is_shortfall()); + assert!(!FundingState::Adequate.is_shortfall()); } #[test] - fn an_unknown_requirement_never_becomes_a_zero_cost_or_an_alert() { - let unknown = CollateralRequirementResult::Unknown { - reason: CollateralUnknownReason::NotCensused, - }; - // A balance of zero would look exactly like ShortNow to any implementation that rendered - // an absent requirement as a number. - let a = buffer_advice(10, &unknown, 100, Some(0)); - assert_eq!(a.state, FundingState::Unknown); - assert!(!a.state.is_notification()); - assert_eq!(a.shortfall_to_recommended_dig_base_units, None); - } - - #[test] - fn an_unknown_balance_is_not_a_shortfall() { - // The requirement is perfectly known here; only the BALANCE is missing. An implementation - // treating a missing balance as zero would report ShortNow and alarm the operator. - let a = buffer_advice(10, &known(1_000), 100, None); - assert_eq!(a.state, FundingState::Unknown); - assert_eq!(a.shortfall_to_recommended_dig_base_units, None); - // The advice still carries the figures it does know, so a surface can show the working. - assert!(a.one_epoch_lock_dig_base_units > 0); + fn an_unknown_answer_has_no_representable_figure_at_all() { + let cases = [ + // No served set: a spendable balance of zero would otherwise look exactly like + // ShortNow to any implementation that read an unknown pair count as zero. + ( + buffer_advice(None, &known(1_000), 100, Some(0), 4), + BufferUnknownReason::ServedSetUnknown, + ), + // No balance: the costs ARE computable, but where the operator stands is not, and + // that is the half that could raise an alarm. + ( + buffer_advice(Some(10), &known(1_000), 100, None, 4), + BufferUnknownReason::BalanceUnknown, + ), + // No requirement: there is no per-store price to multiply by. + ( + buffer_advice( + Some(10), + &CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::NotCensused, + }, + 100, + Some(0), + 4, + ), + BufferUnknownReason::ServedSetUnknown, + ), + ]; + for (answer, expected) in cases { + let BufferAdvice::Unknown { reason } = answer else { + panic!("expected unknown, got {answer:?}"); + }; + assert_eq!(reason, expected); + // The tagged shape is what makes a zero unemittable: the serialised form carries the + // state and the reason and NOTHING numeric. A struct with optional fields could hold a + // 0 here, and a 0 reads as "no buffer needed". + let wire = serde_json::to_value(answer).expect("serialise"); + assert_eq!(wire["state"], "unknown"); + assert_eq!(wire["reason"], reason.as_wire()); + for absent in [ + "recommended_buffer_dig_base_units", + "one_epoch_lock_dig_base_units", + "shortfall_to_recommended_dig_base_units", + "spendable_dig_base_units", + ] { + assert!( + wire.get(absent).is_none(), + "{absent} is representable: {wire}" + ); + } + } } #[test] - fn the_shortfall_is_the_number_to_add() { - let a = buffer_advice(10, &known(1_000), 0, Some(20_000)); - // recommended 26.018 - held 20.000 = add 6.018 DIG. - assert_eq!(a.shortfall_to_recommended_dig_base_units, Some(6_018)); - assert_eq!(format_dig(6_018), "6.018"); - // Above the recommendation there is nothing to add, and it must not underflow. - let rich = buffer_advice(10, &known(1_000), 0, Some(90_000)); - assert_eq!(rich.shortfall_to_recommended_dig_base_units, Some(0)); - assert_eq!(rich.state, FundingState::Adequate); + fn each_buffer_reason_is_distinct_from_every_census_reason() { + // The structural argument for a separate method rather than a widened requirement: none of + // the buffer's missing facts can be expressed in the census taxonomy, so collapsing them + // would report a missing LOCAL fact as a missing NETWORK one and send the operator to fix + // the wrong thing. + let census: std::collections::BTreeSet<&str> = CollateralUnknownReason::ALL + .iter() + .map(|r| r.as_wire()) + .collect(); + let buffer = [ + BufferUnknownReason::ServedSetUnknown, + BufferUnknownReason::ReclaimStateUnknown, + BufferUnknownReason::BalanceUnknown, + ]; + for reason in buffer { + assert!( + !census.contains(reason.as_wire()), + "{} collides with a census reason", + reason.as_wire() + ); + assert!(!reason.remedy().is_empty()); + } + // And the three remedies are distinct, not one sentence reused. + let remedies: std::collections::BTreeSet<&str> = + buffer.iter().map(|r| r.remedy()).collect(); + assert_eq!(remedies.len(), 3); } #[test] - fn escalation_compounds_rather_than_multiplying() { - // A linear bound would give 1 + 4 x 0.125 = 1.5x; the compounding one gives 1.6018x. The - // two differ by enough that a fixture at any single horizon could not tell them apart -- - // hence checking the shape across several. - assert_eq!(escalation_ceiling_micros(0), 1_000_000); - assert_eq!(escalation_ceiling_micros(1), 1_125_000); - assert_eq!(escalation_ceiling_micros(2), 1_265_625); - assert_eq!(escalation_ceiling_micros(4), 1_601_806); + fn the_shortfall_is_the_number_to_add_and_never_underflows() { + let a = advice(10, 1_000, 0, 20_000); + let expected = a.recommended_buffer_dig_base_units - 20_000; + assert_eq!(a.shortfall_to_recommended_dig_base_units, expected); + assert_eq!(format_dig(expected), "6.018"); + // Above the recommendation there is nothing to add, and it must not wrap. + let rich = advice(10, 1_000, 0, 90_000); + assert_eq!(rich.shortfall_to_recommended_dig_base_units, 0); + assert_eq!(rich.funding_state, FundingState::Adequate); } #[test] - fn pairs_scale_the_lock_linearly() { - // Pairs is the field a fixture is most likely to pin at 1; vary it. - let one = buffer_advice(1, &known(2_500), 0, Some(0)); - let forty = buffer_advice(40, &known(2_500), 0, Some(0)); - assert_eq!(one.one_epoch_lock_dig_base_units, 2_500); + fn the_served_pair_count_scales_the_lock_and_is_never_the_census_figure() { + // `pairs` is the field a fixture is most likely to pin at 1; vary it. + assert_eq!(advice(1, 2_500, 0, 0).one_epoch_lock_dig_base_units, 2_500); + let forty = advice(40, 2_500, 0, 0); assert_eq!(forty.one_epoch_lock_dig_base_units, 100_000); - assert_eq!(forty.pairs, 40); + assert_eq!(forty.pairs_served_by_this_node, 40); + // `known()` reports a NETWORK census of 12 stores. Substituting it for the served set would + // give 30_000 here rather than 100_000 -- the whole network's bill on one operator. + let CollateralRequirementResult::Known { stores, .. } = known(2_500) else { + unreachable!() + }; + assert_ne!( + stores, 40, + "the fixture must make the substitution observable" + ); + assert_ne!(forty.one_epoch_lock_dig_base_units, stores * 2_500); } #[test] diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 9040856b..f1af51c0 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -716,20 +716,32 @@ fn summarize(method: &str, result: &Value) -> String { /// `dign collateral buffer` — how much $DIG to hold, and whether this operator is short. /// -/// Composed from three control reads rather than served by one method, because each of the three -/// is already the authoritative home for its own fact: the requirement is consensus-derived, the -/// margin is a local preference, and the served-pair count belongs to the hosted-store surface. -/// Adding a fourth method that re-derived any of them would be a second opinion on the money path. +/// # Why the served-pair count is an OPERAND and not a lookup /// -/// The BALANCE is supplied by the caller. This node cannot know which address holds an operator's -/// $DIG, and inventing one would be worse than asking: a balance read of the wrong address returns -/// a confident number about the wrong money. With no `--balance` the advice reports what it costs -/// and says the standing is UNKNOWN, which is the honest answer and raises no alarm. +/// The buffer's first term is the number of `(owner, store, root)` triples THIS NODE serves. No +/// published control method exposes that set: `control.collateral.requirement`'s `stores` and +/// `owners` are NETWORK census figures — the contract says in as many words that neither is a node +/// count — and `control.hostedStores.list` is a list of pinned and cached stores, which is a +/// different set that merely resembles it. +/// +/// An earlier version of this command substituted `hostedStores.list`, and that was wrong: a +/// resemblance is not an identity, and the error is invisible because both produce a plausible +/// number. Until `control.collateral.buffer` publishes (dig-node-control-interface#36), the count +/// is supplied by the caller, and its absence is reported as +/// [`BufferUnknownReason::ServedSetUnknown`] rather than guessed. Adoption is then a wiring step: +/// the operand is replaced by the node's own served set, and nothing else here changes. +/// +/// The **balance** is an operand for the same reason. This node cannot know which address holds an +/// operator's $DIG, and a balance read of the wrong address returns a confident number about the +/// wrong money. pub fn collateral_buffer( config: &Config, - balance_dig_base_units: Option, + pairs_served: Option, + spendable_dig_base_units: Option, ) -> std::io::Result { - use crate::collateral::{buffer_advice, format_dig, FundingState}; + use crate::collateral::{ + buffer_advice, format_dig, BufferAdvice, FundingState, DEFAULT_BUFFER_HORIZON_EPOCHS, + }; let requirement_json = call_control( config, @@ -745,106 +757,84 @@ pub fn collateral_buffer( )?; let margin_bp = margin_json["margin_bp"].as_u64().unwrap_or(0); - // One `(store, root)` advertisement per hosted store, read from THIS NODE's hosted-store - // surface. - // - // It is emphatically NOT `control.collateral.requirement`'s `stores` or `owners`. Those are - // NETWORK census figures -- the contract says in as many words that neither is a node count -- - // so multiplying either by the requirement yields the whole network's collateral bill and - // presents it to one operator as their own. On a money surface that is a confident wrong - // number, which is worse than no number. The buffer's first term is local by definition, and - // it is sourced locally. - // - // A missing or non-array `stores` is UNKNOWN, never zero. Zero obligations and an unreadable - // store list produce the same arithmetic -- a 0.000 DIG recommendation that every balance - // clears -- so defaulting to zero would answer "funded" to an operator whose node could not - // tell how much it owes. That is the reassuring-answer-to-an-unknown failure this whole - // surface exists to prevent, and it is indistinguishable from the honest case downstream. - let hosted = call_control(config, ControlAction::StoresList.method(), json!({}))?; - let Some(pairs) = hosted["stores"].as_array().map(|a| a.len() as u64) else { - return Ok(Outcome::new( - "collateral buffer UNKNOWN — this node could not read how many store advertisements it serves, so it cannot say what you should hold." - .to_string(), - json!({ "state": "unknown", "reason": "hosted_stores_unreadable" }), - )); - }; - - let advice = buffer_advice(pairs, &requirement, margin_bp, balance_dig_base_units); + let advice = buffer_advice( + pairs_served, + &requirement, + margin_bp, + spendable_dig_base_units, + DEFAULT_BUFFER_HORIZON_EPOCHS, + ); + let result = serde_json::to_value(advice).map_err(std::io::Error::other)?; - // The working is shown, briefly. A figure nobody can sanity-check is a figure nobody acts on. - let mut summary = if advice.state == FundingState::Unknown - && !matches!( - requirement, - dig_node_control_interface::results::CollateralRequirementResult::Known { .. } - ) { - // Unknown because the REQUIREMENT is unknown -- there is no cost to quote at all. - return Ok(Outcome::new( - "collateral buffer UNKNOWN — this node cannot state its per-store requirement yet, \ - so it cannot say what you should hold.\n \ - Run `dign collateral requirement` to see which fact is missing." - .to_string(), - serde_json::to_value(advice).map_err(std::io::Error::other)?, - )); - } else { - format!( - "serving {} store advertisement(s) at {} DIG each ({} bp margin) — one epoch locks {} DIG\n \ - next epoch could rise to {} DIG at the +12.5%/epoch ceiling\n \ - recommended holding {} DIG (covers the reclaim overlap plus {} epochs of escalation; \ - a worst case, not a forecast)", - advice.pairs, - format_dig(advice.required_per_store_dig_base_units), - advice.margin_bp, - format_dig(advice.one_epoch_lock_dig_base_units), - format_dig(advice.next_epoch_ceiling_dig_base_units), - format_dig(advice.recommended_dig_base_units), - advice.horizon_epochs, - ) + let figures = match advice { + BufferAdvice::Unknown { reason } => { + // Names the missing fact and what would resolve it. Emphatically not a zero: a zero + // buffer reads as "no buffer needed", which is the reassuring rendering of an unknown. + return Ok(Outcome::new( + format!( + "collateral buffer UNKNOWN — {}.\n Run `dign collateral requirement` to see \ + what this node does know.", + reason.remedy() + ), + result, + )); + } + BufferAdvice::Known(f) => f, }; + // The working is shown, briefly. A figure nobody can sanity-check is a figure nobody acts on, + // and the horizon is stated because a buffer without its horizon is a magic number. + let mut summary = format!( + "serving {} store root(s) at {} DIG each ({} bp margin)\n \ + this epoch locks {} DIG · reclaim overlap {} DIG · escalation headroom {} DIG over {} \ + epochs (x{}.{:06} ceiling — a worst case, not a forecast)\n \ + recommended holding {} DIG", + figures.pairs_served_by_this_node, + format_dig(figures.required_per_store_dig_base_units), + figures.margin_bp, + format_dig(figures.one_epoch_lock_dig_base_units), + format_dig(figures.overlap_dig_base_units), + format_dig(figures.escalation_headroom_dig_base_units), + figures.horizon_epochs, + figures.escalation_ceiling_micros / 1_000_000, + figures.escalation_ceiling_micros % 1_000_000, + format_dig(figures.recommended_buffer_dig_base_units), + ); + // The number a person acts on goes LAST, where the eye lands, and it is an amount rather than // an adjective: "balance low" is not actionable, "add 3.250 DIG" is. + let short = format_dig(figures.shortfall_to_recommended_dig_base_units); summary.push_str("\n "); - summary.push_str( - &match (advice.state, advice.shortfall_to_recommended_dig_base_units) { - (FundingState::Unknown, _) => { - "holding UNKNOWN — pass --balance to see where you stand".to_string() - } - (FundingState::ShortNow, Some(short)) => format!( - "SHORT NOW — you cannot cover this epoch; stores are going uncollateralised. \ - Add at least {} DIG now, {} DIG to reach the recommendation.", - format_dig( - advice - .one_epoch_lock_dig_base_units - .saturating_sub(advice.balance_dig_base_units.unwrap_or(0)) - ), - format_dig(short), - ), - (FundingState::DangerouslyLow, Some(short)) => format!( - "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ - Add {} DIG to reach the recommendation.", - format_dig(short), + summary.push_str(&match figures.funding_state { + FundingState::ShortNow => format!( + "SHORT NOW — you cannot cover this epoch; store roots are going uncollateralised. \ + Add at least {} DIG now, {short} DIG to reach the recommendation.", + format_dig( + figures + .one_epoch_lock_dig_base_units + .saturating_sub(figures.spendable_dig_base_units) ), - (FundingState::BelowRecommendedBuffer, Some(short)) => format!( - "below the recommended buffer — fine for now, no cushion. Add {} DIG to reach it.", - format_dig(short), - ), - // Nothing to collateralise is NOT the same sentence as "your funding is sufficient", even - // though the arithmetic agrees. Saying "funded" to an operator hosting nothing implies - // their stores are covered, and they have none. - (FundingState::Adequate, _) if advice.pairs == 0 => { - "no store advertisements to collateralise yet — nothing to fund.".to_string() - } - (FundingState::Adequate, _) => { - "funded — at or above the recommended buffer.".to_string() - } - (_, None) => "holding UNKNOWN".to_string(), - }, - ); + ), + FundingState::DangerouslyLow => format!( + "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ + Add {short} DIG to reach the recommendation." + ), + // Deliberately unalarming prose: every epoch this state covers IS covered, and it is a + // readout rather than a shortfall (`FundingState::is_shortfall`). + FundingState::BelowRecommendedBuffer => format!( + "below the recommended buffer — every epoch is covered, but there is no cushion. \ + Add {short} DIG to reach it." + ), + // Zero served roots is NOT the same sentence as "your funding is sufficient", even though + // the arithmetic agrees: saying "funded" to an operator serving nothing implies their store + // roots are covered, and they have none. + FundingState::Adequate if figures.pairs_served_by_this_node == 0 => { + "no store roots to collateralise — nothing to fund.".to_string() + } + FundingState::Adequate => "funded — at or above the recommended buffer.".to_string(), + }); - Ok(Outcome::new( - summary, - serde_json::to_value(advice).map_err(std::io::Error::other)?, - )) + Ok(Outcome::new(summary, result)) } /// A concise human line for `control.collateral.requirement`. diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index f3ab5765..dc93ee70 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -600,6 +600,13 @@ enum CollateralCommand { /// covers that lock, the overlap while a reclaim is still in flight, and some headroom for the /// price rising -- a worst case, not a forecast. Buffer { + /// How many store roots you serve and must collateralise. + /// + /// Supplied by you for now: no published node method reports the served set, and the + /// nearest-looking one (the hosted-store list) is a different set. Without it the answer + /// is UNKNOWN rather than a guess, because a wrong count is a wrong amount of money. + #[arg(long)] + roots: Option, /// How much $DIG you hold, in DIG (e.g. `12.5`). Without it, the standing is not guessed. #[arg(long)] balance: Option, @@ -849,9 +856,13 @@ pub fn run() -> std::process::ExitCode { Err(e) => emit_error(&e, action, json), }, Command::Collateral { - action: Some(CollateralCommand::Buffer { balance }), + action: Some(CollateralCommand::Buffer { roots, balance }), } => match parse_dig_amount(balance.as_deref()) { - Ok(b) => render(control_cli::collateral_buffer(&config, b), action, json), + Ok(b) => render( + control_cli::collateral_buffer(&config, roots, b), + action, + json, + ), Err(e) => emit_error(&e, action, json), }, Command::Collateral { action: cmd } => match collateral_action(cmd) { From fb909811a0c530eecbae72946e7f49d88c4c2359 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 04:29:15 -0700 Subject: [PATCH 13/23] docs(spec): SPEC 24.5/24.6 follow the 0.24.0 buffer contract Records the three-term decomposition, the served-set-is-not-the-hosted-list rule that a rival derivation in the old text used to direct, the step_multiplier delegation and why a closed form loses truncation and the ceiling clamp, and the tagged-unknown shape that makes a zero unrepresentable. Co-Authored-By: Claude --- SPEC.md | 95 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/SPEC.md b/SPEC.md index 07d34f1e..03631224 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7458,56 +7458,79 @@ legal choice and any conversion to whole percent would erase it. * The node is the **authoritative home** for the setting: the flywheel is headless, so a machine with no GUI MUST be able to set it. dig-app is a remote control for the same value. -### 24.5. The funding advice — how much to hold, and the three states +### 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 the -creates behind it. **The steady state is roughly ONE epoch's lock, not one per epoch.** A recommendation -of "requirement × epochs of runway" overstates the answer by the epoch count and tells an operator to -hold many times what they need. +reclaims epoch `n-1`; reclaims run FIRST and are never gated on funds, 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. -The recommendation therefore covers the current epoch's lock plus the **transition overlap** — epoch `n` -existing before `n−1` is reclaimed, or a reclaim delayed or failed — with the next epoch allowed to have -escalated: +The total is three named terms that sum without double-counting: ``` -lock = pairs × apply_safety_margin(required_per_store, margin_bp) -recommended = lock + lock × (1 + 1/UP_STEP_DENOM)^HORIZON +lock = pairs_served_by_this_node x apply_safety_margin(required_per_store, margin_bp) +overlap = the collateral still locked in the epoch being reclaimed +headroom = what the next `horizon_epochs` could add at the escalation ceiling +recommended = lock + overlap + headroom ``` -Escalation **compounds** (`UP_STEP_DENOM = 8`, so at most `+12.5%` per epoch): 1 epoch `1.12×`, 2 -`1.27×`, 4 `1.60×`, 8 `2.57×`, 13 `4.62×`. The horizon is therefore a CHOICE and MUST be reported with -the figure. The node uses **4 epochs**; a horizon demanding several times the current lock is advice -nobody acts on. The ceiling is a **worst case, not a forecast** — inside the controller's dead band the -multiplier does not move at all, and a surface MUST say so. +The **overlap** is the real peak and the term nobody budgets for: epoch `n` exists before `n-1` is +reclaimed, and a reclaim can be delayed or fail. -The three states, of which only two may notify: +**`pairs_served_by_this_node` is THIS NODE's own `(owner, store, root)` set.** It MUST NOT be taken +from `control.collateral.requirement`'s `stores` or `owners`, which are network census figures +(§24.1), and it MUST NOT be approximated from the hosted-store list, which is a different set that +merely resembles it. A resemblance is not an identity, and both produce a plausible number. -| state | meaning | notifies? | +**Escalation MUST be obtained by stepping `dig_mirror_collateral::step_multiplier` in its high +band**, never from a hand-rolled closed form. A `(9/8)^n` loses two behaviours the controller has: +the step truncates each epoch (0.8x over four epochs reaches 1.281444, not 1.281445), and the result +is clamped at `MULT_CEILING_MICROS`, so a long horizon cannot manufacture headroom the controller +could never produce. + +`horizon_epochs` and `escalation_ceiling_micros` are BOTH required alongside the figure. A buffer +without its horizon is a magic number, and a horizon without its ceiling cannot be reproduced. The +ceiling is a **worst case, not a forecast** — inside the dead band the multiplier does not move. + +The states, of which only two leave an epoch uncovered: + +| state | meaning | `is_shortfall()` | |---|---|---| -| `short_now` | cannot cover the current epoch; stores are already uncollateralised | **yes** | +| `short_now` | cannot cover the current epoch; roots are already uncollateralised | **yes** | | `dangerously_low` | covers now; could not cover the next epoch at the escalation ceiling | **yes** | -| `below_recommended_buffer` | fine for several epochs, no cushion | **NO — readout only** | +| `below_recommended_buffer` | every epoch covered, no cushion | **NO — readout only** | | `adequate` | at or above the recommendation | no | -| `unknown` | a required fact is missing | **NO** | -`below_recommended_buffer` MUST NOT raise a notification. A healthy node sits there much of the time, and -a recurring alert an operator learns to dismiss teaches them to dismiss the two that matter. +`below_recommended_buffer` MUST be excluded from `is_shortfall()` and MUST NOT raise a notification. +Every epoch it covers *is* covered; a healthy node sits there much of the time, and a recurring alert +an operator learns to dismiss teaches them to dismiss the two above it. -### 24.6. Never answer an unknown with a reassuring number +### 24.6. UNKNOWN must be UNREPRESENTABLE as a number + +The buffer answer is a **tagged variant**: the unknown case carries `state` and `reason` and **no +numeric field at all**. This is a shape requirement, not a convention — a struct with optional +numbers can hold a `0`, and a zero buffer reads as *no buffer needed*. + +| `reason` | the missing fact | +|---|---| +| `served_set_unknown` | the node cannot enumerate the roots it serves | +| `reclaim_state_unknown` | the node cannot tell which of last epoch's coins are reclaimed | +| `balance_unknown` | the operator's spendable $DIG is not known to this node | -If the node cannot determine its requirement, its served-advertisement count, or the operator's balance, -the state is `unknown`. It MUST NOT be a zero and MUST NOT be an alarm. +**None of these has a counterpart in §24.2's census taxonomy**, which is the structural reason the +buffer is its own method rather than a widening of the requirement: collapsing `served_set_unknown` +into `not_censused` reports a missing LOCAL fact as a missing NETWORK one and sends the operator to +fix the wrong thing. -This is a live hazard rather than a theoretical one, because **an unknown and a genuine zero produce -identical arithmetic**: an unreadable hosted-store list read as zero advertisements yields a `0.000 DIG` -recommendation that every balance clears, so a node that could not tell how much it owes would report -"funded". A surface MUST distinguish "nothing to collateralise" from "your funding is sufficient" for the -same reason. +This is a live hazard, not a theoretical one, because **an unknown and a genuine zero produce +identical arithmetic**: a served count read as zero yields a `0.000 DIG` recommendation that every +balance clears, so a node that could not tell how much it owes would report "funded". A surface MUST +also distinguish "nothing to collateralise" from "your funding is sufficient" for the same reason. -A malformed operator-supplied balance is REFUSED, never parsed as zero, which would report `short_now` -over a typo. Amounts are scaled by integer arithmetic: `0.001 DIG` steps are where an `f64` starts -rounding, and a rounded figure about somebody's money is the lie this section exists to prevent. +A malformed operator-supplied balance is REFUSED, never parsed as zero, which would report +`short_now` over a typo. Amounts are scaled by integer arithmetic: `0.001 DIG` steps are where an +`f64` starts rounding. ### 24.7. The `dign` verbs @@ -7516,7 +7539,9 @@ rounding, and a rounded figure about somebody's money is the lie this section ex `dig-mirror-collateral`'s own constant, never to a number spelled out in the CLI; a second spelling is how two surfaces post different amounts for one choice. An unrecognised word is REFUSED, never treated as the default. -* `collateral buffer [--balance ]` — §24.5/§24.6. States an AMOUNT to add, not an adjective, and - shows the working: advertisements served, per-store requirement, margin, and the horizon used. +* `collateral buffer [--roots ] [--balance ]` — §24.5/§24.6. States an AMOUNT to add, not an + adjective, and shows the working: roots served, per-store requirement, margin, the three terms, and + the horizon with its ceiling. `--roots` is an operand until `control.collateral.buffer` publishes, + because no published method reports the served set and the nearest-looking one is a different set. Every verb offers `--json` beside the human output, with stable field names (§6.2). From 06839f50640e1f2fa014750b9e651784d20a0c5a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 06:42:16 -0700 Subject: [PATCH 14/23] fix(collateral): resolve state paths internally, clearing 3 high CodeQL alerts CodeQL traced DIG_NODE_STATE_DIR through ctx.state_dir into three file operations (rust/path-injection, 3x high). Main carries no alerts of this rule, so these were genuinely new rather than an inherited pattern. The root cause was a second resolver, not a missing guard: the handlers passed ctx.state_dir in, while spend_audit's existing code asks state_dir() itself. Production now goes through CollateralConfig::load/save and EpochRecordStore::in_state_dir, and spends_list uses SpendLog::in_state_dir -- one component knowing where one file lives. The explicit-directory forms remain for tests. Four handlers no longer need &ControlCtx at all, so the parameter is dropped rather than underscored. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 22 ++++++++++++++++ crates/dig-node-service/src/control.rs | 31 ++++++++++++----------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 30d72ca9..75cb58b2 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -68,6 +68,25 @@ impl CollateralConfig { /// refusing to start over a corrupt preference file would take the node down over the one /// setting whose absence is survivable. The fallback is the `+1%` default, never `0`, so the /// degraded path still errs toward over-posting. + /// Load from the node's own machine-wide state directory. + /// + /// The production entry point. It resolves the directory ITSELF via [`crate::state::state_dir`] + /// rather than accepting one, so there is exactly one answer to "where does this node keep its + /// state" — the same shape [`crate::spend_audit::SpendLog::in_state_dir`] uses. Handing the + /// directory in from a caller made the path depend on a value that entered the process from the + /// environment, which is both a second resolver for one fact and a taint flow CodeQL flags. + pub fn load() -> Self { + CollateralConfig::load_from(&crate::state::state_dir()) + } + + /// Persist to the node's own machine-wide state directory. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&crate::state::state_dir()) + } + + /// Load from an explicit directory. + /// + /// For tests and for callers that already own a directory. Production uses [`Self::load`]. pub fn load_from(dir: &Path) -> Self { std::fs::read_to_string(dir.join(COLLATERAL_CONFIG_FILE)) .ok() @@ -117,6 +136,9 @@ impl EpochRecordStore { } /// The node's own store, in the machine-wide state directory. + /// + /// The production entry point, for the same reason as [`CollateralConfig::load`]: one resolver + /// for one fact. pub fn in_state_dir() -> Self { EpochRecordStore::at(crate::state::state_dir().join(EPOCH_RECORD_FILE)) } diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 7463d929..1e0bed35 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -911,13 +911,13 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.peerCounts" => peer_counts(ctx, id).await, // The automated-spend audit record (dig-node#385) -- a READ of this node's own spending // history, and the only sanctioned reader of a node-private append-only file. - "control.spends.list" => spends_list(ctx, id, params), + "control.spends.list" => spends_list(id, params), // The deterministic mirror-coin collateral model (dig_ecosystem#3173). The requirement is // consensus-derived and identical on every node; the margin is a LOCAL preference and the // two are deliberately served by different methods so neither can be mistaken for the other. - "control.collateral.requirement" => collateral_requirement(ctx, id), - "control.collateral.margin.get" => collateral_margin_get(ctx, id), - "control.collateral.margin.set" => collateral_margin_set(ctx, id, params), + "control.collateral.requirement" => collateral_requirement(id), + "control.collateral.margin.get" => collateral_margin_get(id), + "control.collateral.margin.set" => collateral_margin_set(id, params), "control.wallet.broadcast" => wallet_broadcast(ctx, id, params).await, // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s // own status file + CLI (see `crate::updater`'s module doc for why nothing here @@ -3134,7 +3134,7 @@ async fn profile_get_body(ctx: &ControlCtx, id: Value, params: &Value) -> Value /// the page bound inside its own `Deserialize`, so a limit of `0` or one above the cap is refused /// here without this handler having to remember to check. A `0` page makes no progress and a caller /// looping until `complete` would loop forever. -fn spends_list(ctx: &ControlCtx, id: Value, params: &Value) -> Value { +fn spends_list(id: Value, params: &Value) -> Value { use dig_node_control_interface::params::SpendsListParams; let params: SpendsListParams = match serde_json::from_value(params.clone()) { @@ -3154,7 +3154,9 @@ fn spends_list(ctx: &ControlCtx, id: Value, params: &Value) -> Value { limit: Some(limit), }; - let log = crate::spend_audit::SpendLog::at(ctx.state_dir.join("spend-audit.jsonl")); + // `in_state_dir` rather than a path built from `ctx.state_dir`: the audit file has ONE home, + // and `SpendLog` is the component that knows where it is. + let log = crate::spend_audit::SpendLog::in_state_dir(); let ledger = match log.query(&query) { Ok(l) => l, // A cursor the record does not know is the caller's mistake, not a broken record. @@ -3254,7 +3256,7 @@ fn spend_row(r: &crate::spend_audit::SpendRecord) -> Value { /// Deriving it is also what makes a STALE answer unrepresentable. The requirement is looked up for /// the epoch that is current NOW, so a node whose census has stopped running reports /// `not_censused` for the present epoch instead of confidently serving last week's figure. -fn current_collateral_epoch(_ctx: &ControlCtx) -> crate::collateral::CurrentEpoch { +fn current_collateral_epoch() -> crate::collateral::CurrentEpoch { crate::collateral::current_epoch_now() } @@ -3263,10 +3265,9 @@ fn current_collateral_epoch(_ctx: &ControlCtx) -> crate::collateral::CurrentEpoc /// The local safety margin is deliberately not consulted: this figure is the consensus-derived one /// every node derives identically, and returning the margined amount would make this operator's /// preference look like the network's price. -fn collateral_requirement(ctx: &ControlCtx, id: Value) -> Value { - let store = - crate::collateral::EpochRecordStore::at(ctx.state_dir.join("collateral-epochs.jsonl")); - let answer = crate::collateral::requirement(&store, current_collateral_epoch(ctx)); +fn collateral_requirement(id: Value) -> Value { + let store = crate::collateral::EpochRecordStore::in_state_dir(); + let answer = crate::collateral::requirement(&store, current_collateral_epoch()); match serde_json::to_value(&answer) { Ok(v) => control_ok(id, v), Err(e) => control_error(id, ErrorCode::ControlError, e.to_string()), @@ -3274,8 +3275,8 @@ fn collateral_requirement(ctx: &ControlCtx, id: Value) -> Value { } /// `control.collateral.margin.get` — the node's local safety margin, in basis points. -fn collateral_margin_get(ctx: &ControlCtx, id: Value) -> Value { - let cfg = crate::collateral::CollateralConfig::load_from(&ctx.state_dir); +fn collateral_margin_get(id: Value) -> Value { + let cfg = crate::collateral::CollateralConfig::load(); control_ok(id, json!({ "margin_bp": cfg.margin_bp })) } @@ -3284,7 +3285,7 @@ fn collateral_margin_get(ctx: &ControlCtx, id: Value) -> Value { /// A value above the contract's ceiling is REFUSED rather than clamped, and the returned figure is /// what was actually stored. Clamping and returning the clamped value would leave the caller's /// stored intent and the node's behaviour disagreeing on the money path. -fn collateral_margin_set(ctx: &ControlCtx, id: Value, params: &Value) -> Value { +fn collateral_margin_set(id: Value, params: &Value) -> Value { use dig_node_control_interface::params::CollateralMarginSetParams; let parsed: CollateralMarginSetParams = match serde_json::from_value(params.clone()) { @@ -3301,7 +3302,7 @@ fn collateral_margin_set(ctx: &ControlCtx, id: Value, params: &Value) -> Value { }; // Persisted before it is reported. A margin that lapsed to the default on reboot would silently // change what the node posts, so a write failure must not be answered with a success. - if let Err(e) = cfg.save_to(&ctx.state_dir) { + if let Err(e) = cfg.save() { return control_error( id, ErrorCode::ControlError, From fec861cc2e7d78a12c66ee2feb537c2791b140c7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 07:01:21 -0700 Subject: [PATCH 15/23] feat(collateral): adopt control-interface 0.24.0 and serve control.collateral.buffer 0.24.0 published while this branch was in flight, declaring the buffer as its OWN method rather than a widening of .requirement. Adopted in both dig-node-service and dig-wallet, and the method is now served. The local BufferAdvice/BufferFigures/FundingState/BufferUnknownReason types are DELETED in favour of the contract's CollateralBufferResult, CollateralFundingState and CollateralBufferUnknownReason. Keeping a parallel set would have been a rival definition of a money-path shape, which is how two surfaces come to disagree about a funding warning. Three things the published shape corrected in this lane's version: * a fourth reason, RequirementUnknown. This lane folded a missing requirement into served_set_unknown -- reporting a NETWORK gap as a LOCAL one, which sends the operator to fix the wrong thing. Now distinct, with its own remedy sentence, and a test asserts no buffer reason collides with a census reason. * epoch and protocol_version travel WITH the buffer, so a client never has to pair it with a separately-fetched requirement and hope both describe the same epoch. * Funded, not Adequate; and the lock and the shortfall are DERIVED rather than carried, because the contract publishes the inputs to both and a fourth field could disagree with the three it comes from. The node's own answer is honestly unknown today: it passes None for both the served set and the balance rather than approximating them from the hosted-store list or an arbitrary address. A set that merely resembles the served roots, or a balance for the wrong address, is a plausible wrong number on a money surface -- worse than no number. dign still produces a real figure from operands. Conformance: 5/5, node and contract now agree on all five methods. Co-Authored-By: Claude --- .tsplice.py | 28 + .wire2.py | 44 ++ Cargo.lock | 4 +- crates/dig-node-service/Cargo.toml | 2 +- crates/dig-node-service/src/collateral.rs | 574 ++++++++------------- crates/dig-node-service/src/control.rs | 41 ++ crates/dig-node-service/src/control_cli.rs | 93 ++-- crates/dig-wallet/Cargo.toml | 2 +- 8 files changed, 403 insertions(+), 385 deletions(-) create mode 100644 .tsplice.py create mode 100644 .wire2.py diff --git a/.tsplice.py b/.tsplice.py new file mode 100644 index 00000000..3e078834 --- /dev/null +++ b/.tsplice.py @@ -0,0 +1,28 @@ +p = 'crates/dig-node-service/src/collateral.rs' +lines = open(p, encoding='utf-8', newline='').read().split('\n') + +start = None +end = None +for i, l in enumerate(lines): + if start is None and '/// Unwrap a `Known` answer, or fail loudly naming what came back instead.' in l: + start = i + if start is not None and 'fn format_dig_keeps_three_decimals' in l: + end = i - 1 + break +assert start is not None and end is not None and end > start, (start, end) +assert lines[end].strip() == '#[test]', lines[end] + +new = open('.t.rs', encoding='utf-8', newline='').read().rstrip('\n').split('\n') +lines[start:end] = new + [''] +s = '\n'.join(lines) + +# The tests need the contract's params const in scope. +old = ' use dig_mirror_collateral::{base_per_store, handicap_for_owners, required_per_store, EpochCensus};' +new_imp = (' use dig_mirror_collateral::{\n' + ' base_per_store, handicap_for_owners, required_per_store, EpochCensus,\n' + ' };\n' + ' use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS;') +assert s.count(old) == 1, 'test import' +s = s.replace(old, new_imp, 1) +open(p, 'w', encoding='utf-8', newline='').write(s) +print('ok') diff --git a/.wire2.py b/.wire2.py new file mode 100644 index 00000000..8c7e2a6e --- /dev/null +++ b/.wire2.py @@ -0,0 +1,44 @@ +p = 'crates/dig-node-service/src/control.rs' +s = open(p, encoding='utf-8', newline='').read() + + +def rep(old, new, tag, n=1): + global s + assert s.count(old) == n, f'{tag}: {s.count(old)}' + s = s.replace(old, new, n) + + +rep('"control.collateral.margin.set",\n', '"control.collateral.margin.set",\n "control.collateral.buffer",\n', + 'lists', 2) +rep(' "control.collateral.margin.set" => collateral_margin_set(id, params),', + ' "control.collateral.margin.set" => collateral_margin_set(id, params),\n' + ' "control.collateral.buffer" => collateral_buffer(id),', + 'dispatch') + +frag = open('.bufh.rs', encoding='utf-8', newline='').read() +anchor = "/// `control.collateral.margin.get` — the node's local safety margin, in basis points." +assert s.count(anchor) == 1, 'anchor' +s = s.replace(anchor, frag.strip('\n') + '\n\n' + anchor, 1) +open(p, 'w', encoding='utf-8', newline='').write(s) + +p = 'crates/dig-node-service/src/control_cli.rs' +s = open(p, encoding='utf-8', newline='').read() +rep(' ControlAction::CollateralMarginGet.method(),', + ' ControlAction::CollateralMarginGet.method(),', 'noop-check') +rep(''' CollateralMarginGet,''', + ''' CollateralMarginGet, + /// `control.collateral.buffer` — the node's OWN answer: what it recommends holding and the + /// funding state it is in, computed from the served set and balance the node itself knows. + /// + /// Distinct from the operator-supplied form of `dign collateral buffer`, which computes the + /// same figures from operands a person types. The node's answer is authoritative; the operands + /// exist so a person can get a number before the node can enumerate its own served set. + CollateralBuffer,''', 'variant') +rep(''' ControlAction::CollateralMarginGet => "control.collateral.margin.get",''', + ''' ControlAction::CollateralMarginGet => "control.collateral.margin.get", + ControlAction::CollateralBuffer => "control.collateral.buffer",''', 'method') +rep(''' ControlAction::CollateralMarginGet.method(),''', + ''' ControlAction::CollateralMarginGet.method(), + ControlAction::CollateralBuffer.method(),''', 'covered') +open(p, 'w', encoding='utf-8', newline='').write(s) +print('ok') diff --git a/Cargo.lock b/Cargo.lock index a9b08634..ae2e9bed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2993,9 +2993,9 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf6c62dd8afb9eab7b7f2cd3d2a74e7c5e298671f9c34ec15b43dfbff4fdb6f" +checksum = "1bc89557fb776432bd92b99831adfca70d9a39b7da53a26c04f41abef787b0d7" dependencies = [ "async-trait", "semver", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index efbe516a..35a8c982 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -89,7 +89,7 @@ dig-node-core = { path = "../dig-node-core" } # "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of -- # it checked nothing about the very method the change added. A caret range keeps the pin moving # with the published catalog instead of silently narrowing what CI can see. -dig-node-control-interface = "0.23" +dig-node-control-interface = "0.24" # The deterministic mirror-coin collateral model: the per-epoch requirement, the controller # multiplier, the small-network handicap, the floor clamp, and the safety-margin arithmetic. diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 75cb58b2..a259b941 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -26,7 +26,10 @@ use std::path::{Path, PathBuf}; use dig_mirror_collateral::{ apply_safety_margin, EpochRecord, MULT_SCALE, SAFETY_MARGIN_BP_DEFAULT, }; -use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; +use dig_node_control_interface::results::{ + CollateralBufferResult, CollateralBufferUnknownReason, CollateralFundingState, + CollateralRequirementResult, CollateralUnknownReason, +}; use serde::{Deserialize, Serialize}; /// The file holding this node's local collateral preferences. @@ -297,176 +300,26 @@ pub fn requirement(store: &EpochRecordStore, current: CurrentEpoch) -> Collatera } } -/// How many epochs of multiplier escalation the recommended buffer covers. -/// -/// **4 epochs — 28 days, about a x1.60 headroom.** This is the contract lane's judgement -/// (`dig-node-control-interface` PR#36, `DEFAULT_BUFFER_HORIZON_EPOCHS`), NOT a constant published -/// by `dig-mirror-collateral`, and it is restated here only because the contract crate is not yet -/// depended on for it. It moves if the contract moves. -/// -/// A horizon is a CHOICE and not a constant because escalation COMPOUNDS: at the per-epoch ceiling -/// the factors run 1 epoch x1.12, 2 x1.27, 4 x1.60, 8 x2.57, 13 x4.62. Telling an operator to hold -/// 4.6x their current lock against a quarter of uninterrupted worst-case escalation is advice -/// nobody follows. It is reported with the figure ([`BufferAdvice::horizon_epochs`]) because a -/// buffer whose assumption is invisible cannot be argued with. -pub const DEFAULT_BUFFER_HORIZON_EPOCHS: u32 = 4; - -/// Why a node cannot state the buffer. +/// Why a node cannot state the buffer, and what would resolve it. /// -/// **None of these has a counterpart in [`CollateralUnknownReason`]**, which is the structural -/// reason the buffer is a separate method rather than a widening of the requirement: collapsing -/// "I do not know what I serve" into "I have not censused the epoch" would report a missing LOCAL -/// fact as a missing NETWORK one, and send the operator to fix the wrong thing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum BufferUnknownReason { - /// The node cannot enumerate the `(owner, store, root)` triples it serves. - /// - /// The buffer's first term is a count of those triples, so without it there is no figure at - /// all — not a zero. Zero served pairs and an unknown served set produce identical arithmetic, - /// and the zero reads as "you owe nothing". - ServedSetUnknown, - /// The node cannot tell which of the previous epoch's coins have been reclaimed. - /// - /// The overlap term is precisely the collateral still locked in the epoch being reclaimed, so - /// it is unknowable without this, and it is the term that decides the PEAK. - ReclaimStateUnknown, - /// The operator's spendable $DIG is not known to this node. - /// - /// The costs are still computable and are still reported; what is not computable is where the - /// operator STANDS against them, which is the part that could raise an alarm. - BalanceUnknown, -} - -impl BufferUnknownReason { - /// The stable snake_case wire token. - pub const fn as_wire(self) -> &'static str { - match self { - BufferUnknownReason::ServedSetUnknown => "served_set_unknown", - BufferUnknownReason::ReclaimStateUnknown => "reclaim_state_unknown", - BufferUnknownReason::BalanceUnknown => "balance_unknown", +/// The variants are the CONTRACT's ([`CollateralBufferUnknownReason`]); only the operator-facing +/// remedy sentence lives here. **None has a counterpart in [`CollateralUnknownReason`]**, which is +/// the structural reason the buffer is a separate method rather than a widening of the requirement: +/// collapsing "I do not know what I serve" into "I have not censused the epoch" would report a +/// missing LOCAL fact as a missing NETWORK one, and send the operator to fix the wrong thing. +pub fn buffer_remedy(reason: CollateralBufferUnknownReason) -> &'static str { + match reason { + CollateralBufferUnknownReason::RequirementUnknown => { + "this node cannot state this epoch's per-store requirement" } - } - - /// One line telling the operator what would resolve it. - pub const fn remedy(self) -> &'static str { - match self { - BufferUnknownReason::ServedSetUnknown => { - "this node cannot list the store roots it serves" - } - BufferUnknownReason::ReclaimStateUnknown => { - "this node cannot tell which of last epoch's collateral has been reclaimed" - } - BufferUnknownReason::BalanceUnknown => "this node does not know your spendable $DIG", + CollateralBufferUnknownReason::ServedSetUnknown => { + "this node cannot list the store roots it serves" } - } -} - -/// How much $DIG an operator should hold, and whether they are currently short. -/// -/// Modelled as a TAGGED enum rather than a struct with optional numbers, so that the unknown case -/// has **no representable numeric field**. A zero cannot be emitted here even by accident, which -/// matters because a zero buffer reads as "no buffer needed" — the reassuring rendering of a -/// missing fact, on a money surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(tag = "state", rename_all = "snake_case")] -pub enum BufferAdvice { - /// The node can state the buffer. - Known(BufferFigures), - /// The node cannot, and names which fact is missing. - Unknown { - /// Which fact the node is missing. - reason: BufferUnknownReason, - }, -} - -/// The figures behind a [`BufferAdvice::Known`] answer. -/// -/// Every amount is in **DIG base units** (`1 DIG = 1_000`), never mojos. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct BufferFigures { - /// The `(owner, store, root)` triples THIS NODE serves and must collateralise. - /// - /// Deliberately not named `stores`, and deliberately not sourced from - /// [`CollateralRequirementResult::Known::stores`], which is a NETWORK census figure the - /// contract says in as many words is not a node count. Multiplying that by the requirement - /// bills one operator for the whole network. - pub pairs_served_by_this_node: u64, - /// The pre-margin per-store requirement this advice was computed from. - pub required_per_store_dig_base_units: u64, - /// The margin applied, in BASIS POINTS. Never converted to a percentage. - pub margin_bp: u64, - /// What one epoch's posting costs, margin included: `pairs x margined requirement`. - pub one_epoch_lock_dig_base_units: u64, - /// The collateral still locked in the epoch being reclaimed. - /// - /// The real peak, and the term nobody budgets for. Reclaims run FIRST in every pass and are - /// never gated on funds, so in the ordinary case the returned collateral funds the creates - /// behind it — but epoch `n` exists before `n-1` is reclaimed, and a reclaim can be delayed or - /// fail. - pub overlap_dig_base_units: u64, - /// What the next epochs could cost ON TOP of today's lock if the multiplier rises at its - /// ceiling for [`Self::horizon_epochs`]. - pub escalation_headroom_dig_base_units: u64, - /// The authoritative total: lock + overlap + headroom. - pub recommended_buffer_dig_base_units: u64, - /// The escalation horizon assumed, in epochs. Required on the wire — a buffer without its - /// horizon is a magic number nobody can check. - pub horizon_epochs: u32, - /// The multiplier ceiling after `horizon_epochs`, in millionths, relative to today's. - /// - /// Required on the wire beside the horizon so a client can reproduce the headroom rather than - /// trust it. A **worst case, not a forecast**: inside the controller's dead band the multiplier - /// does not move at all. - pub escalation_ceiling_micros: u64, - /// The operator's spendable $DIG, in base units. - pub spendable_dig_base_units: u64, - /// Where the operator stands. - pub funding_state: FundingState, - /// How much more $DIG to hold to reach [`Self::recommended_buffer_dig_base_units`]. - /// - /// The number a person acts on. "Balance low" is not actionable; "add 3.250 DIG" is. - pub shortfall_to_recommended_dig_base_units: u64, -} - -/// Where an operator's $DIG stands against their collateral obligations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum FundingState { - /// Cannot cover the CURRENT epoch: stores are already going uncollateralised. - ShortNow, - /// Covers the current epoch but could not cover the next one at the escalation ceiling. - DangerouslyLow, - /// Fine for several epochs, with no cushion for a delayed reclaim or sustained escalation. - /// - /// **A READOUT, never a notification** — see [`FundingState::is_shortfall`]. - BelowRecommendedBuffer, - /// At or above the recommended buffer. - Adequate, -} - -impl FundingState { - /// Does this state leave an epoch UNCOVERED? - /// - /// True for exactly the two states that do. Named as a predicate on the type rather than left - /// to each surface, because two surfaces deciding independently is how the readout state - /// acquires an alert. - /// - /// [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) is deliberately EXCLUDED. Every - /// epoch it covers is covered; it lacks only a cushion, and a healthy node sits there much of - /// the time. A recurring alert an operator learns to dismiss teaches them to dismiss the two - /// above it, which are the ones that cost money. - pub fn is_shortfall(self) -> bool { - matches!(self, FundingState::ShortNow | FundingState::DangerouslyLow) - } - - /// The stable snake_case wire token. - pub const fn token(self) -> &'static str { - match self { - FundingState::ShortNow => "short_now", - FundingState::DangerouslyLow => "dangerously_low", - FundingState::BelowRecommendedBuffer => "below_recommended_buffer", - FundingState::Adequate => "adequate", + CollateralBufferUnknownReason::ReclaimStateUnknown => { + "this node cannot tell which of last epoch's collateral has been reclaimed" + } + CollateralBufferUnknownReason::BalanceUnknown => { + "this node does not know your spendable $DIG" } } } @@ -474,11 +327,12 @@ impl FundingState { /// The multiplier after `epochs` of uninterrupted escalation, starting from `from_micros`. /// /// Obtained by stepping `dig_mirror_collateral::step_multiplier` in its own HIGH band, never by -/// evaluating a hand-rolled `(9/8)^n`. Re-deriving the recurrence would be a rival implementation +/// evaluating a hand-rolled closed form. Re-deriving the recurrence would be a rival implementation /// of the controller, and it would silently drop two behaviours the real one has: the step is a -/// fraction of the PREVIOUS multiplier (so it compounds, and an implementation using a fixed -/// increment diverges immediately), and the result is clamped at `MULT_CEILING_MICROS` (so a long -/// horizon cannot manufacture a headroom the controller could never produce). +/// fraction of the PREVIOUS multiplier and TRUNCATES each epoch (0.8x over four epochs reaches +/// 1.281444, where the closed form gives 1.281445), and the result is clamped at +/// `MULT_CEILING_MICROS`, so a long horizon cannot manufacture headroom the controller could never +/// produce. /// /// A **worst case, not a forecast.** The high band is the escalating one; inside the dead band the /// multiplier does not move at all, and most epochs do not escalate. @@ -503,108 +357,112 @@ fn scale_micros(amount: u64, micros: u64) -> u64 { .unwrap_or(u64::MAX) } -/// Compute the funding advice. +/// What one epoch's posting costs: the served pairs at the margined per-store requirement. +/// +/// Deliberately NOT carried on the wire — a client derives it from `pairs_served_by_this_node`, +/// `required_per_store_dig_base_units` and `margin_bp`, all of which the contract does carry. It is +/// a named function rather than an inline product because three callers need it and they must not +/// drift. +pub fn one_epoch_lock(pairs: u64, required_per_store: u64, margin_bp: u64) -> u64 { + // `apply_safety_margin` rounds UP; a re-derivation that rounded down would post a base unit + // short of what qualifies. + let per_store = apply_safety_margin(required_per_store, margin_bp); + u64::try_from(u128::from(pairs) * u128::from(per_store)).unwrap_or(u64::MAX) +} + +/// Compute the funding advice, in the contract's own shape. /// -/// # Why the recommendation is not "requirement x epochs of runway" +/// # Why the recommendation is not "requirement times epochs of runway" /// /// **Collateral is RECLAIMED, not spent.** Each pass creates the coins for `(store, root, epoch n)` /// and reclaims epoch `n-1`, and the reclaims run FIRST and are never gated on funds — so the /// returned collateral funds the creates behind it. Steady state is therefore roughly ONE epoch's /// lock, not one per epoch. Multiplying by a runway would overstate the answer by the epoch count -/// and tell an operator to buy many times what they need, which is advice they would rightly -/// ignore. -/// -/// So the total is three named terms, which sum without double-counting: -/// -/// ```text -/// recommended = lock + overlap + escalation_headroom -/// ``` +/// and tell an operator to buy many times what they need. /// -/// the current epoch's posting, the collateral still held in the epoch being reclaimed, and what -/// the next `horizon_epochs` could add if the multiplier rises at its ceiling throughout. +/// So the total is three terms, which sum without double-counting: the current epoch's posting, the +/// collateral still held in the epoch being reclaimed (the real peak, and the term nobody budgets +/// for), and what the next `horizon_epochs` could add if the multiplier rises at its ceiling. /// -/// `pairs_served_by_this_node` MUST be this node's own served set. Passing the census `stores` -/// figure produces the whole network's collateral bill presented to one operator. +/// `pairs_served_by_this_node` MUST be this node's OWN served set. It is never the requirement's +/// `stores`, which is a network census figure the contract says in as many words is not a node +/// count — multiplying that by the requirement bills one operator for the whole network. pub fn buffer_advice( pairs_served_by_this_node: Option, requirement: &CollateralRequirementResult, margin_bp: u64, spendable_dig_base_units: Option, horizon_epochs: u32, -) -> BufferAdvice { - let unknown = |reason| BufferAdvice::Unknown { reason }; +) -> CollateralBufferResult { + let unknown = |reason| CollateralBufferResult::Unknown { reason }; - // A requirement the node does not have is a SERVED-SET-independent gap, but it surfaces here as - // the same practical fact: there is no per-store price to multiply by. It is reported through - // `control.collateral.requirement`'s own reason, which is why the caller is expected to have - // read that first; here it can only be reported as "no figure". + // Each missing fact gets its OWN reason. Folding the requirement into the served set — which an + // earlier version of this function did — reports a NETWORK gap as a LOCAL one. let CollateralRequirementResult::Known { + epoch, + protocol_version, required_per_store_dig_base_units, multiplier_micros, .. } = *requirement else { - return unknown(BufferUnknownReason::ServedSetUnknown); + return unknown(CollateralBufferUnknownReason::RequirementUnknown); }; let Some(pairs) = pairs_served_by_this_node else { - return unknown(BufferUnknownReason::ServedSetUnknown); + return unknown(CollateralBufferUnknownReason::ServedSetUnknown); }; let Some(spendable) = spendable_dig_base_units else { - return unknown(BufferUnknownReason::BalanceUnknown); + return unknown(CollateralBufferUnknownReason::BalanceUnknown); }; - // The margined per-store posting, from the crate — it rounds UP, and a re-derivation that - // rounded down would post a base unit short of what qualifies. - let per_store = apply_safety_margin(required_per_store_dig_base_units, margin_bp); - let lock = u64::try_from(u128::from(pairs) * u128::from(per_store)).unwrap_or(u64::MAX); + let lock = one_epoch_lock(pairs, required_per_store_dig_base_units, margin_bp); // The overlap is a second epoch's worth at TODAY's price: the coins of epoch n-1, still locked // while epoch n's are created. let overlap = lock; - // The ceiling is expressed relative to today's multiplier so a client can check the headroom - // against the multiplier the requirement already reported. - let escalated = escalated_multiplier_micros(multiplier_micros, horizon_epochs); - let ceiling_micros = u64::try_from( - u128::from(escalated) * u128::from(MULT_SCALE) / u128::from(multiplier_micros.max(1)), - ) - .unwrap_or(u64::MAX); - let headroom = scale_micros(lock, ceiling_micros).saturating_sub(lock); - - let recommended = lock.saturating_add(overlap).saturating_add(headroom); - let next_epoch_ceiling = scale_micros( - lock, + // The ceiling is expressed RELATIVE to today's multiplier, so a client can check the headroom + // against the multiplier `control.collateral.requirement` already reported. + let relative_ceiling = |epochs| { u64::try_from( - u128::from(escalated_multiplier_micros(multiplier_micros, 1)) * u128::from(MULT_SCALE) + u128::from(escalated_multiplier_micros(multiplier_micros, epochs)) + * u128::from(MULT_SCALE) / u128::from(multiplier_micros.max(1)), ) - .unwrap_or(MULT_SCALE), - ); + .unwrap_or(u64::MAX) + }; + let escalation_ceiling_micros = relative_ceiling(horizon_epochs); + let headroom = scale_micros(lock, escalation_ceiling_micros).saturating_sub(lock); + let recommended = lock.saturating_add(overlap).saturating_add(headroom); + + // "Could not cover the NEXT epoch" is ONE epoch of escalation, not the whole horizon: the + // horizon sizes the cushion, while this threshold is about the epoch immediately ahead. + let next_epoch_ceiling = scale_micros(lock, relative_ceiling(1)); let funding_state = if spendable < lock { - FundingState::ShortNow + CollateralFundingState::ShortNow } else if spendable < next_epoch_ceiling { - FundingState::DangerouslyLow + CollateralFundingState::DangerouslyLow } else if spendable < recommended { - FundingState::BelowRecommendedBuffer + CollateralFundingState::BelowRecommendedBuffer } else { - FundingState::Adequate + CollateralFundingState::Funded }; - BufferAdvice::Known(BufferFigures { + CollateralBufferResult::Known { + epoch, + protocol_version, + funding_state, + recommended_buffer_dig_base_units: recommended, + spendable_dig_base_units: spendable, pairs_served_by_this_node: pairs, required_per_store_dig_base_units, margin_bp, - one_epoch_lock_dig_base_units: lock, overlap_dig_base_units: overlap, escalation_headroom_dig_base_units: headroom, - recommended_buffer_dig_base_units: recommended, horizon_epochs, - escalation_ceiling_micros: ceiling_micros, - spendable_dig_base_units: spendable, - funding_state, - shortfall_to_recommended_dig_base_units: recommended.saturating_sub(spendable), - }) + escalation_ceiling_micros, + } } /// Render a DIG base-unit amount as decimal DIG, e.g. `1_047` -> `"1.047"`. @@ -622,6 +480,7 @@ mod tests { use dig_mirror_collateral::{ base_per_store, handicap_for_owners, required_per_store, EpochCensus, }; + use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS; /// A record with independently-chosen fields. /// @@ -868,70 +727,80 @@ mod tests { } } - /// Unwrap a `Known` answer, or fail loudly naming what came back instead. - fn figures(a: BufferAdvice) -> BufferFigures { + /// The `Known` fields of a buffer answer, or a loud failure naming what came back instead. + #[allow(clippy::type_complexity)] + fn known_buffer( + a: CollateralBufferResult, + ) -> (CollateralFundingState, u64, u64, u64, u64, u32, u64, u64) { match a { - BufferAdvice::Known(f) => f, + CollateralBufferResult::Known { + funding_state, + recommended_buffer_dig_base_units, + spendable_dig_base_units, + pairs_served_by_this_node, + overlap_dig_base_units, + horizon_epochs, + escalation_headroom_dig_base_units, + escalation_ceiling_micros, + .. + } => ( + funding_state, + recommended_buffer_dig_base_units, + spendable_dig_base_units, + pairs_served_by_this_node, + overlap_dig_base_units, + horizon_epochs, + escalation_headroom_dig_base_units, + escalation_ceiling_micros, + ), other => panic!("expected a known buffer, got {other:?}"), } } - /// The buffer for `pairs` served triples at `required` per store, default horizon. - fn advice(pairs: u64, required: u64, margin_bp: u64, spendable: u64) -> BufferFigures { - figures(buffer_advice( + /// The buffer for `pairs` served roots at `required` per store, at the default horizon. + fn buf(pairs: u64, required: u64, margin_bp: u64, spendable: u64) -> CollateralBufferResult { + buffer_advice( Some(pairs), &known(required), margin_bp, Some(spendable), DEFAULT_BUFFER_HORIZON_EPOCHS, - )) + ) } #[test] fn the_total_is_lock_plus_overlap_plus_headroom_and_the_terms_do_not_double_count() { - // 10 served triples x 1.000 DIG, no margin, so the lock is exactly 10.000 DIG. - let a = advice(10, 1_000, 0, 0); - assert_eq!(a.one_epoch_lock_dig_base_units, 10_000); + // 10 served roots x 1.000 DIG, no margin, so the lock is exactly 10.000 DIG. + let (_, recommended, _, pairs, overlap, horizon, headroom, _) = + known_buffer(buf(10, 1_000, 0, 0)); + let lock = one_epoch_lock(pairs, 1_000, 0); + assert_eq!(lock, 10_000); // The overlap is a second epoch's worth at TODAY's price -- the coins of n-1 still locked. - assert_eq!(a.overlap_dig_base_units, 10_000); + assert_eq!(overlap, 10_000); // The three terms sum to the authoritative total, exactly. A decomposition that // double-counted would still produce a plausible-looking total; this is what catches it. - assert_eq!( - a.recommended_buffer_dig_base_units, - a.one_epoch_lock_dig_base_units - + a.overlap_dig_base_units - + a.escalation_headroom_dig_base_units - ); - // And it is NOT a runway: 4 epochs of runway would be at least 4x the lock. + assert_eq!(recommended, lock + overlap + headroom); + // And it is NOT a runway: four epochs of runway would be at least 4x the lock. assert!( - a.recommended_buffer_dig_base_units < a.one_epoch_lock_dig_base_units * 3, - "a runway-shaped recommendation would be at least 4x the lock, got {}", - a.recommended_buffer_dig_base_units + recommended < lock * 3, + "a runway-shaped recommendation would be at least 4x the lock, got {recommended}" ); - assert_eq!(a.horizon_epochs, DEFAULT_BUFFER_HORIZON_EPOCHS); + assert_eq!(horizon, DEFAULT_BUFFER_HORIZON_EPOCHS); } #[test] fn the_escalation_ceiling_comes_from_the_controller_and_is_reported_with_the_horizon() { - let a = advice(10, 1_000, 0, 0); + let (_, _, _, pairs, _, _, headroom, ceiling) = known_buffer(buf(10, 1_000, 0, 0)); // The controller steps by prev/8 per epoch in its high band, TRUNCATING each step: four // epochs from 0.8x go 0.9 -> 1.0125 -> 1.139062 -> 1.281444, a ceiling of x1.601805 - // relative to today. Note this is NOT exactly 0.8 x (9/8)^4 = 1.281445 -- the per-step - // truncation is the difference, and it is precisely what a hand-rolled closed form would - // get wrong. Pinned as concrete numbers as well as against the crate, so a mutation moving - // both sides is still caught. - let stepped = escalated_multiplier_micros(800_000, 4); - assert_eq!(stepped, 1_281_444); - assert_eq!(a.escalation_ceiling_micros, 1_601_805); + // relative to today. Note this is NOT 0.8 x (9/8)^4 = 1.281445 -- the per-step truncation + // is the difference, and it is exactly what a hand-rolled closed form would get wrong. + assert_eq!(escalated_multiplier_micros(800_000, 4), 1_281_444); + assert_eq!(ceiling, 1_601_805); // The headroom is what that ceiling adds ON TOP of the lock, not the scaled lock itself. - assert_eq!( - a.escalation_headroom_dig_base_units, - scale_micros(a.one_epoch_lock_dig_base_units, a.escalation_ceiling_micros) - - a.one_epoch_lock_dig_base_units - ); - // Both fields travel together: a horizon without its ceiling, or a ceiling without its - // horizon, is a number a client cannot reproduce. - assert!(a.escalation_ceiling_micros > MULT_SCALE); + let lock = one_epoch_lock(pairs, 1_000, 0); + assert_eq!(headroom, scale_micros(lock, ceiling) - lock); + assert!(ceiling > MULT_SCALE); } #[test] @@ -945,71 +814,63 @@ mod tests { assert_eq!(escalated_multiplier_micros(1_000_000, 4), 1_601_806); // And it stops at the controller's own ceiling instead of running away. A hand-rolled - // (9/8)^n has no such clamp, so a long horizon would manufacture headroom the controller - // could never produce -- which is the whole reason this delegates rather than re-derives. - let far = escalated_multiplier_micros(1_000_000, 500); - assert_eq!(far, dig_mirror_collateral::MULT_CEILING_MICROS); + // closed form has no such clamp, so a long horizon would manufacture headroom the + // controller could never produce -- the whole reason this delegates rather than re-derives. + assert_eq!( + escalated_multiplier_micros(1_000_000, 500), + dig_mirror_collateral::MULT_CEILING_MICROS + ); } #[test] fn the_margin_raises_the_lock_and_is_reported_in_basis_points() { - // Same pairs and requirement; the margin is the ONLY thing varied, so the effect is - // attributable to it. - let none = advice(10, 1_000, 0, 0); - let generous = advice(10, 1_000, 500, 0); - assert_eq!(generous.margin_bp, 500); + // The margin is the ONLY thing varied, so the effect is attributable to it. + assert_eq!(one_epoch_lock(10, 1_000, 0), 10_000); // +5% on 1.000 DIG is 1.050, ten of them 10.500. - assert_eq!(generous.one_epoch_lock_dig_base_units, 10_500); - assert!(generous.one_epoch_lock_dig_base_units > none.one_epoch_lock_dig_base_units); + assert_eq!(one_epoch_lock(10, 1_000, 500), 10_500); // A 1 bp margin is a legal choice and must survive: the crate rounds UP, so it adds a base - // unit rather than vanishing. A percentage conversion anywhere would erase it. - assert_eq!(advice(1, 1_000, 1, 0).one_epoch_lock_dig_base_units, 1_001); + // unit rather than vanishing. A conversion to whole percent anywhere would erase it. + assert_eq!(one_epoch_lock(1, 1_000, 1), 1_001); + // And it reaches the answer: the margined lock is what the recommendation is built on. + let (_, plain, ..) = known_buffer(buf(10, 1_000, 0, 0)); + let (_, margined, ..) = known_buffer(buf(10, 1_000, 500, 0)); + assert!(margined > plain); } #[test] fn the_states_sit_at_the_boundaries_they_name() { - // lock 10.000 · next-epoch ceiling 11.250 · recommended 26.016. - let at = |spendable| advice(10, 1_000, 0, spendable).funding_state; + // lock 10.000 · next-epoch ceiling 11.250 · recommended from the implementation. + let at = |spendable| known_buffer(buf(10, 1_000, 0, spendable)).0; + let (_, recommended, ..) = known_buffer(buf(10, 1_000, 0, 0)); // Each bound pinned from BOTH sides: one under must move the state, at-bound must not. - assert_eq!(at(9_999), FundingState::ShortNow); - assert_eq!(at(10_000), FundingState::DangerouslyLow); - assert_eq!(at(11_249), FundingState::DangerouslyLow); - assert_eq!(at(11_250), FundingState::BelowRecommendedBuffer); - // Read from the implementation rather than restated, because the exact total depends on - // the controller's truncation; what this test pins is the BOUNDARY behaviour either side. - let recommended = advice(10, 1_000, 0, 0).recommended_buffer_dig_base_units; - assert_eq!(at(recommended - 1), FundingState::BelowRecommendedBuffer); - assert_eq!(at(recommended), FundingState::Adequate); + assert_eq!(at(9_999), CollateralFundingState::ShortNow); + assert_eq!(at(10_000), CollateralFundingState::DangerouslyLow); + assert_eq!(at(11_249), CollateralFundingState::DangerouslyLow); + assert_eq!(at(11_250), CollateralFundingState::BelowRecommendedBuffer); + assert_eq!( + at(recommended - 1), + CollateralFundingState::BelowRecommendedBuffer + ); + assert_eq!(at(recommended), CollateralFundingState::Funded); } #[test] fn only_the_two_states_that_leave_an_epoch_uncovered_are_shortfalls() { - assert!(FundingState::ShortNow.is_shortfall()); - assert!(FundingState::DangerouslyLow.is_shortfall()); + assert!(CollateralFundingState::ShortNow.is_shortfall()); + assert!(CollateralFundingState::DangerouslyLow.is_shortfall()); // The one that must stay quiet. Every epoch it covers IS covered; it lacks only a cushion, // and a normal node sits here much of the time. An alert an operator learns to dismiss // teaches them to dismiss the two above it. - assert!(!FundingState::BelowRecommendedBuffer.is_shortfall()); - assert!(!FundingState::Adequate.is_shortfall()); + assert!(!CollateralFundingState::BelowRecommendedBuffer.is_shortfall()); + assert!(!CollateralFundingState::Funded.is_shortfall()); } #[test] - fn an_unknown_answer_has_no_representable_figure_at_all() { + fn each_missing_fact_gets_its_own_buffer_reason_and_no_figure_at_all() { let cases = [ - // No served set: a spendable balance of zero would otherwise look exactly like - // ShortNow to any implementation that read an unknown pair count as zero. - ( - buffer_advice(None, &known(1_000), 100, Some(0), 4), - BufferUnknownReason::ServedSetUnknown, - ), - // No balance: the costs ARE computable, but where the operator stands is not, and - // that is the half that could raise an alarm. - ( - buffer_advice(Some(10), &known(1_000), 100, None, 4), - BufferUnknownReason::BalanceUnknown, - ), - // No requirement: there is no per-store price to multiply by. + // No requirement: a NETWORK gap. Reporting it as served_set_unknown -- which an earlier + // version of this function did -- sends the operator to fix the wrong thing. ( buffer_advice( Some(10), @@ -1020,11 +881,23 @@ mod tests { Some(0), 4, ), - BufferUnknownReason::ServedSetUnknown, + CollateralBufferUnknownReason::RequirementUnknown, + ), + // No served set: a spendable balance of zero would otherwise look exactly like + // ShortNow to any implementation that read an unknown count as zero. + ( + buffer_advice(None, &known(1_000), 100, Some(0), 4), + CollateralBufferUnknownReason::ServedSetUnknown, + ), + // No balance: the costs ARE computable, but where the operator stands is not -- and + // that is the half that could raise an alarm. + ( + buffer_advice(Some(10), &known(1_000), 100, None, 4), + CollateralBufferUnknownReason::BalanceUnknown, ), ]; for (answer, expected) in cases { - let BufferAdvice::Unknown { reason } = answer else { + let CollateralBufferResult::Unknown { reason } = answer else { panic!("expected unknown, got {answer:?}"); }; assert_eq!(reason, expected); @@ -1033,69 +906,52 @@ mod tests { // 0 here, and a 0 reads as "no buffer needed". let wire = serde_json::to_value(answer).expect("serialise"); assert_eq!(wire["state"], "unknown"); - assert_eq!(wire["reason"], reason.as_wire()); for absent in [ "recommended_buffer_dig_base_units", - "one_epoch_lock_dig_base_units", - "shortfall_to_recommended_dig_base_units", "spendable_dig_base_units", + "pairs_served_by_this_node", + "overlap_dig_base_units", ] { assert!( wire.get(absent).is_none(), "{absent} is representable: {wire}" ); } + assert!(!buffer_remedy(reason).is_empty()); } } #[test] - fn each_buffer_reason_is_distinct_from_every_census_reason() { - // The structural argument for a separate method rather than a widened requirement: none of - // the buffer's missing facts can be expressed in the census taxonomy, so collapsing them - // would report a missing LOCAL fact as a missing NETWORK one and send the operator to fix - // the wrong thing. + fn no_buffer_reason_collides_with_a_census_reason_and_each_has_its_own_remedy() { + // The structural argument for a separate method rather than a widened requirement: the + // buffer's missing facts are LOCAL and the census's are NETWORK, so one taxonomy cannot + // carry both without sending an operator to fix the wrong thing. let census: std::collections::BTreeSet<&str> = CollateralUnknownReason::ALL .iter() .map(|r| r.as_wire()) .collect(); - let buffer = [ - BufferUnknownReason::ServedSetUnknown, - BufferUnknownReason::ReclaimStateUnknown, - BufferUnknownReason::BalanceUnknown, - ]; - for reason in buffer { - assert!( - !census.contains(reason.as_wire()), - "{} collides with a census reason", - reason.as_wire() - ); - assert!(!reason.remedy().is_empty()); - } - // And the three remedies are distinct, not one sentence reused. - let remedies: std::collections::BTreeSet<&str> = - buffer.iter().map(|r| r.remedy()).collect(); - assert_eq!(remedies.len(), 3); - } - - #[test] - fn the_shortfall_is_the_number_to_add_and_never_underflows() { - let a = advice(10, 1_000, 0, 20_000); - let expected = a.recommended_buffer_dig_base_units - 20_000; - assert_eq!(a.shortfall_to_recommended_dig_base_units, expected); - assert_eq!(format_dig(expected), "6.018"); - // Above the recommendation there is nothing to add, and it must not wrap. - let rich = advice(10, 1_000, 0, 90_000); - assert_eq!(rich.shortfall_to_recommended_dig_base_units, 0); - assert_eq!(rich.funding_state, FundingState::Adequate); + let remedies: std::collections::BTreeSet<&str> = CollateralBufferUnknownReason::ALL + .iter() + .map(|&r| { + assert!( + !census.contains(r.as_wire()), + "{} collides with a census reason", + r.as_wire() + ); + buffer_remedy(r) + }) + .collect(); + // Four distinct remedies, not one sentence reused four times. + assert_eq!(remedies.len(), CollateralBufferUnknownReason::ALL.len()); } #[test] - fn the_served_pair_count_scales_the_lock_and_is_never_the_census_figure() { + fn the_served_root_count_scales_the_lock_and_is_never_the_census_figure() { // `pairs` is the field a fixture is most likely to pin at 1; vary it. - assert_eq!(advice(1, 2_500, 0, 0).one_epoch_lock_dig_base_units, 2_500); - let forty = advice(40, 2_500, 0, 0); - assert_eq!(forty.one_epoch_lock_dig_base_units, 100_000); - assert_eq!(forty.pairs_served_by_this_node, 40); + assert_eq!(one_epoch_lock(1, 2_500, 0), 2_500); + let (_, _, _, pairs, ..) = known_buffer(buf(40, 2_500, 0, 0)); + assert_eq!(pairs, 40); + assert_eq!(one_epoch_lock(pairs, 2_500, 0), 100_000); // `known()` reports a NETWORK census of 12 stores. Substituting it for the served set would // give 30_000 here rather than 100_000 -- the whole network's bill on one operator. let CollateralRequirementResult::Known { stores, .. } = known(2_500) else { @@ -1105,7 +961,23 @@ mod tests { stores, 40, "the fixture must make the substitution observable" ); - assert_ne!(forty.one_epoch_lock_dig_base_units, stores * 2_500); + assert_ne!(one_epoch_lock(pairs, 2_500, 0), stores * 2_500); + } + + #[test] + fn the_buffer_carries_the_epoch_the_requirement_named() { + // The epoch and protocol version travel with the buffer so a client never has to pair it + // with a separately-fetched requirement and hope the two describe the same epoch. + let CollateralBufferResult::Known { + epoch, + protocol_version, + .. + } = buf(10, 1_000, 0, 0) + else { + panic!("expected known") + }; + assert_eq!(epoch, 9, "known() names epoch 9"); + assert_eq!(protocol_version, 1); } #[test] diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 1e0bed35..51734b63 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -203,6 +203,7 @@ pub const CONTROL_METHODS: &[&str] = &[ "control.collateral.requirement", "control.collateral.margin.get", "control.collateral.margin.set", + "control.collateral.buffer", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -266,6 +267,7 @@ pub const OWNED_CONTROL_METHODS: &[&str] = &[ "control.collateral.requirement", "control.collateral.margin.get", "control.collateral.margin.set", + "control.collateral.buffer", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -918,6 +920,7 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.collateral.requirement" => collateral_requirement(id), "control.collateral.margin.get" => collateral_margin_get(id), "control.collateral.margin.set" => collateral_margin_set(id, params), + "control.collateral.buffer" => collateral_buffer(id), "control.wallet.broadcast" => wallet_broadcast(ctx, id, params).await, // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s // own status file + CLI (see `crate::updater`'s module doc for why nothing here @@ -3274,6 +3277,44 @@ fn collateral_requirement(id: Value) -> Value { } } +/// `control.collateral.buffer` — the $DIG this node recommends HOLDING, and the funding state. +/// +/// A SEPARATE method from [`collateral_requirement`] because the two figures have different +/// authorities: the requirement is consensus-derived and identical on every node, while this one +/// depends on this node's own served set, an operator preference, and a horizon this node chose. +/// +/// **The funding state is carried, not left to the client to re-derive from thresholds.** Two +/// clients deriving it will eventually disagree, and the one that disagrees about a funding warning +/// is the one an operator acts on. +/// +/// Today this answers `unknown` with a NAMED reason on most nodes, and that is the honest answer +/// rather than a stub: the served `(owner, store, root)` set is enumerated by the census +/// (dig-node#387), and the operator's spendable balance is not a fact this node holds — it cannot +/// know which address holds their $DIG, and a balance read of the wrong address returns a confident +/// number about the wrong money. A zero would read as "no buffer needed" and have them post nothing. +fn collateral_buffer(id: Value) -> Value { + let store = crate::collateral::EpochRecordStore::in_state_dir(); + let requirement = crate::collateral::requirement(&store, current_collateral_epoch()); + let margin_bp = crate::collateral::CollateralConfig::load().margin_bp; + + let answer = crate::collateral::buffer_advice( + // The served set and the spendable balance are both genuinely unknown to the node today, so + // each is passed as `None` and reported through its own reason. They are NOT approximated + // from the hosted-store list or from an arbitrary address: a set that merely resembles the + // served pairs, or a balance for the wrong address, yields a plausible wrong number on a + // money surface — which is worse than no number. + None, + &requirement, + margin_bp, + None, + dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS, + ); + match serde_json::to_value(answer) { + Ok(v) => control_ok(id, v), + Err(e) => control_error(id, ErrorCode::ControlError, e.to_string()), + } +} + /// `control.collateral.margin.get` — the node's local safety margin, in basis points. fn collateral_margin_get(id: Value) -> Value { let cfg = crate::collateral::CollateralConfig::load(); diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index f1af51c0..7da15a11 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -160,6 +160,13 @@ pub enum ControlAction { CollateralRequirement, /// `control.collateral.margin.get` — the node's local safety margin, in basis points. CollateralMarginGet, + /// `control.collateral.buffer` — the node's OWN answer: what it recommends holding and + /// the funding state it is in, from the served set and balance the node itself knows. + /// + /// Distinct from the operator-supplied form of `dign collateral buffer`, which computes the + /// same figures from operands a person types. The node is authoritative; the operands exist + /// so a person can get a number before the node can enumerate its own served set. + CollateralBuffer, /// `control.collateral.margin.set` — persist the local safety margin. /// /// The node is the authoritative home for this setting: the flywheel is headless, so a machine @@ -224,6 +231,7 @@ impl ControlAction { ControlAction::ChiaPeersList => "control.chiaPeers.list", ControlAction::CollateralRequirement => "control.collateral.requirement", ControlAction::CollateralMarginGet => "control.collateral.margin.get", + ControlAction::CollateralBuffer => "control.collateral.buffer", ControlAction::CollateralMarginSet { .. } => "control.collateral.margin.set", ControlAction::ChiaPeersRemove { .. } => "control.chiaPeers.remove", } @@ -472,6 +480,7 @@ pub fn cli_covered_control_methods() -> Vec<&'static str> { // deterministic mirror-coin collateral surface (dig_ecosystem#3173). ControlAction::CollateralRequirement.method(), ControlAction::CollateralMarginGet.method(), + ControlAction::CollateralBuffer.method(), ControlAction::CollateralMarginSet { margin_bp: 0 }.method(), ControlAction::ChiaPeersRemove { ip: String::new(), @@ -739,9 +748,9 @@ pub fn collateral_buffer( pairs_served: Option, spendable_dig_base_units: Option, ) -> std::io::Result { - use crate::collateral::{ - buffer_advice, format_dig, BufferAdvice, FundingState, DEFAULT_BUFFER_HORIZON_EPOCHS, - }; + use crate::collateral::{buffer_advice, buffer_remedy, format_dig, one_epoch_lock}; + use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS; + use dig_node_control_interface::results::{CollateralBufferResult, CollateralFundingState}; let requirement_json = call_control( config, @@ -766,21 +775,44 @@ pub fn collateral_buffer( ); let result = serde_json::to_value(advice).map_err(std::io::Error::other)?; - let figures = match advice { - BufferAdvice::Unknown { reason } => { + let known = match advice { + CollateralBufferResult::Unknown { reason } => { // Names the missing fact and what would resolve it. Emphatically not a zero: a zero // buffer reads as "no buffer needed", which is the reassuring rendering of an unknown. return Ok(Outcome::new( format!( "collateral buffer UNKNOWN — {}.\n Run `dign collateral requirement` to see \ what this node does know.", - reason.remedy() + buffer_remedy(reason) ), result, )); } - BufferAdvice::Known(f) => f, + known @ CollateralBufferResult::Known { .. } => known, + }; + let CollateralBufferResult::Known { + funding_state, + recommended_buffer_dig_base_units, + spendable_dig_base_units, + pairs_served_by_this_node, + required_per_store_dig_base_units, + margin_bp, + overlap_dig_base_units, + escalation_headroom_dig_base_units, + horizon_epochs, + escalation_ceiling_micros, + .. + } = known + else { + unreachable!("the unknown arm returned above") }; + // Derived rather than carried: the contract publishes the three inputs, so a client and this + // node compute the same lock instead of trusting a fourth field that could disagree with them. + let lock = one_epoch_lock( + pairs_served_by_this_node, + required_per_store_dig_base_units, + margin_bp, + ); // The working is shown, briefly. A figure nobody can sanity-check is a figure nobody acts on, // and the horizon is stated because a buffer without its horizon is a magic number. @@ -789,49 +821,50 @@ pub fn collateral_buffer( this epoch locks {} DIG · reclaim overlap {} DIG · escalation headroom {} DIG over {} \ epochs (x{}.{:06} ceiling — a worst case, not a forecast)\n \ recommended holding {} DIG", - figures.pairs_served_by_this_node, - format_dig(figures.required_per_store_dig_base_units), - figures.margin_bp, - format_dig(figures.one_epoch_lock_dig_base_units), - format_dig(figures.overlap_dig_base_units), - format_dig(figures.escalation_headroom_dig_base_units), - figures.horizon_epochs, - figures.escalation_ceiling_micros / 1_000_000, - figures.escalation_ceiling_micros % 1_000_000, - format_dig(figures.recommended_buffer_dig_base_units), + pairs_served_by_this_node, + format_dig(required_per_store_dig_base_units), + margin_bp, + format_dig(lock), + format_dig(overlap_dig_base_units), + format_dig(escalation_headroom_dig_base_units), + horizon_epochs, + escalation_ceiling_micros / 1_000_000, + escalation_ceiling_micros % 1_000_000, + format_dig(recommended_buffer_dig_base_units), ); // The number a person acts on goes LAST, where the eye lands, and it is an amount rather than // an adjective: "balance low" is not actionable, "add 3.250 DIG" is. - let short = format_dig(figures.shortfall_to_recommended_dig_base_units); + // Derived, not carried: the contract publishes the recommendation and the balance, so a + // shortfall field would be a fourth number that could disagree with the two it comes from. + let short = + format_dig(recommended_buffer_dig_base_units.saturating_sub(spendable_dig_base_units)); summary.push_str("\n "); - summary.push_str(&match figures.funding_state { - FundingState::ShortNow => format!( + summary.push_str(&match funding_state { + CollateralFundingState::ShortNow => format!( "SHORT NOW — you cannot cover this epoch; store roots are going uncollateralised. \ Add at least {} DIG now, {short} DIG to reach the recommendation.", - format_dig( - figures - .one_epoch_lock_dig_base_units - .saturating_sub(figures.spendable_dig_base_units) - ), + format_dig(lock.saturating_sub(spendable_dig_base_units)), ), - FundingState::DangerouslyLow => format!( + CollateralFundingState::DangerouslyLow => format!( "DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be. \ Add {short} DIG to reach the recommendation." ), // Deliberately unalarming prose: every epoch this state covers IS covered, and it is a - // readout rather than a shortfall (`FundingState::is_shortfall`). - FundingState::BelowRecommendedBuffer => format!( + // readout rather than a shortfall (`CollateralFundingState::is_shortfall`). + CollateralFundingState::BelowRecommendedBuffer => format!( "below the recommended buffer — every epoch is covered, but there is no cushion. \ Add {short} DIG to reach it." ), // Zero served roots is NOT the same sentence as "your funding is sufficient", even though // the arithmetic agrees: saying "funded" to an operator serving nothing implies their store // roots are covered, and they have none. - FundingState::Adequate if figures.pairs_served_by_this_node == 0 => { + CollateralFundingState::Funded if pairs_served_by_this_node == 0 => { "no store roots to collateralise — nothing to fund.".to_string() } - FundingState::Adequate => "funded — at or above the recommended buffer.".to_string(), + CollateralFundingState::Funded => { + "funded — at or above the recommended buffer.".to_string() + } }); Ok(Outcome::new(summary, result)) diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 0592768a..547b847d 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -64,7 +64,7 @@ axum = "0.7" # The PUBLISHED control-plane contract. `BalanceAsset` is re-exported from it rather than # restated here: the wire spelling of an asset and the asset a read scopes to must be the same # value, and two enums naming the same tokens agree only until one of them gains a variant. -dig-node-control-interface = "0.23" +dig-node-control-interface = "0.24" # The ecosystem's single definition of a Chia offer id (dig_ecosystem#3140, #283): # `sha256(spend_bundle.to_bytes())`, the value Chia's `Offer.name()`, Sage and dexie use. This # crate once derived the id itself from the offered coin set alone, which collided across offers From 4ceea799c43e7b48c088c852079e318b4167a2de Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 07:12:10 -0700 Subject: [PATCH 16/23] feat(cli): dign collateral buffer asks the node, with operands as an override Closes the gap between the CLI-parity list and reality: the list claimed a verb drove control.collateral.buffer and none did. With no operands the verb now calls the node -- it is the authority on its own served set, preference and balance. --roots/--balance remain as an override so a person can get a figure before the node can enumerate its served set. One renderer (render_buffer) serves both paths. Two renderings of one money figure is how an operator comes to trust the wrong one. A payload this build cannot decode is reported as unreadable rather than rendered as a figure. Co-Authored-By: Claude --- crates/dig-node-service/src/control_cli.rs | 46 ++++++++++++++++------ crates/dig-node-service/src/entrypoint.rs | 11 +++++- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 7da15a11..6279a483 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -26,6 +26,7 @@ use serde_json::{json, Value}; use crate::cli::Outcome; use crate::config::Config; use crate::control_client::call_control; +use dig_node_control_interface::results::{CollateralBufferResult, CollateralFundingState}; /// One control-parity CLI action, clap-agnostic (mapped from the subcommand in `entrypoint.rs`). /// Each variant names the single `control.*` method it dispatches — see [`ControlAction::method`]. @@ -713,6 +714,7 @@ fn summarize(method: &str, result: &Value) -> String { format!("{coins} direct child coin(s) — one hop, not a lineage{more}") } "control.collateral.requirement" => summarize_collateral_requirement(result), + "control.collateral.buffer" => summarize_collateral_buffer(result), // Shown with its real cost, not as a bare setting: a margin is a number of basis points // until someone says what it costs to hold. "control.collateral.margin.get" | "control.collateral.margin.set" => { @@ -748,9 +750,8 @@ pub fn collateral_buffer( pairs_served: Option, spendable_dig_base_units: Option, ) -> std::io::Result { - use crate::collateral::{buffer_advice, buffer_remedy, format_dig, one_epoch_lock}; + use crate::collateral::buffer_advice; use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS; - use dig_node_control_interface::results::{CollateralBufferResult, CollateralFundingState}; let requirement_json = call_control( config, @@ -774,19 +775,25 @@ pub fn collateral_buffer( DEFAULT_BUFFER_HORIZON_EPOCHS, ); let result = serde_json::to_value(advice).map_err(std::io::Error::other)?; + Ok(Outcome::new(render_buffer(&advice), result)) +} - let known = match advice { +/// Render a buffer answer as the human line, for BOTH the node-computed and the +/// operator-supplied forms. +/// +/// One renderer on purpose: two renderings of one money figure is how an operator comes to +/// trust the wrong one. +fn render_buffer(advice: &CollateralBufferResult) -> String { + use crate::collateral::{buffer_remedy, format_dig, one_epoch_lock}; + let known = match *advice { CollateralBufferResult::Unknown { reason } => { // Names the missing fact and what would resolve it. Emphatically not a zero: a zero // buffer reads as "no buffer needed", which is the reassuring rendering of an unknown. - return Ok(Outcome::new( - format!( - "collateral buffer UNKNOWN — {}.\n Run `dign collateral requirement` to see \ - what this node does know.", - buffer_remedy(reason) - ), - result, - )); + return format!( + "collateral buffer UNKNOWN — {}.\n Run `dign collateral requirement` to see \ + what this node does know.", + buffer_remedy(reason) + ); } known @ CollateralBufferResult::Known { .. } => known, }; @@ -867,7 +874,22 @@ pub fn collateral_buffer( } }); - Ok(Outcome::new(summary, result)) + summary +} + +/// A concise human line for `control.collateral.buffer` — the node's OWN answer. +/// +/// Shares [`render_buffer`] with the operator-supplied form of `dign collateral buffer`, so the two +/// cannot describe the same figures differently. Two renderings of one money figure is how an +/// operator comes to trust the wrong one. +fn summarize_collateral_buffer(result: &Value) -> String { + match serde_json::from_value::(result.clone()) { + Ok(answer) => render_buffer(&answer), + // A payload this build cannot decode is reported as such, never as a figure. Guessing at a + // partially-understood money answer is worse than saying the node spoke a shape we do not + // know. + Err(e) => format!("collateral buffer: unreadable answer from the node ({e})"), + } } /// A concise human line for `control.collateral.requirement`. diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index dc93ee70..698dbb6c 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -858,6 +858,15 @@ pub fn run() -> std::process::ExitCode { Command::Collateral { action: Some(CollateralCommand::Buffer { roots, balance }), } => match parse_dig_amount(balance.as_deref()) { + // With no operands the NODE is asked -- it is the authority on its own served set, + // preference and balance, and `control.collateral.buffer` is that answer. Operands are + // an override for the operator who wants a figure before the node can enumerate its own + // served set, and they are computed locally FROM the node's requirement and margin. + Ok(None) if roots.is_none() => render( + control_cli::run(&config, ControlAction::CollateralBuffer), + action, + json, + ), Ok(b) => render( control_cli::collateral_buffer(&config, roots, b), action, @@ -1084,7 +1093,7 @@ fn collateral_action(cmd: Option) -> std::io::Result Ok(ControlAction::CollateralRequirement), // Handled before this mapper: it composes three control reads rather than dispatching one. - Some(CollateralCommand::Buffer { .. }) => Ok(ControlAction::CollateralRequirement), + Some(CollateralCommand::Buffer { .. }) => Ok(ControlAction::CollateralBuffer), Some(CollateralCommand::Margin { action: None }) => Ok(ControlAction::CollateralMarginGet), Some(CollateralCommand::Margin { action: Some(MarginCommand::Set { value }), From 9a2d26b9e938f0777602d9126ac3287cc14c9da0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 07:12:54 -0700 Subject: [PATCH 17/23] docs(spec): SPEC 24.5/24.6 follow the served 0.24.0 buffer method Adds the fourth unknown reason (requirement_unknown) and why it is distinct -- a missing requirement is a NETWORK gap while the other three are LOCAL, and reporting one as the other sends the operator to fix the wrong thing. Also records why the buffer is its own method rather than a widening of .requirement, that the funding state is carried rather than re-derived by clients, funded rather than adequate, and that the dign operands are an override rather than a fallback the node applies to itself. Co-Authored-By: Claude --- SPEC.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 03631224..ea3eac92 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7500,7 +7500,7 @@ The states, of which only two leave an epoch uncovered: | `short_now` | cannot cover the current epoch; roots are already uncollateralised | **yes** | | `dangerously_low` | covers now; could not cover the next epoch at the escalation ceiling | **yes** | | `below_recommended_buffer` | every epoch covered, no cushion | **NO — readout only** | -| `adequate` | at or above the recommendation | no | +| `funded` | at or above the recommendation | no | `below_recommended_buffer` MUST be excluded from `is_shortfall()` and MUST NOT raise a notification. Every epoch it covers *is* covered; a healthy node sits there much of the time, and a recurring alert @@ -7508,16 +7508,27 @@ an operator learns to dismiss teaches them to dismiss the two above it. ### 24.6. UNKNOWN must be UNREPRESENTABLE as a number +`control.collateral.buffer` is a **separate method** from `control.collateral.requirement`, not a +widening of it: the requirement is consensus-derived and identical on every node, while the buffer +depends on this node's own served set, an operator preference, and a horizon this node chose. The +funding state is **carried, not re-derived by clients** — two clients deriving it will disagree, and +the one that disagrees about a funding warning is the one an operator acts on. + The buffer answer is a **tagged variant**: the unknown case carries `state` and `reason` and **no numeric field at all**. This is a shape requirement, not a convention — a struct with optional numbers can hold a `0`, and a zero buffer reads as *no buffer needed*. | `reason` | the missing fact | |---|---| +| `requirement_unknown` | the node cannot state this epoch's per-store requirement | | `served_set_unknown` | the node cannot enumerate the roots it serves | | `reclaim_state_unknown` | the node cannot tell which of last epoch's coins are reclaimed | | `balance_unknown` | the operator's spendable $DIG is not known to this node | +`requirement_unknown` is distinct from the rest and from §24.2's reasons on purpose: a missing +requirement is a NETWORK gap, while the other three are LOCAL. Reporting one as the other sends the +operator to fix the wrong thing. + **None of these has a counterpart in §24.2's census taxonomy**, which is the structural reason the buffer is its own method rather than a widening of the requirement: collapsing `served_set_unknown` into `not_censused` reports a missing LOCAL fact as a missing NETWORK one and sends the operator to @@ -7539,9 +7550,11 @@ A malformed operator-supplied balance is REFUSED, never parsed as zero, which wo `dig-mirror-collateral`'s own constant, never to a number spelled out in the CLI; a second spelling is how two surfaces post different amounts for one choice. An unrecognised word is REFUSED, never treated as the default. -* `collateral buffer [--roots ] [--balance ]` — §24.5/§24.6. States an AMOUNT to add, not an - adjective, and shows the working: roots served, per-store requirement, margin, the three terms, and - the horizon with its ceiling. `--roots` is an operand until `control.collateral.buffer` publishes, - because no published method reports the served set and the nearest-looking one is a different set. +* `collateral buffer [--roots ] [--balance ]` — §24.5/§24.6. With no operands it asks the + node, which is the authority on its own served set, preference and balance. The operands are an + OVERRIDE, so a person can get a figure before the node can enumerate its served set; they are never + a fallback the node applies itself. Either way it states an AMOUNT to add, not an adjective, and + shows the working: roots served, per-store requirement, margin, the three terms, and the horizon + with its ceiling. Every verb offers `--json` beside the human output, with stable field names (§6.2). From 8d19002fe4ca30970e2e775b7bb98570efa05246 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 08:46:39 -0700 Subject: [PATCH 18/23] chore(cli): drop committed splice scratch files and restore spliced doc comments The `.tsplice.py` and `.wire2.py` helpers were editing scratch and never belonged in the tree; they are now ignored via the worktree's info/exclude rather than the repo .gitignore, which is for artefacts every clone produces. One of those splices concatenated three doc blocks above `parse_dig_amount`, orphaning `chia_peers_action`'s "listing is the default" security rationale and `collateral_action`'s "an unrecognised preset is REFUSED" rationale onto an unrelated parser. A security comment on the wrong function is worse than a missing one: it reads as reviewed. Each block is back on the function it describes. Co-Authored-By: Claude --- .tsplice.py | 28 ---------------------------- .wire2.py | 44 -------------------------------------------- 2 files changed, 72 deletions(-) delete mode 100644 .tsplice.py delete mode 100644 .wire2.py diff --git a/.tsplice.py b/.tsplice.py deleted file mode 100644 index 3e078834..00000000 --- a/.tsplice.py +++ /dev/null @@ -1,28 +0,0 @@ -p = 'crates/dig-node-service/src/collateral.rs' -lines = open(p, encoding='utf-8', newline='').read().split('\n') - -start = None -end = None -for i, l in enumerate(lines): - if start is None and '/// Unwrap a `Known` answer, or fail loudly naming what came back instead.' in l: - start = i - if start is not None and 'fn format_dig_keeps_three_decimals' in l: - end = i - 1 - break -assert start is not None and end is not None and end > start, (start, end) -assert lines[end].strip() == '#[test]', lines[end] - -new = open('.t.rs', encoding='utf-8', newline='').read().rstrip('\n').split('\n') -lines[start:end] = new + [''] -s = '\n'.join(lines) - -# The tests need the contract's params const in scope. -old = ' use dig_mirror_collateral::{base_per_store, handicap_for_owners, required_per_store, EpochCensus};' -new_imp = (' use dig_mirror_collateral::{\n' - ' base_per_store, handicap_for_owners, required_per_store, EpochCensus,\n' - ' };\n' - ' use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS;') -assert s.count(old) == 1, 'test import' -s = s.replace(old, new_imp, 1) -open(p, 'w', encoding='utf-8', newline='').write(s) -print('ok') diff --git a/.wire2.py b/.wire2.py deleted file mode 100644 index 8c7e2a6e..00000000 --- a/.wire2.py +++ /dev/null @@ -1,44 +0,0 @@ -p = 'crates/dig-node-service/src/control.rs' -s = open(p, encoding='utf-8', newline='').read() - - -def rep(old, new, tag, n=1): - global s - assert s.count(old) == n, f'{tag}: {s.count(old)}' - s = s.replace(old, new, n) - - -rep('"control.collateral.margin.set",\n', '"control.collateral.margin.set",\n "control.collateral.buffer",\n', - 'lists', 2) -rep(' "control.collateral.margin.set" => collateral_margin_set(id, params),', - ' "control.collateral.margin.set" => collateral_margin_set(id, params),\n' - ' "control.collateral.buffer" => collateral_buffer(id),', - 'dispatch') - -frag = open('.bufh.rs', encoding='utf-8', newline='').read() -anchor = "/// `control.collateral.margin.get` — the node's local safety margin, in basis points." -assert s.count(anchor) == 1, 'anchor' -s = s.replace(anchor, frag.strip('\n') + '\n\n' + anchor, 1) -open(p, 'w', encoding='utf-8', newline='').write(s) - -p = 'crates/dig-node-service/src/control_cli.rs' -s = open(p, encoding='utf-8', newline='').read() -rep(' ControlAction::CollateralMarginGet.method(),', - ' ControlAction::CollateralMarginGet.method(),', 'noop-check') -rep(''' CollateralMarginGet,''', - ''' CollateralMarginGet, - /// `control.collateral.buffer` — the node's OWN answer: what it recommends holding and the - /// funding state it is in, computed from the served set and balance the node itself knows. - /// - /// Distinct from the operator-supplied form of `dign collateral buffer`, which computes the - /// same figures from operands a person types. The node's answer is authoritative; the operands - /// exist so a person can get a number before the node can enumerate its own served set. - CollateralBuffer,''', 'variant') -rep(''' ControlAction::CollateralMarginGet => "control.collateral.margin.get",''', - ''' ControlAction::CollateralMarginGet => "control.collateral.margin.get", - ControlAction::CollateralBuffer => "control.collateral.buffer",''', 'method') -rep(''' ControlAction::CollateralMarginGet.method(),''', - ''' ControlAction::CollateralMarginGet.method(), - ControlAction::CollateralBuffer.method(),''', 'covered') -open(p, 'w', encoding='utf-8', newline='').write(s) -print('ok') From 4e7d6a6b128c2b0702fabd322b8f5e687d13783a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 08:56:24 -0700 Subject: [PATCH 19/23] fix(cli): refuse an undecodable collateral answer instead of rendering a figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `summarize_collateral_requirement` guarded positively on `state == "unknown"` and let every other payload fall through to a formatter whose fields were each `unwrap_or(0)`. An unrecognised state therefore rendered a REAL epoch number beside a fabricated `0.000 DIG per store` — authoritative-looking rather than degraded, and the exact money lie the unknown branch exists to prevent. An operator acting on it posts nothing and leaves every store root uncollateralised. The trigger is a planned event, not a failure: `CollateralRequirementResult` is `#[serde(tag = "state")]`, so a new variant is additive, and `dign` ships separately from the node — the next minor would put an unrecognised state in front of every already-installed CLI. All three collateral renderers now decode typed and refuse what they cannot decode, matching `summarize_collateral_buffer`, which already did: - `summarize_collateral_requirement` — the gating case. - `collateral_buffer`'s `margin_bp` — an absent margin defaulted to zero understates the recommendation by exactly the cushion the operator chose, flipping `BelowRecommendedBuffer` to `Funded`. - `summarize_margin` — found by sweeping the same class; zero is a legitimate margin, so an absent one substituted for it is indistinguishable from a real answer on the line an operator reads back after `margin set`. Tested on the RENDERED OUTPUT, since the defect was in what a person reads: the fixtures carry the same epoch as the truthful control, so any leaked field fails the assertion. Co-Authored-By: Claude --- crates/dig-node-service/src/control_cli.rs | 249 ++++++++++++++++++--- crates/dig-node-service/src/entrypoint.rs | 26 +-- 2 files changed, 231 insertions(+), 44 deletions(-) diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 6279a483..27a77097 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -765,7 +765,13 @@ pub fn collateral_buffer( ControlAction::CollateralMarginGet.method(), json!({}), )?; - let margin_bp = margin_json["margin_bp"].as_u64().unwrap_or(0); + // Decoded typed and REFUSED if undecodable, never defaulted to zero. A zero margin is a + // legitimate setting, so a missing one substituted for it is indistinguishable from a real + // answer — and it understates the recommendation by exactly the cushion the operator chose, + // which is enough to render `BelowRecommendedBuffer` as `Funded`. + let margin: dig_node_control_interface::results::CollateralMarginResult = + serde_json::from_value(margin_json).map_err(std::io::Error::other)?; + let margin_bp = margin.margin_bp; let advice = buffer_advice( pairs_served, @@ -902,43 +908,88 @@ fn summarize_collateral_buffer(result: &Value) -> String { /// The unknown branch prints the REASON, never a zero. Each reason names a different missing fact /// because the remedies differ: a node that has not censused the epoch needs to run the census, /// whereas one inside the finality depth only needs to wait. +/// +/// # Why this decodes typed instead of guarding on the `state` string +/// +/// An earlier version tested `state == "unknown"` positively and let EVERY other payload fall +/// through to a formatter whose fields were each `unwrap_or(0)`. That renders an unrecognised state +/// as a real epoch number beside a fabricated `0.000 DIG per store` — which reads as authoritative +/// rather than degraded, and is the exact money lie the unknown branch exists to prevent. An +/// operator acting on it posts nothing and leaves every store root uncollateralised. +/// +/// The trigger is a PLANNED event, not a failure. [`CollateralRequirementResult`] is +/// `#[serde(tag = "state")]`, so a new variant is an ADDITIVE contract change, and `dign` and the +/// node are separately installed binaries — so the next minor would make every already-installed +/// `dign` print it. Decoding typed means an undecodable payload is reported as undecodable, exactly +/// as [`summarize_collateral_buffer`] already does. fn summarize_collateral_requirement(result: &Value) -> String { - if result["state"].as_str() == Some("unknown") { - let (reason, remedy) = match result["reason"].as_str().unwrap_or("") { - "not_censused" => ( - "this node has not censused the epoch", - "run the census for this epoch", - ), - "behind_finality_depth" => ( - "the epoch's census inputs are not final yet", - "wait for the chain to settle", - ), - "record_unreadable" => ( - "the record for this epoch could not be read", - "re-run the census for this epoch", - ), - "no_chain_source" => ("this node cannot see the chain", "configure a chain source"), - other => (other, "unknown"), - }; - // Emphatically NOT "0 DIG". An absent requirement rendered as a zero cost is the money lie - // this surface exists to prevent. - return format!("collateral requirement UNKNOWN — {reason} · {remedy}"); - } + use dig_node_control_interface::results::{ + CollateralRequirementResult, CollateralUnknownReason, + }; + + let answer = match serde_json::from_value::(result.clone()) { + Ok(answer) => answer, + // A payload this build cannot decode is reported as such, never as a figure. Guessing at a + // partially-understood money answer is worse than saying the node spoke a shape we do not + // know. + Err(e) => return format!("collateral requirement: unreadable answer from the node ({e})"), + }; + + let (epoch, protocol_version, required, stores, owners, multiplier, handicap) = match answer { + CollateralRequirementResult::Unknown { reason } => { + let (reason, remedy) = match reason { + CollateralUnknownReason::NotCensused => ( + "this node has not censused the epoch", + "run the census for this epoch", + ), + CollateralUnknownReason::BehindFinalityDepth => ( + "the epoch's census inputs are not final yet", + "wait for the chain to settle", + ), + CollateralUnknownReason::RecordUnreadable => ( + "the record for this epoch could not be read", + "re-run the census for this epoch", + ), + CollateralUnknownReason::NoChainSource => { + ("this node cannot see the chain", "configure a chain source") + } + }; + // Emphatically NOT "0 DIG". An absent requirement rendered as a zero cost is the money + // lie this surface exists to prevent. + return format!("collateral requirement UNKNOWN — {reason} · {remedy}"); + } + CollateralRequirementResult::Known { + epoch, + protocol_version, + required_per_store_dig_base_units, + stores, + owners, + multiplier_micros, + handicap_dig_base_units, + } => ( + epoch, + protocol_version, + required_per_store_dig_base_units, + stores, + owners, + multiplier_micros, + handicap_dig_base_units, + ), + }; - let dig = |key: &str| crate::collateral::format_dig(result[key].as_u64().unwrap_or(0)); - let multiplier = result["multiplier_micros"].as_u64().unwrap_or(0); + let dig = crate::collateral::format_dig; format!( "epoch {} (protocol v{}) — {} DIG per store, before any safety margin\n \ from {} advertisement(s) across {} collateralised owner(s) · multiplier {}.{:06}x · \ handicap {} DIG", - result["epoch"].as_u64().unwrap_or(0), - result["protocol_version"].as_u64().unwrap_or(0), - dig("required_per_store_dig_base_units"), - result["stores"].as_u64().unwrap_or(0), - result["owners"].as_u64().unwrap_or(0), + epoch, + protocol_version, + dig(required), + stores, + owners, multiplier / 1_000_000, multiplier % 1_000_000, - dig("handicap_dig_base_units"), + dig(handicap), ) } @@ -947,8 +998,19 @@ fn summarize_collateral_requirement(result: &Value) -> String { /// A margin shown alone is a number of basis points and nothing more. Shown beside the per-store /// amount it adds, it is a decision an operator can make — which is the whole point of exposing the /// setting rather than just storing it. +/// +/// Decoded typed for the same reason as [`summarize_collateral_requirement`]: zero is a legitimate +/// margin, so an absent one substituted for it reads as a deliberate choice the operator did not +/// make — and this line is what they check after `margin set`, which makes it the one place a +/// silently-defaulted zero would be believed. fn summarize_margin(result: &Value) -> String { - let bp = result["margin_bp"].as_u64().unwrap_or(0); + let bp = match serde_json::from_value::< + dig_node_control_interface::results::CollateralMarginResult, + >(result.clone()) + { + Ok(margin) => margin.margin_bp, + Err(e) => return format!("safety margin: unreadable answer from the node ({e})"), + }; let preset = match bp { b if b == dig_mirror_collateral::SAFETY_MARGIN_BP_TIGHT => " (tight)", b if b == dig_mirror_collateral::SAFETY_MARGIN_BP_DEFAULT => " (default)", @@ -1203,6 +1265,131 @@ mod tests { assert!(line.contains("0.720 DIG"), "{line}"); } + /// A payload this build cannot decode must render as UNREADABLE — never as a figure, and never + /// as the `unknown` branch either. + /// + /// # What each fixture distinguishes + /// + /// The nearest wrong implementation is the one this replaced: guard positively on + /// `state == "unknown"`, and let everything else fall through to a formatter of + /// `unwrap_or(0)`s. It renders a REAL epoch number beside a fabricated `0.000 DIG per store`, + /// which reads as authoritative rather than degraded. + /// + /// So the fixtures carry `epoch: 104` — the SAME epoch as the truthful control above — and the + /// assertions forbid it appearing. A renderer that leaked any real field through would print + /// `104` and fail here; asserting only the absence of `0.000` would not catch a formatter that + /// happened to be given a non-zero requirement. + /// + /// The `known`-with-a-missing-field case is the one that separates a typed decode from a + /// hybrid that matches the state string and then falls back per field: the state token is + /// perfectly valid there, and only a decode of the whole variant refuses it. + /// + /// This matters because the trigger is a PLANNED event: `CollateralRequirementResult` is + /// `#[serde(tag = "state")]`, so a new variant is additive, and `dign` ships separately from + /// the node — the next minor would put an unrecognised state in front of every installed CLI. + #[test] + fn an_undecodable_requirement_renders_unreadable_and_never_a_figure() { + let cases = [ + // A state this build has never heard of — the additive-variant case. + ( + "unrecognised state", + json!({ "state": "suspended", "epoch": 104 }), + ), + // No state tag at all. + ("empty object", json!({})), + // A VALID state token whose payload is short a required field. A positive guard on the + // string cannot tell this from a complete answer. + ( + "known missing owners", + json!({ + "state": "known", + "epoch": 104, + "protocol_version": 1, + "required_per_store_dig_base_units": 3_780u64, + "stores": 17, + "multiplier_micros": 900_000u64, + "handicap_dig_base_units": 720u64, + }), + ), + // An unknown whose REASON this build does not recognise: the reason taxonomy is + // additive too, and a reason rendered as an empty remedy is its own small lie. + ( + "unrecognised reason", + json!({ "state": "unknown", "reason": "awaiting_peer_quorum" }), + ), + ]; + + for (label, payload) in cases { + let line = summarize_collateral_requirement(&payload); + assert!( + line.contains("unreadable answer from the node"), + "{label} was not reported as unreadable: {line}" + ); + // Not a figure, at any value. + assert!( + !line.contains("per store"), + "{label} rendered a per-store figure: {line}" + ); + assert!( + !line.contains("0.000"), + "{label} rendered a zero cost: {line}" + ); + // Not a real field leaked from the payload. `104` is the live epoch in the truthful + // control above, so its presence here means the formatter ran. + assert!( + !line.contains("104"), + "{label} leaked a real field into a degraded line: {line}" + ); + // And not misreported as the node having NAMED a missing fact, which would send the + // operator to run a census that would not help. + assert!( + !line.contains("UNKNOWN"), + "{label} borrowed the unknown branch: {line}" + ); + } + } + + /// An undecodable margin must not render as `0 bp`. + /// + /// Zero is a LEGITIMATE margin, which is what makes the old `unwrap_or(0)` dangerous here: + /// unlike an absent requirement, an absent margin substituted for zero is indistinguishable + /// from a real answer, and this line is what an operator reads back after `margin set` to + /// confirm the setting took. The `250` fixture is the distinguishing one — a renderer that + /// leaked the payload through would still find no `margin_bp`, so the fixture instead proves + /// the ADJACENT well-formed value renders, keeping a truthful control beside the refusal. + #[test] + fn an_undecodable_margin_renders_unreadable_and_never_zero_bp() { + for (label, payload) in [ + ("empty object", json!({})), + ("wrong type", json!({ "margin_bp": "250" })), + ("negative", json!({ "margin_bp": -1 })), + ("misspelled field", json!({ "marginBp": 250 })), + ] { + let line = summarize_margin(&payload); + assert!( + line.contains("unreadable answer from the node"), + "{label} was not reported as unreadable: {line}" + ); + assert!( + !line.contains("0 bp"), + "{label} rendered a fabricated zero margin: {line}" + ); + assert!( + !line.contains('%'), + "{label} rendered a percentage from an unknown: {line}" + ); + } + // The truthful control: the same shape, well-formed, still renders its real value. + assert!(summarize_margin(&json!({ "margin_bp": 250 })).contains("250 bp")); + // And a genuine zero margin is still reportable as itself. + let zero = summarize_margin(&json!({ "margin_bp": 0 })); + assert!(zero.contains("0 bp"), "{zero}"); + assert!( + !zero.contains("unreadable"), + "a real zero margin must not be reported as unreadable: {zero}" + ); + } + #[test] fn the_margin_line_names_its_preset_and_keeps_sub_percent_values() { // A 1 bp margin is 0.01%, and rounding it to whole percent would erase a legal choice diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 698dbb6c..c8ce83ff 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -1041,19 +1041,6 @@ fn subscriptions_action(cmd: Option) -> ControlAction { } } -/// Map the `chia-peers` subcommand to its [`ControlAction`] (no sub-action → list the peers). -/// -/// Listing is the default because it is the only harmless one of the three: defaulting to `add` -/// would make a bare `dign chia-peers` grant trust, and a default must never be the act that -/// costs something. -/// Map the `collateral` subcommand to its [`ControlAction`], resolving a margin preset name. -/// -/// A preset resolves to the SAME basis-point constant `dig-mirror-collateral` publishes, rather -/// than to a number spelled out here. A second spelling of "generous" is how one surface comes to -/// post a different amount than another for a setting the operator believes is one choice. -/// -/// An unrecognised word is REFUSED, never silently treated as a number or as the default: a typo -/// that fell through to the default would change what this node posts without saying so. /// Parse a `--balance` operand in DIG into DIG base units. /// /// $DIG has THREE decimals and its base unit is 0.001 DIG. Parsed as text and scaled by integer @@ -1086,6 +1073,14 @@ fn parse_dig_amount(raw: Option<&str>) -> std::io::Result> { .ok_or_else(refuse) } +/// Map the `collateral` subcommand to its [`ControlAction`], resolving a margin preset name. +/// +/// A preset resolves to the SAME basis-point constant `dig-mirror-collateral` publishes, rather +/// than to a number spelled out here. A second spelling of "generous" is how one surface comes to +/// post a different amount than another for a setting the operator believes is one choice. +/// +/// An unrecognised word is REFUSED, never silently treated as a number or as the default: a typo +/// that fell through to the default would change what this node posts without saying so. fn collateral_action(cmd: Option) -> std::io::Result { use dig_mirror_collateral::{ SAFETY_MARGIN_BP_DEFAULT, SAFETY_MARGIN_BP_GENEROUS, SAFETY_MARGIN_BP_TIGHT, @@ -1116,6 +1111,11 @@ fn collateral_action(cmd: Option) -> std::io::Result) -> ControlAction { match cmd { None | Some(ChiaPeersCommand::List) => ControlAction::ChiaPeersList, From 89c726753903b07a7645cb7026487be9c5e6fbb5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 09:10:52 -0700 Subject: [PATCH 20/23] fix(collateral): an unreadable state directory is not an uncensused epoch Round-2 gate findings D2 and D3, plus the recorded decisions for D1 and D4. D2 -- `EpochRecordStore::get` returned `Absent` for ANY read failure, collapsing the Absent/Unreadable split the method's own doc comment exists to preserve. The consequence is a wrong REMEDY rather than a wrong figure: `Absent` renders as "run the census for this epoch", which writes to the very file that could not be read, so an operator with a broken state directory is sent to a remedy that fails again without ever naming the fault. Only `NotFound` is now `Absent`. D3 -- `dign collateral buffer --roots` rendered an operator-supplied count in a line otherwise identical to the node's own measured answer, so a guess was indistinguishable from a measurement, including in the recommendation derived from it. The line now marks its provenance, which makes the named limitation visible where the figure is read rather than only in the help text. The marker goes when dig-node#387 lands the served-set count. D1 (a well-formed but false stored record scales into a multi-million-DIG recommendation) is recorded as a bounded known rather than fixed: writing that record needs the state directory that also holds the identity key, arbitrary corruption already fails closed, and a plausibility bound derived at the read side would be a rival implementation of the controller. The honest remedy is a protocol_version ceiling check where the census writer's invariants live. D4 (a paired-tier margin outlives `pairing.revoke`) is likewise documented, not fixed: it is a pairing-lifecycle question, and answering it for this one setting would establish by accident a rule the other paired-tier writes do not follow. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 69 +++++++++++++++++++++- crates/dig-node-service/src/control.rs | 12 ++++ crates/dig-node-service/src/control_cli.rs | 15 ++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index a259b941..3c0854ca 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -176,9 +176,16 @@ impl EpochRecordStore { /// separately from the full record: a record that fails to deserialise still usually carries a /// readable epoch number, and attributing it is the difference between the node saying "I lost /// this" and the node saying "this never happened". + /// A file that is MISSING is [`StoredEpoch::Absent`]; a file that exists and cannot be read is + /// [`StoredEpoch::Unreadable`]. An earlier version returned `Absent` for both, which sent an + /// operator whose state directory had become unreadable off to "run the census for this epoch" + /// — a remedy that writes to the very file it cannot read, and so fails again without ever + /// naming the real fault. pub fn get(&self, epoch: u64) -> StoredEpoch { - let Ok(text) = std::fs::read_to_string(&self.path) else { - return StoredEpoch::Absent; + let text = match std::fs::read_to_string(&self.path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StoredEpoch::Absent, + Err(_) => return StoredEpoch::Unreadable, }; let mut best: Option = None; let mut saw_unreadable = false; @@ -269,6 +276,25 @@ pub fn current_epoch_now() -> CurrentEpoch { /// This epoch's per-store requirement, or the named reason the node cannot state it. /// +/// # Why a stored record is served verbatim, and what that does NOT cover +/// +/// A record that parses is trusted as this node's own bookkeeping. Deliberate, with a bound worth +/// stating: a record that is well-formed but FALSE produces a correspondingly false requirement, +/// and the buffer scales it — a forged multiplier yields a recommendation in the millions of DIG. +/// +/// It is not defended here for three reasons. Writing that record requires write access to the +/// node's state directory, which is also where the margin, the config and the identity key live — +/// an attacker holding it does not need this path. Arbitrary corruption already fails closed, as +/// an unparseable line is [`StoredEpoch::Unreadable`] rather than a figure. And a plausibility +/// bound derived here would be a rival implementation of the controller `dig-mirror-collateral` +/// owns, which is how two surfaces come to disagree about one price. +/// +/// The honest remedy is a check the record can make against its OWN published invariants — chiefly +/// that `protocol_version` does not exceed what this build implements, since a record from a newer +/// model is one this build cannot interpret even when every field parses. That belongs with the +/// census writer, where those invariants live, and is tracked on dig-node#387 rather than bolted +/// on at the read side. +/// /// The margin is deliberately not consulted. `required_per_store_dig_base_units` is the PRE-margin, /// consensus-derived figure every node derives identically; folding a local preference into it here /// would make this operator's cushion look like the network's price. @@ -597,6 +623,45 @@ mod tests { ); } + /// A record file that EXISTS but cannot be read is `Unreadable`; only a missing one is + /// `Absent`. + /// + /// The two fixtures differ in exactly one way — whether the path is there — and they must + /// produce DIFFERENT answers. A single fixture could not show that, because the old code + /// returned `Absent` for both and would satisfy either assertion alone. + /// + /// The distinction is a remedy, not a figure: `Absent` becomes "run the census for this + /// epoch", which writes to the very file that cannot be read, so an operator with a broken + /// state directory is sent to a remedy that fails again without naming the fault. + /// + /// A directory standing in for the file is the portable way to provoke a non-`NotFound` read + /// error — chmod is a no-op for an administrator on Windows, so a permission fixture would + /// pass here by not being unreadable at all. + #[test] + fn an_unreadable_record_file_is_not_reported_as_a_missing_one() { + let dir = tempfile::tempdir().expect("tempdir"); + + // Control: genuinely nothing there. Still `Absent`, still "this never happened". + assert_eq!(store_at(dir.path()).get(7), StoredEpoch::Absent); + + // The variable: the path exists and cannot be read as a file. + let blocked = dir.path().join("blocked"); + std::fs::create_dir(&blocked).expect("create_dir"); + let store = EpochRecordStore::at(blocked.join(EPOCH_RECORD_FILE)); + std::fs::create_dir(store.path()).expect("directory in the record's place"); + + assert_eq!(store.get(7), StoredEpoch::Unreadable); + assert!( + matches!( + requirement(&store, CurrentEpoch::Final(7)), + CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::RecordUnreadable + } + ), + "an unreadable state directory must not be reported as an uncensused epoch" + ); + } + #[test] fn a_corrupt_record_for_the_asked_epoch_is_unreadable_not_absent() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 51734b63..8347dfe6 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3326,6 +3326,18 @@ fn collateral_margin_get(id: Value) -> Value { /// A value above the contract's ceiling is REFUSED rather than clamped, and the returned figure is /// what was actually stored. Clamping and returning the clamped value would leave the caller's /// stored intent and the node's behaviour disagreeing on the money path. +/// +/// # Known: the margin OUTLIVES the pairing that set it +/// +/// This method is reachable from the paired tier, and the margin it writes persists across +/// `pairing.revoke` — so a revoked client leaves a setting behind that the operator never chose and +/// cannot see was inherited. Bounded today because the margin reaches no spend path: it scales a +/// displayed recommendation and nothing posts from it. +/// +/// Deliberately NOT fixed here. "Which paired-tier state should a revoke reclaim" is a question +/// about the pairing lifecycle as a whole — the margin is one instance of it, and answering it for +/// this one setting would establish a rule by accident that the other paired-tier writes do not +/// follow. Tracked as a pairing-lifecycle question rather than as a collateral one. fn collateral_margin_set(id: Value, params: &Value) -> Value { use dig_node_control_interface::params::CollateralMarginSetParams; diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 27a77097..100cbfd1 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -781,7 +781,20 @@ pub fn collateral_buffer( DEFAULT_BUFFER_HORIZON_EPOCHS, ); let result = serde_json::to_value(advice).map_err(std::io::Error::other)?; - Ok(Outcome::new(render_buffer(&advice), result)) + + // PROVENANCE. `pairs_served_by_this_node` is named as though the node counted it, and on the + // node's own answer it did. Here it is whatever the operator typed after `--roots`, and the + // rendered line is otherwise identical — so an operator's guess would be indistinguishable + // from a measurement, including in the recommendation derived from it. Marking it makes the + // named limitation visible where the figure is read rather than only in the help text. The + // marker goes away when the node serves its own served-set count (dig-node#387). + let mut human = render_buffer(&advice); + if pairs_served.is_some() { + human.push_str( + "\n (store-root count supplied by you via `--roots`, not measured by this node)", + ); + } + Ok(Outcome::new(human, result)) } /// Render a buffer answer as the human line, for BOTH the node-computed and the From 1befabba1a6872058aa3c1a179e4077f8b83c967 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 09:17:57 -0700 Subject: [PATCH 21/23] docs(spec): SPEC 24.2/24.7 fix the undecodable-answer and operand-provenance rules Three normative statements the round-2 gate showed were absent: - a client MUST NOT render a requirement it cannot decode as a figure, and MUST NOT borrow the `unknown` rendering, which asserts the node named a missing fact that an undecodable answer did not; - `record_unreadable` is decided by the record FILE, not only its contents: a missing file is `not_censused`, an unreadable one is `record_unreadable`; - `collateral buffer` MUST mark a root count that came from `--roots`. Co-Authored-By: Claude --- SPEC.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index ea3eac92..0b6afc9d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7414,7 +7414,18 @@ The reasons are distinct because their remedies differ: Collapsing them into one "unavailable" hands every client the same unactionable sentence. **A record the node never wrote and one it wrote and cannot read are different answers** -(`not_censused` vs `record_unreadable`). +(`not_censused` vs `record_unreadable`). This is decided by the record file itself, not only by its +contents: a file that is MISSING is `not_censused`, and a file that EXISTS and cannot be read is +`record_unreadable` even when no line was parsed. Reporting an unreadable state directory as +`not_censused` sends the operator to run a census that writes to the very file it cannot read. + +**A client MUST NOT render a requirement it cannot decode as a figure.** `state` is an open tag and +the reason taxonomy is open with it, so a client will meet values it does not know — including from +a node newer than itself, since the CLI and the node are installed separately. A client that guards +positively on the states it knows and formats everything else from absent fields renders a real +epoch beside a zero requirement, which reads as authoritative rather than degraded. An answer this +build cannot decode MUST be reported as undecodable, and MUST NOT borrow the `unknown` rendering +either: `unknown` asserts that the node NAMED a missing fact, which an undecodable answer did not. ### 24.3. The epoch is DERIVED from the canonical clock, and never re-derived locally @@ -7555,6 +7566,8 @@ A malformed operator-supplied balance is REFUSED, never parsed as zero, which wo OVERRIDE, so a person can get a figure before the node can enumerate its served set; they are never a fallback the node applies itself. Either way it states an AMOUNT to add, not an adjective, and shows the working: roots served, per-store requirement, margin, the three terms, and the horizon - with its ceiling. + with its ceiling. When the root count came from `--roots`, the output MUST say so: the operand and + the node's own measurement otherwise render identically, which would make an operator's guess + indistinguishable from a measurement in every figure derived from it. Every verb offers `--json` beside the human output, with stable field names (§6.2). From c12224d30b37c6b81aea23f3ffd1486807fc6d66 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 11:04:59 -0700 Subject: [PATCH 22/23] fix(collateral): pin the margin and provenance guards, and stop the config's silent fallback Round-2 re-gate follow-ups. Four items, all in files this PR already owns. 1. The round-2 claim that three tests failed independently was WRONG: reverting the S1 margin decode and the D3 provenance marker together left the suite green, so only the F1 leg was pinned. Both guards are observable ONLY in the rendered string, and both sat behind two `call_control` round trips where no test could reach them. `buffer_outcome` now separates everything after the I/O, and two tests read what a person reads. The margin test asserts the FLIP rather than merely an error: the balance is calibrated at run time to the ZERO-margin recommendation, the exact point at which `unwrap_or(0)` and the truthful decode disagree about whether the operator is funded. The provenance test asserts a PLACEMENT: the same advice through the shared renderer must stay silent, so moving the marker into `render_buffer` -- the obvious simplification -- makes the node's own measured answer claim an operator supplied a count they never typed, and fails. 2. A third orphaned doc block, this one introduced by this PR rather than inherited: a `load_from` description was attached to `load()`, which takes no directory, while the function that actually swallows the error carried no rationale at all. Each block is now on its own function. 3. The D1 rationale rested on a false premise -- it named the identity key as living in the state directory, but `state.rs` holds ONLY the control/auth state and is identity-independent by design. The conclusion survives, because the control token is the strictly greater capability, so the sentence now rests on the token. A correct decision resting on a false premise is how the premise gets reused elsewhere. 4. `CollateralConfig::load_from` carried the exact Absent/Unreadable conflation D2 just fixed, one type above it in the same file. Falling back to the `+1%` default is still right -- refusing to start over a corrupt preference file would take the node down over the one setting whose absence is survivable -- but doing it SILENTLY is not: an operator whose margin has reverted learns about it only from a figure that looks deliberate. A file that exists and cannot be read, or cannot be parsed, is now warned about by path and cause. Co-Authored-By: Claude --- crates/dig-node-service/src/collateral.rs | 62 +++++-- crates/dig-node-service/src/control_cli.rs | 182 ++++++++++++++++++++- 2 files changed, 222 insertions(+), 22 deletions(-) diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 3c0854ca..42df6555 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -65,12 +65,6 @@ impl Default for CollateralConfig { } impl CollateralConfig { - /// Load from `dir`, falling back to the default for a missing OR unreadable file. - /// - /// An unreadable file yields the default rather than an error because the margin is a cushion: - /// refusing to start over a corrupt preference file would take the node down over the one - /// setting whose absence is survivable. The fallback is the `+1%` default, never `0`, so the - /// degraded path still errs toward over-posting. /// Load from the node's own machine-wide state directory. /// /// The production entry point. It resolves the directory ITSELF via [`crate::state::state_dir`] @@ -90,11 +84,47 @@ impl CollateralConfig { /// Load from an explicit directory. /// /// For tests and for callers that already own a directory. Production uses [`Self::load`]. + /// + /// # Why an unreadable preference file yields the default rather than an error + /// + /// The margin is a cushion, and refusing to start over a corrupt preference file would take + /// the node down over the one setting whose absence is survivable. The fallback is the `+1%` + /// default, never `0`, so the degraded path still errs toward OVER-posting. + /// + /// # But it is not SILENT about which case it is in + /// + /// A missing file and a file that exists and cannot be read are different facts, exactly as + /// they are for [`EpochRecordStore::get`]. Collapsing them here is bounded — both land on the + /// same survivable default, the result is visible on `dign collateral margin`, and it reaches + /// no spend path — but silence is how an operator whose margin has quietly reverted to the + /// default learns about it only from a figure that looks deliberate. Falling back is the right + /// behaviour; doing it without saying so is not. pub fn load_from(dir: &Path) -> Self { - std::fs::read_to_string(dir.join(COLLATERAL_CONFIG_FILE)) - .ok() - .and_then(|t| serde_json::from_str(&t).ok()) - .unwrap_or_default() + let path = dir.join(COLLATERAL_CONFIG_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + // Nothing written yet: the default IS the answer, and there is nothing to report. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "the collateral preference file could not be read; using the default safety margin" + ); + return Self::default(); + } + }; + match serde_json::from_str(&text) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "the collateral preference file could not be parsed; using the default safety margin" + ); + Self::default() + } + } } /// Persist to `dir`, creating the state directory with restricted permissions if needed. @@ -283,11 +313,13 @@ pub fn current_epoch_now() -> CurrentEpoch { /// and the buffer scales it — a forged multiplier yields a recommendation in the millions of DIG. /// /// It is not defended here for three reasons. Writing that record requires write access to the -/// node's state directory, which is also where the margin, the config and the identity key live — -/// an attacker holding it does not need this path. Arbitrary corruption already fails closed, as -/// an unparseable line is [`StoredEpoch::Unreadable`] rather than a figure. And a plausibility -/// bound derived here would be a rival implementation of the controller `dig-mirror-collateral` -/// owns, which is how two surfaces come to disagree about one price. +/// node's machine-wide state directory, which is where the CONTROL TOKEN lives — a strictly +/// greater capability than a false requirement, since it authorises every `control.*` call. (The +/// identity key is NOT there: [`crate::state`] holds only the control/auth state and is +/// identity-independent by design. The bounding rests on the token.) Arbitrary corruption already +/// fails closed, as an unparseable line is [`StoredEpoch::Unreadable`] rather than a figure. And a +/// plausibility bound derived here would be a rival implementation of the controller +/// `dig-mirror-collateral` owns, which is how two surfaces come to disagree about one price. /// /// The honest remedy is a check the record can make against its OWN published invariants — chiefly /// that `protocol_version` does not exceed what this build implements, since a record from a newer diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 100cbfd1..9ea09413 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -750,33 +750,55 @@ pub fn collateral_buffer( pairs_served: Option, spendable_dig_base_units: Option, ) -> std::io::Result { - use crate::collateral::buffer_advice; - use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS; - let requirement_json = call_control( config, ControlAction::CollateralRequirement.method(), json!({}), )?; - let requirement: dig_node_control_interface::results::CollateralRequirementResult = - serde_json::from_value(requirement_json).map_err(std::io::Error::other)?; let margin_json = call_control( config, ControlAction::CollateralMarginGet.method(), json!({}), )?; + buffer_outcome( + requirement_json, + margin_json, + pairs_served, + spendable_dig_base_units, + ) +} + +/// Turn the two control answers into the buffer outcome — everything after the I/O. +/// +/// # Why this is separate from [`collateral_buffer`] +/// +/// Both guards this function carries are observable ONLY in the string it returns: that an +/// undecodable margin is refused rather than defaulted to zero, and that an operator-supplied root +/// count is marked as such. Left inline behind two `call_control` round trips, neither could be +/// exercised without a listening node, and both duly went unpinned — a round-2 gate reverted them +/// together and the suite stayed green. A defect in what a person reads needs a test that reads it. +fn buffer_outcome( + requirement_json: Value, + margin_json: Value, + pairs_served: Option, + spendable_dig_base_units: Option, +) -> std::io::Result { + use crate::collateral::buffer_advice; + use dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS; + + let requirement: dig_node_control_interface::results::CollateralRequirementResult = + serde_json::from_value(requirement_json).map_err(std::io::Error::other)?; // Decoded typed and REFUSED if undecodable, never defaulted to zero. A zero margin is a // legitimate setting, so a missing one substituted for it is indistinguishable from a real // answer — and it understates the recommendation by exactly the cushion the operator chose, // which is enough to render `BelowRecommendedBuffer` as `Funded`. let margin: dig_node_control_interface::results::CollateralMarginResult = serde_json::from_value(margin_json).map_err(std::io::Error::other)?; - let margin_bp = margin.margin_bp; let advice = buffer_advice( pairs_served, &requirement, - margin_bp, + margin.margin_bp, spendable_dig_base_units, DEFAULT_BUFFER_HORIZON_EPOCHS, ); @@ -1278,6 +1300,152 @@ mod tests { assert!(line.contains("0.720 DIG"), "{line}"); } + /// A well-formed `known` requirement, for the buffer fixtures below. + fn known_requirement_json() -> Value { + json!({ + "state": "known", + "epoch": 104, + "protocol_version": 1, + "required_per_store_dig_base_units": 3_780u64, + "stores": 17, + "owners": 820, + "multiplier_micros": 1_000_000u64, + "handicap_dig_base_units": 0u64, + }) + } + + /// The recommendation the buffer reaches for `margin_bp`, read off the machine result. + fn recommendation_at(margin_bp: u64) -> u64 { + let outcome = buffer_outcome( + known_requirement_json(), + json!({ "margin_bp": margin_bp }), + Some(3), + Some(u64::MAX / 2), + ) + .expect("a well-formed pair decodes"); + outcome.result["recommended_buffer_dig_base_units"] + .as_u64() + .expect("a known answer carries its recommendation") + } + + /// An undecodable margin must ABORT the buffer, never be substituted with zero. + /// + /// # Why this asserts the FLIP and not merely an error + /// + /// The nearest wrong implementation is `unwrap_or(0)`, and it fails in the dangerous + /// direction: it understates the recommendation by exactly the cushion the operator chose, so + /// a node that is `BelowRecommendedBuffer` reads as `Funded` and nobody adds the $DIG. An + /// `is_err()` assertion alone would pin the refusal while saying nothing about why it matters — + /// and would still pass if the cushion had quietly stopped affecting the figure. + /// + /// So the balance is pinned at the ZERO-margin recommendation: the exact point at which the + /// two implementations disagree about the funding state. A margin of zero calls that funded; a + /// real 500 bp margin does not. The fixture is calibrated at run time from the machine result + /// rather than from a hard-coded figure, so it cannot drift out of the band it is testing. + #[test] + fn an_undecodable_margin_aborts_the_buffer_rather_than_becoming_a_zero_cushion() { + let at_zero = recommendation_at(0); + let at_500 = recommendation_at(500); + // The cushion is real money, and it is the money the defaulted-to-zero path would drop. + assert!( + at_500 > at_zero, + "a 500 bp margin must recommend more than none: {at_500} vs {at_zero}" + ); + + // Held at exactly the zero-margin recommendation: funded only if the cushion is ignored. + let human = |margin_bp: u64| { + buffer_outcome( + known_requirement_json(), + json!({ "margin_bp": margin_bp }), + Some(3), + Some(at_zero), + ) + .expect("a well-formed pair decodes") + .summary + }; + let fabricated = human(0); + let truthful = human(500); + assert!( + fabricated.contains("funded"), + "the zero-margin reading should be the reassuring one: {fabricated}" + ); + assert!( + !truthful.contains("funded — at or above"), + "a real 500 bp margin must not read as funded at the zero-margin figure: {truthful}" + ); + assert!( + truthful.contains("Add "), + "the truthful reading must state an amount to add: {truthful}" + ); + + // And the defect itself: an undecodable margin produces NO reading at all. + for (label, margin) in [ + ("empty object", json!({})), + ("wrong type", json!({ "margin_bp": "500" })), + ("misspelled field", json!({ "marginBp": 500 })), + ] { + let outcome = buffer_outcome(known_requirement_json(), margin, Some(3), Some(at_zero)); + assert!( + outcome.is_err(), + "{label} produced a buffer reading from an unknown margin: {:?}", + outcome.map(|o| o.summary) + ); + } + } + + /// An operand-supplied root count is MARKED, and the shared renderer does not mark anything. + /// + /// # Why the second assertion is the load-bearing one + /// + /// This is a PLACEMENT, not an outcome: the marker is correct only when it sits on the operand + /// path. Put it inside [`render_buffer`] instead — the obvious "simplification", since that is + /// where the line is built — and the node's OWN measured answer starts claiming the operator + /// supplied a count they never typed, which is the same confusion inverted. Asserting only + /// that the operand path carries the marker would pass under that mislocation. + /// + /// So the second actor is the shared renderer, given the SAME advice: it must stay silent. + #[test] + fn an_operand_supplied_root_count_is_marked_and_only_on_the_operand_path() { + const MARKER: &str = "supplied by you via `--roots`"; + + let operand = buffer_outcome( + known_requirement_json(), + json!({ "margin_bp": 100 }), + Some(3), + Some(u64::MAX / 2), + ) + .expect("a well-formed pair decodes") + .summary; + assert!( + operand.contains(MARKER), + "an operand-supplied count must say so where the figure is read: {operand}" + ); + assert!( + operand.contains("not measured by this node"), + "the marker must say what it is NOT, not merely name the flag: {operand}" + ); + + // The same advice through the shared renderer — the node's own measured answer. Silent. + let requirement: dig_node_control_interface::results::CollateralRequirementResult = + serde_json::from_value(known_requirement_json()).expect("fixture decodes"); + let advice = crate::collateral::buffer_advice( + Some(3), + &requirement, + 100, + Some(u64::MAX / 2), + dig_node_control_interface::params::DEFAULT_BUFFER_HORIZON_EPOCHS, + ); + let measured = render_buffer(&advice); + assert!( + !measured.contains(MARKER), + "the node's own measured count must not claim an operator supplied it: {measured}" + ); + // The control: both really are describing the same three roots, so the difference above + // is the marker and not two unrelated answers. + assert!(measured.contains("serving 3 store root(s)"), "{measured}"); + assert!(operand.contains("serving 3 store root(s)"), "{operand}"); + } + /// A payload this build cannot decode must render as UNREADABLE — never as a figure, and never /// as the `unknown` branch either. /// From 29203a703849f481103f6ef890d6e88dc34abad7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 12:00:18 -0700 Subject: [PATCH 23/23] =?UTF-8?q?chore(release):=200.162.0=20=E2=80=94=200?= =?UTF-8?q?.161.0=20was=20taken=20by=20#393?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae2e9bed..63fb50ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3067,7 +3067,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.161.0" +version = "0.162.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index d75ebb94..2c6262b8 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.161.0" +version = "0.162.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over