diff --git a/Cargo.lock b/Cargo.lock index 489b188..5681f29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.22.0" +version = "0.23.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 4423f73..fd0ba21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.22.0" +version = "0.23.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index ec78bcf..5162c99 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ and pushes bytes somebody else signed. | Method | Auth | Route | Params | Result | |---|---|---|---|---| | `control.spends.list` | T | own | `{since_ms?, until_ms?, store_id?, kind?, status?, after_id?, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}`; READ-ONLY — the audit record of spends this node made WITHOUT per-transaction approval. It never initiates, signs, cancels or alters a spend. A `failed` row carries the STAGE it died at, because only `signing` means the money definitely did not move; `unresolved` is its own state and MUST NOT be folded into `failed`. `complete` states truncation explicitly — never infer it from the page length. A non-zero `unreadable_lines` means the trail is INCOMPLETE and must be surfaced; a record that cannot be read at all is `-32048`, never an empty page | +| `control.collateral.requirement` | T | own | — | `{state:"known", epoch, protocol_version, required_per_store_dig_base_units, stores, owners, multiplier_micros, handicap_dig_base_units}` \| `{state:"unknown", reason}`; this epoch's PRE-margin per-store collateral requirement with the census inputs that produced it, so a client can show why the figure moved. `unknown` is a first-class answer with a named reason (`not_censused` \| `behind_finality_depth` \| `record_unreadable` \| `no_chain_source`) — NEVER a zero, which would read as no collateral required. `protocol_version` is the version that COMPUTED the epoch, not the newest the build implements. `stores` counts advertisements and `owners` counts collateralised owners; neither is a node count | +| `control.collateral.margin.get` | T | own | — | `{margin_bp:u64}`; the node's LOCAL safety margin in BASIS POINTS (`100` = +1%), never a percentage. A config predating the field reports the `100` default, never `0` | +| `control.collateral.margin.set` | T | own | `{margin_bp:u64}` | `{margin_bp:u64}`; persists the margin and returns what is now in force. Bounded at `10000` bp (+100%) and REFUSED as `-32602` above it rather than clamped, so the applied value never differs silently from the requested one. The margin is local: it MUST NOT reach any census or signal, and it does not guarantee a store is counted | | `control.profile.putBody` | T | del | `{store_id:string, root:string, body_b64:string}` | `{stored:true, store_id, root, body_bytes}`; the node INDEPENDENTLY resolves `root` on chain and REFUSES any body whose recomputed root is not the confirmed one — `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Decoded bodies above `MAX_BODY_BYTES` (4 MiB) are refused as `INVALID_PARAMS` | | `control.profile.getBody` | T | del | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}`; `body_b64:null` means this node holds no body at that root and NEVER that the body could not be read, which is an error. The answer is at the root that was ASKED for, never a newer one | diff --git a/SPEC.md b/SPEC.md index d231d09..afa4b3f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -128,6 +128,9 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `control.wallet.reservations.reserve` | yes | delegated | `{coin_ids:[string], ttl_secs?:u64}` | `{reservation_id, coin_ids, expires_at_unix, ttl_secs}` | | `control.wallet.reservations.release` | yes | delegated | `{reservation_id:string}` | `{released:bool, coin_ids:[string]}` | | `control.spends.list` | yes | owned | `{since_ms?:u64, until_ms?:u64, store_id?:string, kind?:string, status?:string, after_id?:string, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}` | +| `control.collateral.requirement` | yes | owned | — | `CollateralRequirementResult` (`{state:"known", epoch, protocol_version, required_per_store_dig_base_units, stores, owners, multiplier_micros, handicap_dig_base_units}` \| `{state:"unknown", reason}`) | +| `control.collateral.margin.get` | yes | owned | — | `{margin_bp:u64}` | +| `control.collateral.margin.set` | yes | owned | `{margin_bp:u64}` | `{margin_bp:u64}` | | `control.profile.putBody` | yes | delegated | `{store_id:string, root:string, body_b64:string}` | `{stored, store_id, root, body_bytes}` | | `control.profile.getBody` | yes | delegated | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}` | | `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` | @@ -897,6 +900,78 @@ investigating on. **The read is token-gated although it is a read.** The caller supplies no identifier, so the answer is this node's OWN spending history — the same rule that keeps `control.wallet.arrivals` gated. +### 4.2e Mirror collateral: the epoch requirement and the local safety margin + +Two different kinds of value live in this category and MUST NOT be conflated. The **requirement** is +consensus-derived: every node derives the same per-store figure for an epoch from the same census, and +a mirror advertisement is counted in that epoch only if it posts at least that figure. The **safety +margin** is a LOCAL operator preference that changes only how much THIS node chooses to lock over the +requirement. + +**The margin MUST NOT be a consensus input.** No value derived from the margin MUST reach a census, a +controller signal, or any value another node derives. `control.collateral.requirement` MUST return the +PRE-margin requirement; a node that returned the margined amount would present its own preference as +the network's price. + +**The margin is an unsigned integer count of BASIS POINTS (`100` is +1%), and MUST NOT be converted.** +It is the unit `dig_mirror_collateral::apply_safety_margin` takes and the unit dig-app `SPEC.md` §3.7b +fixes for `AgentConfig.collateral.margin_bp`; the `dign` CLI persists the same integer under the same +key. A percentage or a float is not an alternative spelling of it — `1` bp (0.01%) is a legal margin +that any conversion to whole percent erases. + +**The node is the authoritative home for the margin.** The flywheel is headless, so an install with no +GUI MUST be able to set this; `control.collateral.margin.set` is how a graphical client reaches the +same stored value rather than keeping one of its own. + +**A margin above `MAX_SAFETY_MARGIN_BP` (10000 bp, +100%) MUST be refused as `-32602 INVALID_PARAMS`, +never clamped.** The bound exists because `.set` is a money-path mutation reachable with an ordinary +paired token, and the margin arithmetic saturates rather than failing, so an unbounded value produces a +silently enormous posting instead of an error. Refusal rather than clamping is required because the +caller is stating an intent now: applying a different number than the one requested would leave the +caller's stored intent and the node's behaviour disagreeing about money. `control.collateral.margin.set` +MUST return the margin actually in force, and for an accepted request that value MUST equal the +requested one. + +**A node whose configuration predates the field MUST report `DEFAULT_SAFETY_MARGIN_BP` (100 bp, +1%), +never `0`.** A zero margin is a deliberate choice to post the requirement exactly; reporting it for a +configuration that never expressed one tells an operator they declined a cushion they never declined. +The default errs high because the failure is asymmetric — under-posting likely costs that epoch's +rewards, while over-posting costs only the opportunity cost of the locked $DIG. + +**An unknown requirement MUST be stated as unknown, with its reason.** A node that has not censused the +epoch, or that sits inside `CENSUS_FINALITY_DEPTH_BLOCKS` of the chain tip, MUST return +`{state:"unknown", reason}` and MUST NOT return `0`, an error a client would render as "no collateral +required", or a previous epoch's figure presented as this epoch's. The four reasons — +`not_censused`, `behind_finality_depth`, `record_unreadable`, `no_chain_source` — name DIFFERENT missing +facts with different remedies, and MUST NOT be collapsed into one. `known` and `unknown` are variants of +one tagged union precisely so that no representable value carries a figure the node was not given; this +is what dig-app `SPEC.md` §3.7b requires when it forbids any path that renders an absent requirement as +a zero cost. + +**A `known` requirement MUST declare the collateral protocol version that COMPUTED the epoch** — not +the newest version the node's build implements. The two differ exactly when a node upgraded +mid-schedule, which is the one case where a client needs to tell a rule change from a disagreement. A +requirement without its version MUST be rejected by a reader rather than defaulted. + +**The census inputs travel with the figure.** `stores` counts qualifying `(owner, store, root)` +advertisements — one owner publishing two roots for one store id contributes two — and `owners` counts +distinct owner puzzle hashes. Neither is a node count or an operator count, and a surface displaying +`owners` MUST say "collateralised owners". `multiplier_micros` is in millionths and +`handicap_dig_base_units` is in DIG base units. + +**These methods are TOKEN-GATED although two of them are reads.** The requirement's figure is derivable +from chain by anyone, but its `unknown` branch names this node's OWN census position and the caller +supplies no identifier, so the answer is a fact about this node rather than a relayed public one. They +are NOT open reads (§4.2), and they are NOT master-token methods (§2.1): setting a margin grants the +caller no authority that outlives the token, and the operator can revoke and reset it. + +**The count of stores THIS node holds is not served here.** A client assembling a recommended-$DIG +buffer reads the held-store count from `control.hostedStores.list`; duplicating it in the requirement +response would create a second source of truth for a figure that feeds a money calculation. + +**Nothing on this contract claims a margin guarantees inclusion.** The requirement is re-derived every +epoch and can rise by more than any margin chosen. + ### 4.3 The custody boundary (§908) The node holds no user key and produces no signature. `control.wallet.broadcast` carries signed bytes @@ -973,4 +1048,3 @@ The catalog types are plain serde structs with no non-wasm dependencies, so a br (T5's `wasm-bindgen` binding) serializes them to identical JSON. The `serde_json::Value`-typed proxied results and the `#[serde(untagged)]` `RequestId` are the only shapes needing a JS-side check; T5 adds a Rust↔wasm/JS byte-identical KAT over the vectors in §6. - diff --git a/src/kats.rs b/src/kats.rs index 64fd9d1..a93e2f2 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -951,6 +951,12 @@ const MOCK_DEFAULT_TTL_SECS: u64 = 300; const RESERVE_COIN_A: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1"; const RESERVE_COIN_B: &str = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"; +thread_local! { + /// The margin `MockNode` currently holds, so `.set` then `.get` is a real round trip through + /// stored state rather than two independent echoes of the same literal. + static MARGIN_BP: std::cell::RefCell = const { std::cell::RefCell::new(crate::params::DEFAULT_SAFETY_MARGIN_BP) }; +} + // The reservation table `MockNode` serves from: reservation_id -> (held coins, expiry). // // THREAD-LOCAL for the same reason the enrolment registry is: one test's hold must never satisfy @@ -1597,6 +1603,38 @@ impl ControlHandler for MockNode { } })) } + async fn collateral_requirement( + &self, + ) -> Result { + Ok(results::CollateralRequirementResult::Known { + epoch: 7, + protocol_version: 1, + required_per_store_dig_base_units: 1_036, + stores: 4_200, + owners: 310, + multiplier_micros: 1_050_000, + handicap_dig_base_units: 2_760, + }) + } + + async fn collateral_margin_get(&self) -> Result { + Ok(results::CollateralMarginResult { + margin_bp: MARGIN_BP.with(|m| *m.borrow()), + }) + } + + async fn collateral_margin_set( + &self, + params: crate::params::CollateralMarginSetParams, + ) -> Result { + // Stores what it was given, so a test can distinguish "persisted the request" from + // "echoed the request" by reading it back through the OTHER method. + MARGIN_BP.with(|m| *m.borrow_mut() = params.margin_bp); + Ok(results::CollateralMarginResult { + margin_bp: params.margin_bp, + }) + } + async fn spends_list( &self, params: SpendsListParams, @@ -2269,6 +2307,7 @@ fn minimal_params(m: ControlMethod) -> Value { | ControlMethod::SyncTrigger => json!({"store": STORE}), ControlMethod::CapsuleFetch => json!({"store": STORE, "root": ROOT}), ControlMethod::UpdaterSetChannel => json!({"channel": "stable"}), + ControlMethod::CollateralMarginSet => json!({"margin_bp": 100}), ControlMethod::UpdaterPause => json!({}), ControlMethod::PairingApprove => json!({"pairing_id": "x"}), ControlMethod::PairingRevoke => json!({"token_id": "x"}), @@ -4408,3 +4447,295 @@ fn every_catalogued_error_code_is_a_row_of_an_error_code_table() { } } } + +// --------------------------------------------------------------------------------------------- +// Collateral: the epoch requirement + the local safety margin (#32). +// --------------------------------------------------------------------------------------------- + +/// **The three collateral methods carry their exact wire names, category, routing and auth tier.** +/// +/// The names are pinned as LITERALS rather than derived from the enum, because every consumer that +/// must agree with them — `dign`, dig-app, the node dispatcher — spells them by hand somewhere. +/// +/// The auth assertions are the load-bearing half. `control.collateral.requirement` is a READ of a +/// number every node derives identically, so the tempting judgement is to make it an OPEN read by +/// analogy with `control.wallet.peak`. It is NOT one, and the rule in +/// [`ControlMethod::is_open_read`] says why: its UNKNOWN branch discloses this node's own census +/// position, and the caller supplies no identifier. Asserting the gate here means a later widening +/// has to argue with a test rather than with a comment. +#[test] +fn the_collateral_methods_are_named_categorised_and_gated() { + use crate::method::{Category, Routing}; + + assert_eq!( + ControlMethod::CollateralRequirement.name(), + "control.collateral.requirement" + ); + assert_eq!( + ControlMethod::CollateralMarginGet.name(), + "control.collateral.margin.get" + ); + assert_eq!( + ControlMethod::CollateralMarginSet.name(), + "control.collateral.margin.set" + ); + + for m in [ + ControlMethod::CollateralRequirement, + ControlMethod::CollateralMarginGet, + ControlMethod::CollateralMarginSet, + ] { + assert_eq!(m.category(), Category::Collateral, "{}", m.name()); + assert_eq!(m.routing(), Routing::Owned, "{}", m.name()); + assert!(m.requires_auth(), "{} must be token-gated", m.name()); + assert!(!m.is_open_read(), "{} must not be an open read", m.name()); + assert!( + !m.requires_master_token(), + "{} grants no authority outliving its token, so it stays on the ordinary tier", + m.name() + ); + assert!( + ControlMethod::ALL.contains(&m), + "{} missing from ALL", + m.name() + ); + } +} + +/// **The margin is BASIS POINTS on the wire, under the key `margin_bp`.** +/// +/// The fixture is deliberately `1`, not `100`. `100` is +1% and survives a +/// basis-points-to-percent conversion as the plausible integer `1`, so a percent-converting +/// implementation could still round-trip a "reasonable-looking" value; `1` bp is +/// `SAFETY_MARGIN_BP_TIGHT` (0.01%) and any conversion to whole percent collapses it to `0` — the +/// exact silent no-margin the round-up in `apply_safety_margin` exists to prevent. +/// +/// The KEY is pinned too. dig-app `SPEC.md` §3.7b fixes `collateral.margin_bp` and requires `dign` +/// to persist the same integer under the same key; a rename here is the drift that spec forbids. +#[test] +fn the_margin_is_basis_points_under_the_margin_bp_key() { + let set = CollateralMarginSetParams { margin_bp: 1 }; + assert_eq!( + serde_json::to_value(set).unwrap(), + json!({ "margin_bp": 1 }), + "a tight 1bp margin must stay 1 bp; converting to percent would render it as 0" + ); + + let read: results::CollateralMarginResult = + serde_json::from_value(json!({ "margin_bp": 1 })).unwrap(); + assert_eq!(read.margin_bp, 1); + + // The default the contract publishes is the crate's own +1%, expressed in the same unit. + assert_eq!(DEFAULT_SAFETY_MARGIN_BP, 100); +} + +/// **The margin ceiling is pinned from BOTH sides.** +/// +/// A bound tested only from below can only confirm itself: an implementation with no bound at all +/// passes an "at the ceiling is accepted" assertion. So the at-bound value MUST be accepted and the +/// one-over value MUST be refused, and the refusal must carry `-32602 INVALID_PARAMS` rather than +/// any code that would invite a retry. +#[test] +fn the_margin_ceiling_is_pinned_from_both_sides() { + let at_bound = CollateralMarginSetParams { + margin_bp: MAX_SAFETY_MARGIN_BP, + }; + assert!( + at_bound.validated().is_ok(), + "the ceiling itself must be a legal margin" + ); + + let over = CollateralMarginSetParams { + margin_bp: MAX_SAFETY_MARGIN_BP + 1, + }; + let err = over + .validated() + .expect_err("one over the ceiling must be refused"); + assert_eq!(err.code, ControlErrorCode::InvalidParams.code()); +} + +/// **An unknown requirement can never be read as a zero cost.** +/// +/// This is the guard dig-app `SPEC.md` §3.7b demands ("there MUST be no path that renders an absent +/// requirement as a zero cost"), enforced on the CONTRACT rather than on one client. +/// +/// The fixture is a full `unknown` envelope, and the assertion is that it does not deserialize into +/// anything carrying a number. The nearest wrong shape is `required_per_store_dig_base_units: +/// Option` beside a `known: bool` — which happily accepts an unknown response and yields +/// `unwrap_or(0)` at the first careless call site. A tagged enum makes that state unrepresentable, +/// and the reason cannot be dropped: each unknown names WHICH fact is missing. +#[test] +fn an_unknown_requirement_carries_a_reason_and_no_number() { + let unknown: results::CollateralRequirementResult = serde_json::from_value(json!({ + "state": "unknown", + "reason": "not_censused", + })) + .unwrap(); + + match unknown { + results::CollateralRequirementResult::Unknown { reason } => { + assert_eq!(reason, results::CollateralUnknownReason::NotCensused); + } + results::CollateralRequirementResult::Known { .. } => { + panic!("an unknown requirement decoded as a known figure") + } + } + + // Every reason is a distinct wire token, so a client can tell "this node has not censused the + // epoch" from "this node is inside the finality depth" -- different remedies, different waits. + let tokens: std::collections::BTreeSet<&str> = results::CollateralUnknownReason::ALL + .iter() + .map(|r| r.as_wire()) + .collect(); + assert_eq!(tokens.len(), results::CollateralUnknownReason::ALL.len()); +} + +/// **A known requirement cannot omit the protocol version that produced it.** +/// +/// The collateral model is versioned and upgradable, and the version that computed an epoch travels +/// with its record. A client shown a requirement without knowing the ruleset behind it cannot +/// detect that it and the node disagree — it can only render a number confidently and be wrong. +/// +/// So the assertion is a REFUSAL: the same fixture minus `protocol_version` must fail to decode. An +/// `Option` or a `#[serde(default)]` would pass a "the field is present" test on the complete +/// fixture while silently accepting the incomplete one. +#[test] +fn a_known_requirement_must_declare_its_protocol_version() { + let complete = json!({ + "state": "known", + "epoch": 7, + "protocol_version": 1, + "required_per_store_dig_base_units": 1_036, + "stores": 4_200, + "owners": 310, + "multiplier_micros": 1_050_000, + "handicap_dig_base_units": 2_760, + }); + let known: results::CollateralRequirementResult = + serde_json::from_value(complete.clone()).unwrap(); + match known { + results::CollateralRequirementResult::Known { + epoch, + protocol_version, + required_per_store_dig_base_units, + stores, + owners, + multiplier_micros, + handicap_dig_base_units, + } => { + // Every field carries a DIFFERENT value, so a transposition of any pair fails here + // rather than passing on a shape they share. + assert_eq!(epoch, 7); + assert_eq!(protocol_version, 1); + assert_eq!(required_per_store_dig_base_units, 1_036); + assert_eq!(stores, 4_200); + assert_eq!(owners, 310); + assert_eq!(multiplier_micros, 1_050_000); + assert_eq!(handicap_dig_base_units, 2_760); + } + results::CollateralRequirementResult::Unknown { .. } => panic!("known decoded as unknown"), + } + + let mut missing = complete.as_object().unwrap().clone(); + missing.remove("protocol_version"); + assert!( + serde_json::from_value::(Value::Object(missing)) + .is_err(), + "a requirement without its protocol version must be REFUSED, not defaulted" + ); +} + +/// **`.set` stores what it was given, and `.get` reads back the SAME number.** +/// +/// `SPEC.md` §4.2e states as a MUST that `control.collateral.margin.set` returns the margin actually +/// in force and that, for an accepted request, it equals the requested one. That MUST is exactly the +/// one a clamping implementation breaks silently, so it is asserted here rather than left as prose. +/// +/// The route is deliberately `.set` through the DISPATCHER and then `.get` through the dispatcher — +/// two separate calls — because a single call asserting `result.margin_bp == params.margin_bp` is +/// satisfied by a handler that echoes its input and stores nothing. Reading it back through the +/// other method is what distinguishes persistence from an echo. +/// +/// The value is `1` bp rather than a preset: a preset is a value an implementation might special-case +/// or round to, and 1 bp is the smallest legal margin, so it also fails against anything that +/// quantises to whole percent. +#[test] +fn setting_the_margin_persists_it_and_getting_it_back_agrees() { + let set = round_trip(&CollateralMarginSetParams { margin_bp: 1 }) + .expect("setting a legal margin must succeed"); + assert_eq!(set.margin_bp, 1); + + let got = + round_trip(&CollateralMarginGetParams {}).expect("reading the margin back must succeed"); + assert_eq!( + got.margin_bp, 1, + "the margin read back must be the one set, not the default and not an echo" + ); +} + +/// **The margin bound published in `SPEC.md` is the bound the code enforces.** +/// +/// `SPEC.md` §4.2e names the ceiling as a NUMBER in normative prose, and a reimplementer is entitled +/// to build against that number. Prose has no compiler, so changing +/// [`MAX_SAFETY_MARGIN_BP`] without changing the sentence would ship a document that is false about +/// the code in the same repository — the failure mode the sibling method-name and error-code guards +/// exist to prevent, applied to the one figure here that is a money-path bound. +/// +/// The same check covers the default, for the same reason: `SPEC.md` promises `100`, and a node that +/// reported `0` for a config predating the field would be telling an operator they declined a +/// cushion they never declined. +/// +/// The figure is READ OUT of the sentence that names the constant — `` `NAME` (N bp`` — and compared +/// as a number, rather than asking whether the constant's digits occur somewhere in the document. A +/// substring probe over a 900-line file cannot distinguish a MISSING row from a WRONG one: `"10"` +/// occurs inside the `"100"` that is already there, so a default silently retuned to `10` reads as +/// published, and a §4.2e rewritten to teach `250 bp` reads as agreeing while being normatively false +/// about a money-path default. EVERY occurrence is checked, not the first, so a second sentence +/// naming a different figure is a failure rather than a shadowed one. +#[test] +fn the_published_margin_bounds_match_the_declared_constants() { + let spec = include_str!("../SPEC.md"); + for (label, name, value) in [ + ("ceiling", "MAX_SAFETY_MARGIN_BP", MAX_SAFETY_MARGIN_BP), + ( + "default", + "DEFAULT_SAFETY_MARGIN_BP", + DEFAULT_SAFETY_MARGIN_BP, + ), + ] { + let published = published_bp_figures(spec, name); + assert!( + !published.is_empty(), + "SPEC.md never states the margin {label} as `{name}` ( bp, …), so its normative prose \ + and the constant can drift apart unnoticed" + ); + for figure in published { + assert_eq!( + figure, value, + "SPEC.md publishes the margin {label} as {figure} bp while `{name}` is {value}; a \ + reimplementer building against the document would enforce a different money-path \ + bound than this crate does" + ); + } + } +} + +/// Every basis-point figure `SPEC.md` attaches to `name`, read from the `` `NAME` (N bp`` shape §4.2e +/// uses to publish a constant. +/// +/// Returns each occurrence rather than the first: a document that names a constant twice with two +/// different numbers is exactly as false as one that names it once with the wrong number, and taking +/// only the first would hide the second. +fn published_bp_figures(spec: &str, name: &str) -> Vec { + spec.match_indices(name) + .filter_map(|(at, _)| { + let tail = spec[at + name.len()..].trim_start_matches(['`', ' ']); + let digits = tail.strip_prefix('(')?; + let end = digits.find(|c: char| !c.is_ascii_digit())?; + if !digits[end..].starts_with(" bp") { + return None; + } + digits[..end].parse().ok() + }) + .collect() +} diff --git a/src/method.rs b/src/method.rs index 6f92103..3fb50ce 100644 --- a/src/method.rs +++ b/src/method.rs @@ -29,6 +29,11 @@ pub enum Routing { } /// The functional area a control method belongs to — for grouping in UIs and docs. +/// +/// `#[non_exhaustive]` so adding a category in a minor release is additive; downstream matches must +/// carry a `_ => …` arm. A new method often arrives with a new area, so this enum grows on the same +/// cadence as [`ControlMethod`] and needs the same guarantee. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Category { /// Node status snapshot. @@ -61,6 +66,10 @@ pub enum Category { /// reading one back. The chain root itself is never written here -- dig-app signs and pushes /// that (§908); this category moves only the bytes an already-confirmed root commits to. Profile, + /// Mirror-collateral: this epoch's derived per-store requirement, and the node's LOCAL safety + /// margin over it. The requirement is consensus-derived and read-only here; the margin is an + /// operator preference this node owns and MUST NOT let into any census or signal. + Collateral, } /// A dig-node CONTROL method. @@ -203,6 +212,14 @@ pub enum ControlMethod { /// `control.spends.list` — read the record of spends this node made WITHOUT asking. SpendsList, + // ---- Mirror collateral (shell-owned) ---- + /// `control.collateral.requirement` -- this epoch's per-store collateral requirement. + CollateralRequirement, + /// `control.collateral.margin.get` -- read the node's local safety margin, in basis points. + CollateralMarginGet, + /// `control.collateral.margin.set` -- set the node's local safety margin, in basis points. + CollateralMarginSet, + // ---- dig-profile bodies (delegated to the engine) ---- /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to. ProfilePutBody, @@ -268,6 +285,9 @@ impl ControlMethod { ControlMethod::WalletReservationsReserve => "control.wallet.reservations.reserve", ControlMethod::WalletReservationsRelease => "control.wallet.reservations.release", ControlMethod::SpendsList => "control.spends.list", + ControlMethod::CollateralRequirement => "control.collateral.requirement", + ControlMethod::CollateralMarginGet => "control.collateral.margin.get", + ControlMethod::CollateralMarginSet => "control.collateral.margin.set", ControlMethod::ProfilePutBody => "control.profile.putBody", ControlMethod::ProfileGetBody => "control.profile.getBody", ControlMethod::PairingRequest => "pairing.request", @@ -498,6 +518,9 @@ impl ControlMethod { | ControlMethod::WalletReservationsReserve | ControlMethod::WalletReservationsRelease => Category::Wallet, ControlMethod::SpendsList => Category::Spends, + ControlMethod::CollateralRequirement + | ControlMethod::CollateralMarginGet + | ControlMethod::CollateralMarginSet => Category::Collateral, ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile, } } @@ -549,6 +572,9 @@ impl ControlMethod { ControlMethod::WalletWatch => "Enrol PUBLIC keys (48-byte G1, lowercase 96-hex) for the node's chain replica to follow, so their addresses are synced and readable. IDEMPOTENT: re-enrolling a key already enrolled succeeds and changes nothing. Keys, never puzzle hashes -- the node derives the addresses itself, so one derivation serves every client. TOKEN-GATED.", ControlMethod::WalletUnwatch => "Deregister enrolled public keys, so the node stops following their addresses. IDEMPOTENT: a key that was never enrolled is not an error. TOKEN-GATED.", ControlMethod::SpendsList => "READ-only: the record of spends this node made WITHOUT per-transaction approval -- what moved, when, on whose standing authority, and whether the chain confirmed it. It NEVER initiates, signs, cancels or alters a spend, and there is no verb here that edits an entry. A failed spend is reported WITH the stage it died at, because only a signing failure means the money definitely did not move; a broadcast or confirmation failure is an UNKNOWN outcome, as is `unresolved`. A page is bounded and says so via `complete`; `unreadable_lines` reports entries the node could not parse, so an audit trail that lost rows can never read as a tidy shorter one. TOKEN-GATED although it is a read: the caller supplies no identifier, so the answer is this node's OWN state.", + ControlMethod::CollateralRequirement => "READ-only: this epoch's per-store mirror-collateral requirement in DIG base units, the collateral protocol version that computed it, and the census inputs behind it (advertised stores, collateralised owners, controller multiplier, small-network handicap) so a client can show WHY the figure moved rather than only that it did. A node that has not censused the epoch, or that is inside the census finality depth, answers `unknown` WITH the reason -- never a zero, which would read as a free requirement. `stores` counts qualifying (owner, store, root) advertisements and `owners` counts distinct owner puzzle hashes: neither is a node count. It NEVER returns the local safety margin, which is not a consensus value.", + ControlMethod::CollateralMarginGet => "Read the node's LOCAL safety margin in BASIS POINTS over the epoch requirement (`100` is +1%). The margin is an operator preference that changes only how much THIS node chooses to lock; it is never a census input and no value derived from it reaches another node. Basis points are the unit the collateral crate's own presets and rounding use, and are never converted.", + ControlMethod::CollateralMarginSet => "Set the node's LOCAL safety margin in BASIS POINTS (`100` is +1%), returning the margin now in force. Bounded at 10000 bp (+100%): the margin multiplies what the node locks on every store, so an unbounded value would commit the operator to an arbitrary posting. A margin above the bound is REFUSED as -32602 INVALID_PARAMS rather than clamped, so the applied value can never differ silently from the requested one. A margin gives room if the requirement rises; it does NOT guarantee a store is counted, because the requirement is re-derived every epoch and can rise by more than any margin.", ControlMethod::ProfilePutBody => "Hand the node the dig-profile BODY that a chain root commits to. The node INDEPENDENTLY resolves that root on chain and REFUSES any body whose recomputed root is not the confirmed one -- the caller's `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Bodies are capped at MAX_BODY_BYTES (4 MiB). TOKEN-GATED.", ControlMethod::ProfileGetBody => "READ-only: the dig-profile body this node holds at a given store id + root, or `body: null` when it holds none. `null` NEVER means the body could not be read, which is an error. TOKEN-GATED.", ControlMethod::WalletWatched => "READ-only: the public keys currently enrolled, so a client can reconcile what it asked for against what the node holds. TOKEN-GATED although it is a read -- the caller supplies nothing, so the answer is this node's OWN key set.", @@ -611,6 +637,9 @@ impl ControlMethod { ControlMethod::WalletReservationsReserve, ControlMethod::WalletReservationsRelease, ControlMethod::SpendsList, + ControlMethod::CollateralRequirement, + ControlMethod::CollateralMarginGet, + ControlMethod::CollateralMarginSet, ControlMethod::ProfilePutBody, ControlMethod::ProfileGetBody, ControlMethod::PairingRequest, diff --git a/src/params.rs b/src/params.rs index d9dfa81..04a94b8 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1766,3 +1766,76 @@ mod tests { assert_ne!(chia_peer_endpoint("::1", 8444), naive); } } + +/// The default local safety margin, in basis points — `+1%`. +/// +/// The same value as `dig_mirror_collateral::SAFETY_MARGIN_BP_DEFAULT`, restated here rather than +/// imported: `dig-mirror-collateral` sits at the SAME crate level as this contract, and a +/// same-level dependency is forbidden (CLAUDE.md Appendix B). It is published on the contract so a +/// config written before the field existed loads as the default rather than as a zero margin. +/// +/// The default errs HIGH because the failure is asymmetric: under-posting likely costs an epoch's +/// rewards, while over-posting costs only the opportunity cost of the locked $DIG. +pub const DEFAULT_SAFETY_MARGIN_BP: u64 = 100; + +/// The largest safety margin a node accepts, in basis points — `10_000`, i.e. +100%. +/// +/// A margin is a cushion against the requirement rising, so doubling the requirement is already far +/// past any honest cushion. The bound exists because `.set` is a MONEY-PATH mutation reachable with +/// an ordinary paired token: an unbounded `u64` lets a caller commit the operator to locking an +/// arbitrary multiple of every store's requirement, and the margin arithmetic saturates rather than +/// failing, so an absurd value produces a silently enormous posting instead of an error. +pub const MAX_SAFETY_MARGIN_BP: u64 = 10_000; + +no_params!( + /// `control.collateral.requirement` params (none). + /// + /// The caller supplies no epoch: the answer is the epoch the NODE currently derives, so a + /// caller-named epoch would invite a client to render a requirement for an epoch that is not + /// the one being posted against. + CollateralRequirementParams => ControlMethod::CollateralRequirement, + results::CollateralRequirementResult +); +no_params!( + /// `control.collateral.margin.get` params (none). + CollateralMarginGetParams => ControlMethod::CollateralMarginGet, + results::CollateralMarginResult +); + +/// `control.collateral.margin.set` params. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollateralMarginSetParams { + /// The margin in BASIS POINTS over the requirement (`100` is +1%), at most + /// [`MAX_SAFETY_MARGIN_BP`]. + /// + /// Basis points, never a percentage and never a float: it is the unit + /// `dig_mirror_collateral::apply_safety_margin` takes and the unit dig-app `SPEC.md` §3.7b + /// fixes for `collateral.margin_bp`. A 1 bp margin (0.01%) is a legal choice and any conversion + /// to whole percent would erase it. + pub margin_bp: u64, +} + +impl CollateralMarginSetParams { + /// Refuse a margin above [`MAX_SAFETY_MARGIN_BP`] as `-32602 INVALID_PARAMS`. + /// + /// Refused rather than clamped, and the asymmetry with dig-app is deliberate. dig-app CLAMPS a + /// stored margin that exceeds its own ceiling, because refusing a value already on disk would + /// leave the node posting the lower amount it was trying to move away from. This is the + /// opposite situation: a caller is stating an intent right now, and silently applying a + /// different number than the one requested would make a subsequent + /// [`CollateralMarginResult`](crate::results::CollateralMarginResult) disagree with what the + /// caller believes it set — on the money path. + pub fn validated(self) -> Result { + if self.margin_bp > MAX_SAFETY_MARGIN_BP { + return Err(ControlError::of( + ControlErrorCode::InvalidParams, + format!( + "margin_bp must be at most {MAX_SAFETY_MARGIN_BP} basis points (+100%); got {}", + self.margin_bp + ), + )); + } + Ok(self) + } +} +control_call!(CollateralMarginSetParams => ControlMethod::CollateralMarginSet, results::CollateralMarginResult); diff --git a/src/results.rs b/src/results.rs index 57f2001..574e790 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2807,3 +2807,114 @@ mod tests { ); } } + +/// Why a node cannot state this epoch's collateral requirement. +/// +/// Each variant names a DIFFERENT missing fact, because the remedies differ: a node that has not +/// censused the epoch needs to run the census, whereas a node inside the finality depth needs only +/// to wait for the chain to settle. Collapsing them into one "unavailable" would hand every client +/// the same unactionable sentence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollateralUnknownReason { + /// This node has not censused the epoch, so it holds no record to answer from. + NotCensused, + /// The epoch's census inputs are not yet final — the node is inside + /// `CENSUS_FINALITY_DEPTH_BLOCKS` of the chain tip and any figure it derived could still move. + BehindFinalityDepth, + /// The node holds a record for the epoch but could not read it. + RecordUnreadable, + /// The node cannot see the chain at all, so it cannot know whether a record should exist. + NoChainSource, +} + +impl CollateralUnknownReason { + /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT. + pub const ALL: &'static [CollateralUnknownReason] = &[ + CollateralUnknownReason::NotCensused, + CollateralUnknownReason::BehindFinalityDepth, + CollateralUnknownReason::RecordUnreadable, + CollateralUnknownReason::NoChainSource, + ]; + + /// The stable snake_case wire token, matching the `reason` field. + pub const fn as_wire(self) -> &'static str { + match self { + CollateralUnknownReason::NotCensused => "not_censused", + CollateralUnknownReason::BehindFinalityDepth => "behind_finality_depth", + CollateralUnknownReason::RecordUnreadable => "record_unreadable", + CollateralUnknownReason::NoChainSource => "no_chain_source", + } + } +} + +/// `control.collateral.requirement` — this epoch's per-store collateral requirement, or a named +/// reason the node cannot state it. +/// +/// **UNKNOWN is a first-class answer, not an error.** A node that has not censused the epoch, or +/// that is inside the census finality depth, is not broken; it simply does not know yet. Making +/// that a tagged variant rather than an optional number means there is no representable state in +/// which a client holds a figure it has not been given — which is what dig-app `SPEC.md` §3.7b +/// requires when it forbids any path that renders an absent requirement as a zero cost. +/// +/// **The census inputs travel with the figure on purpose.** A client that can show only the number +/// can say the price moved; a client holding `stores`, `owners`, `multiplier_micros` and +/// `handicap_dig_base_units` can say WHY it moved. The per-epoch record already holds all four, so +/// carrying them costs the node nothing and is the difference between a figure an operator can +/// weigh and one they can only accept. +/// +/// **The margin is deliberately absent here.** The requirement is a consensus-derived value every +/// node derives identically; the margin is a local operator preference that MUST NOT be a consensus +/// input. Returning them from one method would invite exactly the conflation dig-app `SPEC.md` +/// §3.7b forbids — read the margin from +/// [`CollateralMarginResult`](crate::results::CollateralMarginResult) instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum CollateralRequirementResult { + /// The node holds a final record for the epoch and states its requirement. + Known { + /// The epoch this requirement governs, one-based. + epoch: u64, + /// The collateral protocol version that COMPUTED this epoch. + /// + /// Travels with the figure because the model is versioned and upgradable: a client that + /// knows only the number cannot tell a disagreement from a rule change. + protocol_version: u16, + /// The per-store requirement, in DIG base units, BEFORE any local safety margin. + required_per_store_dig_base_units: u64, + /// Qualifying `(owner, store, root)` advertisements counted in the census. + /// + /// An advertisement count, never a node count: one owner publishing two roots for one store + /// id contributes two. + stores: u64, + /// Distinct owner puzzle hashes across those advertisements. + /// + /// Not a node count and not an operator count. A surface displaying it MUST say + /// "collateralised owners". + owners: u64, + /// The controller multiplier for the epoch, in millionths (`MULT_SCALE` = 1_000_000). + multiplier_micros: u64, + /// The small-network handicap applied for the epoch, in DIG base units. + handicap_dig_base_units: u64, + }, + /// The node cannot state the requirement, and names which fact is missing. + Unknown { + /// Which fact the node is missing. + reason: CollateralUnknownReason, + }, +} + +/// `control.collateral.margin.get` / `.set` — the node's LOCAL safety margin. +/// +/// `.set` returns the margin now in force, so a caller never has to re-read to learn what was +/// applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollateralMarginResult { + /// The margin in BASIS POINTS over the requirement (`100` is +1%). + /// + /// The unit is basis points and is never converted, because it is the unit + /// `dig_mirror_collateral::apply_safety_margin` takes and the one dig-app `SPEC.md` §3.7b + /// normatively fixes. A conversion performed independently by two surfaces is a money-path + /// drift bug. + pub margin_bp: u64, +} diff --git a/src/traits.rs b/src/traits.rs index fe7249a..c063ede 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -467,6 +467,63 @@ pub trait ControlHandler: Sync { &self, params: params::SpendsListParams, ) -> Result; + /// `control.collateral.requirement` (TOKEN-GATED) + /// + /// This epoch's derived per-store collateral requirement, with the census inputs behind it. + /// + /// Gated although the figure itself is derivable from chain by anyone: the UNKNOWN branch names + /// this node's own census position, and the caller supplies no identifier, so the answer is a + /// fact about this node rather than a relayed public one. + /// + /// Three obligations, each of which a plausible implementation gets wrong: + /// + /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A node that + /// has not censused the epoch, or that sits inside `CENSUS_FINALITY_DEPTH_BLOCKS` of the tip, + /// MUST return [`Unknown`](results::CollateralRequirementResult::Unknown). It MUST NOT return + /// a zero, a stale epoch's figure presented as this epoch's, or an error that a client would + /// render as "no collateral required" — under-posting costs the operator that epoch's rewards. + /// - **Report the protocol version that COMPUTED the epoch**, not the newest version this build + /// implements. The two differ exactly when a node has upgraded mid-schedule, which is the one + /// case where a client needs to know the difference. + /// - **Never derive the figure from the local safety margin.** The margin MUST NOT reach any + /// value another node derives; `required_per_store_dig_base_units` is the pre-margin + /// requirement, and a node that returned the margined amount here would make its own + /// preference look like the network's price. + async fn collateral_requirement( + &self, + ) -> Result; + + /// `control.collateral.margin.get` (TOKEN-GATED) + /// + /// The node's local safety margin in basis points. + /// + /// A node whose stored configuration predates the field MUST answer + /// [`DEFAULT_SAFETY_MARGIN_BP`](params::DEFAULT_SAFETY_MARGIN_BP), never `0`: a zero margin is a + /// deliberate choice to post the requirement exactly, and reporting it for a config that never + /// expressed one tells the operator they opted out of a cushion they never declined. + async fn collateral_margin_get(&self) -> Result; + + /// `control.collateral.margin.set` (TOKEN-GATED) + /// + /// Persist the node's local safety margin and return the margin now in force. + /// + /// 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 — and dig-app is a remote control for the same value. + /// + /// Two obligations: + /// + /// - **Persist it**, so it survives a restart. A margin that lapses to the default on reboot + /// silently changes what the node posts. + /// - **Return what was actually stored.** The returned `margin_bp` MUST equal the accepted + /// request's, because a value above [`MAX_SAFETY_MARGIN_BP`](params::MAX_SAFETY_MARGIN_BP) is + /// REFUSED rather than clamped. An implementation that clamped and returned the clamped value + /// would leave the caller's stored intent and the node's behaviour disagreeing on the money + /// path. + async fn collateral_margin_set( + &self, + params: params::CollateralMarginSetParams, + ) -> Result; + /// `control.profile.putBody` (TOKEN-GATED) /// /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root @@ -631,6 +688,16 @@ pub trait ControlHandler: Sync { ControlMethod::WalletReservationsRelease => { encode(self.wallet_reservations_release(decode(params)?).await?) } + ControlMethod::CollateralRequirement => encode(self.collateral_requirement().await?), + ControlMethod::CollateralMarginGet => encode(self.collateral_margin_get().await?), + // `CollateralMarginSetParams` derives `Deserialize`, so decoding enforces NOTHING beyond + // the field's type. `validated()` here is the SOLE enforcement of `MAX_SAFETY_MARGIN_BP` + // on this money-path mutation — dropping it admits an unbounded margin, and the margin + // arithmetic saturates rather than failing, so the result is a silently enormous posting. + ControlMethod::CollateralMarginSet => { + let params: params::CollateralMarginSetParams = decode(params)?; + encode(self.collateral_margin_set(params.validated()?).await?) + } // Re-validated here idempotently; deserialization already enforced the same rule. ControlMethod::SpendsList => { let params: params::SpendsListParams = decode(params)?;