diff --git a/Cargo.lock b/Cargo.lock index f762ec31..63fb50ed 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.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca01b2c4a8bb3bfb43eb5c3c1b835ba945e36e9e089f8385928823121fa98c2a" +checksum = "1bc89557fb776432bd92b99831adfca70d9a39b7da53a26c04f41abef787b0d7" 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", @@ -3046,7 +3067,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.161.0" +version = "0.162.0" dependencies = [ "async-trait", "axum", @@ -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/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 diff --git a/SPEC.md b/SPEC.md index 9249ccfc..0b6afc9d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7364,3 +7364,210 @@ 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`). 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 + +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 + +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 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 x epochs of runway" overstates by the epoch count and tells an +operator to hold many times what they need. + +The total is three named terms that sum without double-counting: + +``` +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 +``` + +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. + +**`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. + +**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; 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** | +| `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 +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 +fix the wrong thing. + +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. + +### 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 [--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. 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). 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..35a8c982 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.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. +# +# 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-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs new file mode 100644 index 00000000..42df6555 --- /dev/null +++ b/crates/dig-node-service/src/collateral.rs @@ -0,0 +1,1088 @@ +//! 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, +}; +use dig_node_control_interface::results::{ + CollateralBufferResult, CollateralBufferUnknownReason, CollateralFundingState, + 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"; + +/// 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 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`]. + /// + /// # 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 { + 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. + 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. + /// + /// 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)) + } + + /// 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". + /// 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 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; + 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, +} + +/// The mirror-coin epoch containing `now_unix_ms`. +/// +/// 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. +/// +/// 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 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. +/// +/// # 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 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 +/// 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. +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), + } +} + +/// Why a node cannot state the buffer, and what would resolve it. +/// +/// 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" + } + CollateralBufferUnknownReason::ServedSetUnknown => { + "this node cannot list the store roots it serves" + } + 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" + } + } +} + +/// 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 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 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. +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: u64) -> u64 { + u64::try_from(u128::from(amount) * u128::from(micros) / u128::from(MULT_SCALE)) + .unwrap_or(u64::MAX) +} + +/// 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 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. +/// +/// 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. 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, +) -> CollateralBufferResult { + let unknown = |reason| CollateralBufferResult::Unknown { reason }; + + // 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(CollateralBufferUnknownReason::RequirementUnknown); + }; + let Some(pairs) = pairs_served_by_this_node else { + return unknown(CollateralBufferUnknownReason::ServedSetUnknown); + }; + let Some(spendable) = spendable_dig_base_units else { + return unknown(CollateralBufferUnknownReason::BalanceUnknown); + }; + + 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 `control.collateral.requirement` already reported. + let relative_ceiling = |epochs| { + u64::try_from( + u128::from(escalated_multiplier_micros(multiplier_micros, epochs)) + * u128::from(MULT_SCALE) + / u128::from(multiplier_micros.max(1)), + ) + .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 { + CollateralFundingState::ShortNow + } else if spendable < next_epoch_ceiling { + CollateralFundingState::DangerouslyLow + } else if spendable < recommended { + CollateralFundingState::BelowRecommendedBuffer + } else { + CollateralFundingState::Funded + }; + + 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, + overlap_dig_base_units: overlap, + escalation_headroom_dig_base_units: headroom, + horizon_epochs, + escalation_ceiling_micros, + } +} + +/// 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::{ + 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. + /// + /// 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 + }; + // 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 + } + + 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 + ); + } + + /// 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"); + 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); + } + + #[test] + 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. + /// + /// 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. + /// `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, + protocol_version: 1, + required_per_store_dig_base_units: required, + stores: 12, + owners: 300, + multiplier_micros: 800_000, + handicap_dig_base_units: 0, + } + } + + /// 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 { + 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 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 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!(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!(recommended, lock + overlap + headroom); + // And it is NOT a runway: four epochs of runway would be at least 4x the lock. + assert!( + recommended < lock * 3, + "a runway-shaped recommendation would be at least 4x the lock, got {recommended}" + ); + assert_eq!(horizon, DEFAULT_BUFFER_HORIZON_EPOCHS); + } + + #[test] + fn the_escalation_ceiling_comes_from_the_controller_and_is_reported_with_the_horizon() { + 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 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. + let lock = one_epoch_lock(pairs, 1_000, 0); + assert_eq!(headroom, scale_micros(lock, ceiling) - lock); + assert!(ceiling > 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 + // 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() { + // 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!(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 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 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), 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!(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!(!CollateralFundingState::BelowRecommendedBuffer.is_shortfall()); + assert!(!CollateralFundingState::Funded.is_shortfall()); + } + + #[test] + fn each_missing_fact_gets_its_own_buffer_reason_and_no_figure_at_all() { + let cases = [ + // 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), + &CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::NotCensused, + }, + 100, + Some(0), + 4, + ), + 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 CollateralBufferResult::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"); + for absent in [ + "recommended_buffer_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 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 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_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!(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 { + unreachable!() + }; + assert_ne!( + stores, 40, + "the fixture must make the substitution observable" + ); + 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] + 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/control.rs b/crates/dig-node-service/src/control.rs index 431c4352..8347dfe6 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -199,6 +199,11 @@ 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.collateral.buffer", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -258,6 +263,11 @@ 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.collateral.buffer", "control.profile.putBody", "control.profile.getBody", "control.updater.status", @@ -901,6 +911,16 @@ 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(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(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 @@ -3103,6 +3123,248 @@ 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(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), + }; + + // `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. + 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 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() -> crate::collateral::CurrentEpoch { + crate::collateral::current_epoch_now() +} + +/// `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(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()), + } +} + +/// `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(); + 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. +/// +/// # 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; + + 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() { + 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..9ea09413 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`]. @@ -151,6 +152,30 @@ 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.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 + /// 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 +230,10 @@ 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::CollateralBuffer => "control.collateral.buffer", + ControlAction::CollateralMarginSet { .. } => "control.collateral.margin.set", ControlAction::ChiaPeersRemove { .. } => "control.chiaPeers.remove", } } @@ -235,6 +264,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 +477,12 @@ 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::CollateralBuffer.method(), + ControlAction::CollateralMarginSet { margin_bp: 0 }.method(), ControlAction::ChiaPeersRemove { ip: String::new(), ban: false, @@ -462,6 +500,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 +713,354 @@ 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" => { + summarize_margin(result) + } "control.updater.status" => summarize_updater_status(result), _ => compact(result), } } +/// `dign collateral buffer` — how much $DIG to hold, and whether this operator is short. +/// +/// # Why the served-pair count is an OPERAND and not a lookup +/// +/// 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, + pairs_served: Option, + spendable_dig_base_units: Option, +) -> std::io::Result { + let requirement_json = call_control( + config, + ControlAction::CollateralRequirement.method(), + json!({}), + )?; + 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 advice = buffer_advice( + pairs_served, + &requirement, + margin.margin_bp, + spendable_dig_base_units, + DEFAULT_BUFFER_HORIZON_EPOCHS, + ); + let result = serde_json::to_value(advice).map_err(std::io::Error::other)?; + + // 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 +/// 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 format!( + "collateral buffer UNKNOWN — {}.\n Run `dign collateral requirement` to see \ + what this node does know.", + buffer_remedy(reason) + ); + } + 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. + 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", + 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. + // 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 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(lock.saturating_sub(spendable_dig_base_units)), + ), + 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 (`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. + CollateralFundingState::Funded if pairs_served_by_this_node == 0 => { + "no store roots to collateralise — nothing to fund.".to_string() + } + CollateralFundingState::Funded => { + "funded — at or above the recommended buffer.".to_string() + } + }); + + 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`. +/// +/// 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. +/// +/// # 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 { + 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 = 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", + epoch, + protocol_version, + dig(required), + stores, + owners, + multiplier / 1_000_000, + multiplier % 1_000_000, + dig(handicap), + ) +} + +/// 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. +/// +/// 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 = 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)", + 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 @@ -845,6 +1232,365 @@ 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}"); + } + + /// 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. + /// + /// # 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 + // 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. diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 61214e47..c8ce83ff 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -192,6 +192,15 @@ enum Command { #[command(subcommand)] action: Option, }, + /// 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, + }, /// Add, list and remove a TRUSTED Chia full-node peer. /// /// A different network from `peers`, which manages DIG gossip peers. Trusting a Chia peer @@ -438,6 +447,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, @@ -572,6 +584,54 @@ 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 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 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, + }, + /// 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. @@ -639,6 +699,7 @@ impl Command { Command::Updater { .. } => "updater", Command::Subscriptions { .. } => "subscriptions", Command::Peers { .. } => "peers", + Command::Collateral { .. } => "collateral", Command::ChiaPeers { .. } => "chia-peers", Command::EnsureHosts => "ensure-hosts", } @@ -794,6 +855,29 @@ 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 { 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, + 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, @@ -816,6 +900,7 @@ fn spends_action(cmd: Option) -> crate::spend_audit_cli::SpendsAc store, kind, status, + after_id, limit, }) => SpendsAction::List(SpendQuery { since_ms, @@ -823,6 +908,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 }, @@ -955,6 +1041,76 @@ fn subscriptions_action(cmd: Option) -> ControlAction { } } +/// 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) +} + +/// 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), + // Handled before this mapper: it composes three control reads rather than dispatching one. + Some(CollateralCommand::Buffer { .. }) => Ok(ControlAction::CollateralBuffer), + 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 }) + } + } +} + /// 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` 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 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, diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 6d569e38..fbba1fbc 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,214 @@ 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); + } } diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index deb31327..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.21" +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