From aea2298ff834298b599c2a7fe0db94bfd17b5209 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 04:11:32 -0700 Subject: [PATCH] feat(collateral): declare control.collateral.buffer, the recommended $DIG buffer + funding state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares a SEPARATE collateral method rather than extending `control.collateral.requirement`. The requirement is consensus-derived and identical on every node; the buffer is local — it rests on the pairs THIS node serves, on an operator preference (the safety margin), on unreclaimed collateral, and on a horizon this node chose. 0.23.0 kept the margin out of `.requirement` on exactly that grounds, and folding a local position into a consensus result would make one node's preferences look like the network's price. The payload carries the recommended buffer, the funding state, and the working behind both: pairs served by this node, the pre-margin per-store requirement, the margin in basis points, the unreclaimed transition overlap, the escalation headroom, the spendable balance the verdict was reached against, and the HORIZON plus the compounded worst-case ceiling that headroom assumed. Amounts are DIG base units (3 decimals); a mojo is XCH's unit and is nine orders of magnitude away. The horizon is required on the wire: escalation compounds at up to +12.5% per epoch, so the same buffer over a different horizon is a different claim. `unknown` is a tagged variant with a named reason and no representable number, because a zero here reads as "no buffer needed" — an operator acting on it posts nothing and loses the epoch. The funding state is the node's verdict rather than a threshold each client re-derives; `below_recommended_buffer` is a readout and never a recurring notification. Also excludes AGENTS.md and CLAUDE.md from the published tarball (27 -> 25 entries): a crates.io version is immutable, so 0.23.0 carries them permanently. Closes #35 --- Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 1 + SPEC.md | 77 +++++++++++++++++- src/kats.rs | 213 +++++++++++++++++++++++++++++++++++++++++++++++++ src/method.rs | 8 +- src/params.rs | 37 +++++++++ src/results.rs | 206 +++++++++++++++++++++++++++++++++++++++++++++++ src/traits.rs | 30 +++++++ 9 files changed, 574 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5681f29..87179ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.23.0" +version = "0.24.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index fd0ba21..fbed4c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,13 +13,17 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.23.0" +version = "0.24.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" description = "Canonical client <-> dig-node CONTROL interface contract: the method catalog for controlling/querying a running dig-node (config, status, peers, subscriptions, cache, wallet), transport-agnostic. SSOT so client and node can't drift." repository = "https://github.com/DIG-Network/dig-node-control-interface" readme = "README.md" +# Agent-instruction files are development context, never part of the published contract. A crates.io +# version is immutable, so a file shipped once is shipped forever -- 0.23.0 carries AGENTS.md and +# CLAUDE.md permanently. Ordinary crate tooling (.github, cliff.toml, commitlint) stays in the tarball. +exclude = [".claude", "AGENTS.md", "CLAUDE.md"] keywords = ["dig", "control", "rpc", "node", "jsonrpc"] categories = ["network-programming", "authentication"] diff --git a/README.md b/README.md index 5162c99..9d90568 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ and pushes bytes somebody else signed. | `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.collateral.buffer` | T | own | — | `{state:"known", epoch, protocol_version, 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}` \| `{state:"unknown", reason}`; the $DIG this node recommends HOLDING and its position against that figure. Amounts are DIG base units (3 decimals, `0.001 DIG`), never mojos; `margin_bp` is basis points. A client MUST NOT derive this: `pairs_served_by_this_node` is THIS node's served set, not the census `stores` count, and the overlap term needs reclaim state nothing else exposes. The horizon travels in the payload and is never implied — escalation is capped at +12.5%/epoch and COMPOUNDS (x1.12 at 1, x1.60 at 4, x4.62 at 13) — and `escalation_ceiling_micros` is a worst case, not a forecast. `funding_state` is the node's verdict, not a threshold for a client to re-derive: `short_now` \| `dangerously_low` leave an epoch uncovered, `below_recommended_buffer` is a READOUT and never a recurring notification, `funded` holds the buffer. `unknown` is a first-class answer with a named reason (`requirement_unknown` \| `served_set_unknown` \| `reclaim_state_unknown` \| `balance_unknown`) — NEVER a zero, which here reads as no buffer needed | | `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 afa4b3f..cc1fea2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -131,6 +131,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `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.collateral.buffer` | yes | owned | — | `CollateralBufferResult` (`{state:"known", epoch, protocol_version, 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}` \| `{state:"unknown", reason}`) | | `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}` | @@ -965,9 +966,79 @@ supplies no identifier, so the answer is a fact about this node rather than a re 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. +**The count of stores THIS node holds is not served here.** It is served by +`control.collateral.buffer` (§4.2f) as `pairs_served_by_this_node`, together with the buffer that +figure feeds; duplicating it in the requirement response would create a second source of truth for +a value on the money path. + +### 4.2f The recommended $DIG buffer and the node's funding state + +`control.collateral.buffer` states the $DIG a node recommends HOLDING and where that node stands +against the figure. It is a SEPARATE method from `control.collateral.requirement`, and MUST remain +one: the requirement is consensus-derived and identical on every node, whereas the buffer is LOCAL — +it rests on the `(owner, store, root)` pairs this node serves, on an operator preference (the safety +margin), on this node's unreclaimed collateral, and on a horizon this node chose. Folding it into the +requirement's result would make one node's local position look like a network figure, which is the +same conflation §4.2e forbids for the margin. + +**A client MUST NOT derive this figure.** The buffer is +`pairs_served_by_this_node x required_per_store x (1 + margin)` plus the transition overlap plus the +escalation headroom. The first term is THIS node's served set, and the census `stores` figure returned +by `control.collateral.requirement` is a network-wide advertisement count that MUST NOT be substituted +for it; the overlap term needs reclaim state no other method exposes. A client multiplying the census +count by the requirement produces a confident, badly wrong number on a money surface. The node owns +the calculation for two further reasons: `dign` MUST answer the same question on a headless host where +no notification will ever fire, and two independent derivations of one money figure will disagree. + +**All amounts are in DIG BASE UNITS**, `$DIG` carrying 3 decimals so that one base unit is +`0.001 DIG`. They are NOT mojos — a mojo is XCH's base unit at `1e-12` XCH, nine orders of magnitude +away. `margin_bp` is in BASIS POINTS (`100` is +1%) and MUST NOT be converted, per §4.2e. + +**`recommended_buffer_dig_base_units` is authoritative and the other terms are the working.** A client +MUST render the node's total rather than re-adding the terms and preferring its own sum: the rounding +lives in the node's arithmetic. `pairs_served_by_this_node`, `required_per_store_dig_base_units`, +`margin_bp`, `overlap_dig_base_units` and `escalation_headroom_dig_base_units` exist so a surface can +show WHY the number is what it is; `spendable_dig_base_units` is the balance the verdict was reached +against, and carrying it is what makes `funding_state` checkable rather than merely asserted. + +**The horizon MUST travel in the payload and MUST NOT be implied.** Escalation of the per-store +requirement is bounded at `+1/ESCALATION_UP_STEP_DENOM` (`+12.5%`) per epoch and COMPOUNDS — about +x1.12 at one epoch, x1.60 at four, x4.62 at thirteen — so the same buffer quoted over different +horizons answers different questions and neither can be checked without knowing which. +`horizon_epochs` states how many future epochs the headroom covers and `escalation_ceiling_micros` +states the compounded multiplier assumed, in millionths. A reader MUST reject a `known` payload +missing either rather than defaulting it. `escalation_ceiling_micros` is a WORST CASE and MUST NOT be +presented as a forecast: inside the controller's dead band the multiplier does not move at all. + +**The node decides the funding state; a client MUST NOT re-derive it from thresholds.** Two clients +choosing their own thresholds will disagree, and the one that disagrees about a funding warning is the +one an operator acts on. The four states are: + +| `funding_state` | meaning | +| --- | --- | +| `short_now` | cannot cover the CURRENT epoch; stores this node serves are already going uncollateralised | +| `dangerously_low` | covers now, could not cover the NEXT epoch at the escalation ceiling | +| `below_recommended_buffer` | covers every epoch in the horizon but holds less than the buffer: funded, no cushion | +| `funded` | holds at least the recommended buffer over the stated horizon | + +`short_now` and `dangerously_low` leave an epoch uncovered. **`below_recommended_buffer` is a READOUT +and MUST NOT be raised as a recurring notification**: a healthy node sits there much of the time, and a +client that alerted on it would teach an operator to dismiss the two states that matter. Whether a +state warrants interrupting somebody is otherwise the client's decision; which state the node is in is +the node's. + +**An undeterminable buffer MUST be stated as unknown, with its reason, and MUST NOT be a zero.** On the +requirement a fabricated zero reads as a free requirement; here it reads as *no buffer needed*, and an +operator acting on it posts nothing and loses the epoch. The four reasons — `requirement_unknown`, +`served_set_unknown`, `reclaim_state_unknown`, `balance_unknown` — name DIFFERENT missing facts with +different remedies and MUST NOT be collapsed. `requirement_unknown` deliberately does not restate +§4.2e's taxonomy; a client needing to know WHICH census fact is missing calls +`control.collateral.requirement`. `known` and `unknown` are variants of one tagged union so that no +representable value carries a figure the node was not given. + +**The read is TOKEN-GATED although it is a read.** The caller supplies nothing, so the answer is this +node's own served set, operator preference and balance — an association, not a relayed public fact. It +is NOT an open read (§4.2) and NOT a master-token method (§2.1). **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. diff --git a/src/kats.rs b/src/kats.rs index a93e2f2..221ac1f 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -1635,6 +1635,25 @@ impl ControlHandler for MockNode { }) } + async fn collateral_buffer(&self) -> Result { + // Deliberately NOT the census `stores` figure the requirement mock returns (4_200): the + // pairs this node serves are 12, so a test can tell a served-set answer from a census one. + Ok(results::CollateralBufferResult::Known { + epoch: 7, + protocol_version: 1, + funding_state: results::CollateralFundingState::DangerouslyLow, + recommended_buffer_dig_base_units: 32_400, + spendable_dig_base_units: 14_050, + pairs_served_by_this_node: 12, + required_per_store_dig_base_units: 1_036, + margin_bp: MARGIN_BP.with(|m| *m.borrow()), + overlap_dig_base_units: 3_108, + escalation_headroom_dig_base_units: 7_468, + horizon_epochs: 4, + escalation_ceiling_micros: 1_601_806, + }) + } + async fn spends_list( &self, params: SpendsListParams, @@ -4739,3 +4758,197 @@ fn published_bp_figures(spec: &str, name: &str) -> Vec { }) .collect() } + +// --------------------------------------------------------------------------------------------- +// Collateral: the recommended $DIG buffer + the node's funding state (#35). +// --------------------------------------------------------------------------------------------- + +/// **`control.collateral.buffer` is in the catalog, in the collateral category, shell-owned, and +/// TOKEN-GATED.** +/// +/// Resolved through [`ControlMethod::from_name`] rather than by naming the variant, because +/// `from_name` searches `ControlMethod::ALL`. A variant added to the enum but forgotten in `ALL` is +/// invisible to discovery, to the OpenRPC surface and to every conformance sweep while still +/// compiling everywhere — this fails in exactly that case, and a direct `CollateralBuffer.name()` +/// assertion would not. +/// +/// The auth assertion is the load-bearing half. The caller supplies nothing, so the answer is this +/// node's own served set, operator preference and balance; opening it would disclose that +/// association to any local process. +#[test] +fn the_buffer_method_is_in_the_catalog_categorised_and_gated() { + let m = ControlMethod::from_name("control.collateral.buffer") + .expect("control.collateral.buffer must be reachable through ControlMethod::ALL"); + assert_eq!(m.category(), crate::method::Category::Collateral); + assert_eq!(m.routing(), crate::method::Routing::Owned); + assert!(m.requires_auth(), "the buffer read is token-gated"); + assert!(!m.is_open_read()); + assert!(!m.requires_master_token(), "a read grants nothing lasting"); +} + +/// **The horizon is part of the payload, not an implied default.** +/// +/// The fixture is a complete `known` payload with `horizon_epochs` REMOVED — the nearest wrong +/// implementation, which makes the field optional and lets a reader fall back to +/// [`DEFAULT_BUFFER_HORIZON_EPOCHS`]. That reader would state a claim the node never made: the same +/// buffer over one epoch and over thirteen differ by the whole compounding range (x1.12 vs x4.62). +/// A second removal (`escalation_ceiling_micros`) pins the multiplier for the same reason, and the +/// intact payload is the control that proves the fixture is otherwise decodable. +#[test] +fn a_buffer_without_its_horizon_does_not_decode() { + let full = json!({ + "state": "known", + "epoch": 7, + "protocol_version": 1, + "funding_state": "below_recommended_buffer", + "recommended_buffer_dig_base_units": 32_400, + "spendable_dig_base_units": 40_000, + "pairs_served_by_this_node": 12, + "required_per_store_dig_base_units": 1_036, + "margin_bp": 100, + "overlap_dig_base_units": 3_108, + "escalation_headroom_dig_base_units": 7_468, + "horizon_epochs": 4, + "escalation_ceiling_micros": 1_601_806 + }); + serde_json::from_value::(full.clone()) + .expect("the intact payload must decode -- otherwise the removals below prove nothing"); + + for required in ["horizon_epochs", "escalation_ceiling_micros"] { + let mut stripped = full.as_object().unwrap().clone(); + stripped.remove(required); + assert!( + serde_json::from_value::(Value::Object(stripped)) + .is_err(), + "{required} must be REQUIRED: a buffer read without it cannot be checked by anyone" + ); + } +} + +/// **An `unknown` buffer cannot carry a number, because a zero here reads as NO BUFFER NEEDED.** +/// +/// The assertion is structural rather than about one field's value: every `unknown` payload is +/// serialized and checked to contain no numeric value at all. The nearest wrong implementation is a +/// flat struct with `Option` amounts beside a reason, which serializes `0` (or `null` a client +/// coerces to `0`) on exactly the path where an operator would then post nothing and lose the epoch. +/// Every reason is exercised, so a taxonomy that leaks a number on one branch is caught. +#[test] +fn an_unknown_buffer_carries_a_reason_and_never_a_number() { + for &reason in results::CollateralBufferUnknownReason::ALL { + let json = + serde_json::to_value(results::CollateralBufferResult::Unknown { reason }).unwrap(); + let obj = json.as_object().expect("the unknown answer is an object"); + assert_eq!(obj["state"], "unknown"); + assert_eq!(obj["reason"], reason.as_wire()); + assert_eq!( + obj.len(), + 2, + "an unknown answer carries the tag and the reason and nothing else: {json}" + ); + for (key, value) in obj { + assert!( + !value.is_number() && !value.is_null(), + "`{key}` puts a number-shaped value on an unknown answer: {json}" + ); + } + } + + let tokens: std::collections::BTreeSet<&str> = results::CollateralBufferUnknownReason::ALL + .iter() + .map(|r| r.as_wire()) + .collect(); + assert_eq!( + tokens.len(), + results::CollateralBufferUnknownReason::ALL.len(), + "two reasons sharing a wire token make one of them unreportable" + ); +} + +/// **The funding states are four, their tokens are distinct, and exactly two of them mean an epoch +/// is UNCOVERED.** +/// +/// The shortfall set is written out rather than derived from `is_shortfall`, so this pins the SET +/// and not the implementation's opinion of itself. `below_recommended_buffer` is the one that +/// matters: a healthy node sits there much of the time, and an implementation that folded it in +/// would have every client raising a recurring alert on a normal node — which is how an operator +/// learns to dismiss the two that are real. +#[test] +fn exactly_two_funding_states_mean_an_epoch_is_uncovered() { + let tokens: std::collections::BTreeSet<&str> = results::CollateralFundingState::ALL + .iter() + .map(|s| s.as_wire()) + .collect(); + assert_eq!(tokens.len(), results::CollateralFundingState::ALL.len()); + assert_eq!( + tokens, + [ + "short_now", + "dangerously_low", + "below_recommended_buffer", + "funded" + ] + .into_iter() + .collect() + ); + + let shortfalls: std::collections::BTreeSet<&str> = results::CollateralFundingState::ALL + .iter() + .filter(|s| s.is_shortfall()) + .map(|s| s.as_wire()) + .collect(); + assert_eq!( + shortfalls, + ["short_now", "dangerously_low"].into_iter().collect(), + "below_recommended_buffer is a READOUT: nothing is uncovered there" + ); + + // Each state survives a wire round-trip under its own token, so a client switching on the + // string and one switching on the enum cannot disagree. + for &state in results::CollateralFundingState::ALL { + let back: results::CollateralFundingState = + serde_json::from_value(json!(state.as_wire())).unwrap(); + assert_eq!(back, state); + } +} + +/// **The dispatcher routes the buffer read to its typed handler, and the served-set count that +/// comes back is THIS NODE's — not the census figure.** +/// +/// The mock deliberately answers `12` pairs while its requirement handler answers a census `stores` +/// of `4_200`. Both are u64 counts in the same domain, so a handler wired to the census figure +/// compiles, type-checks and looks plausible; only a fixture in which the two DIFFER can see it. +/// That substitution is the specific "confident, badly wrong number" this method was declared to +/// prevent. +#[test] +fn the_buffer_read_returns_this_nodes_served_set_not_the_census_count() { + let census_stores = match round_trip(&CollateralRequirementParams {}).unwrap() { + results::CollateralRequirementResult::Known { stores, .. } => stores, + results::CollateralRequirementResult::Unknown { .. } => panic!("mock answers known"), + }; + + match round_trip(&CollateralBufferParams {}).unwrap() { + results::CollateralBufferResult::Known { + funding_state, + pairs_served_by_this_node, + horizon_epochs, + recommended_buffer_dig_base_units, + .. + } => { + assert_ne!( + pairs_served_by_this_node, census_stores, + "the served set must not be the network-wide advertisement count" + ); + assert_eq!(pairs_served_by_this_node, 12); + assert_eq!( + funding_state, + results::CollateralFundingState::DangerouslyLow + ); + assert!(funding_state.is_shortfall()); + assert_eq!(horizon_epochs, 4); + assert!(recommended_buffer_dig_base_units > 0); + } + results::CollateralBufferResult::Unknown { reason } => { + panic!("the mock states a buffer, got unknown: {reason:?}") + } + } +} diff --git a/src/method.rs b/src/method.rs index 3fb50ce..fa71346 100644 --- a/src/method.rs +++ b/src/method.rs @@ -219,6 +219,8 @@ pub enum ControlMethod { CollateralMarginGet, /// `control.collateral.margin.set` -- set the node's local safety margin, in basis points. CollateralMarginSet, + /// `control.collateral.buffer` -- the $DIG this node recommends holding, and its funding state. + CollateralBuffer, // ---- dig-profile bodies (delegated to the engine) ---- /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to. @@ -288,6 +290,7 @@ impl ControlMethod { ControlMethod::CollateralRequirement => "control.collateral.requirement", ControlMethod::CollateralMarginGet => "control.collateral.margin.get", ControlMethod::CollateralMarginSet => "control.collateral.margin.set", + ControlMethod::CollateralBuffer => "control.collateral.buffer", ControlMethod::ProfilePutBody => "control.profile.putBody", ControlMethod::ProfileGetBody => "control.profile.getBody", ControlMethod::PairingRequest => "pairing.request", @@ -520,7 +523,8 @@ impl ControlMethod { ControlMethod::SpendsList => Category::Spends, ControlMethod::CollateralRequirement | ControlMethod::CollateralMarginGet - | ControlMethod::CollateralMarginSet => Category::Collateral, + | ControlMethod::CollateralMarginSet + | ControlMethod::CollateralBuffer => Category::Collateral, ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile, } } @@ -581,6 +585,7 @@ impl ControlMethod { ControlMethod::WalletReservationsHeld => "READ-only: every coin currently committed to an in-flight spend, each with the reservation holding it and the unix second that hold lapses, plus the node's own clock. `reserved: []` means NOTHING is held; a set that cannot be read is an error, never an empty list. Narrows what a caller may SELECT; never subtract these from a balance -- the coins are still the user's money. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN state.", ControlMethod::WalletReservationsReserve => "Atomically hold coins against further selection: EVERY named coin or none. A coin already held refuses the whole call and reserves nothing, as WALLET_COINS_RESERVED -- a WAIT, never a shortfall. Reserving an empty list succeeds with a handle that releases nothing. The requested ttl_secs is clamped by the node, which returns the lifetime it actually applied. Bookkeeping only: it holds no key and authorizes nothing (§908). TOKEN-GATED.", ControlMethod::WalletReservationsRelease => "Free a hold now rather than waiting out its TTL -- call it the moment a spend is known settled or known dead. A handle that names no live reservation is a SUCCESS with released: false, because a caller releasing on confirmation cannot know whether the TTL got there first. Every hold also lapses on its own, so an abandoned reservation is recoverable and never a permanent funds lockout. TOKEN-GATED.", + ControlMethod::CollateralBuffer => "READ-only: the $DIG this node recommends HOLDING, in DIG base units, and the funding state it is in -- plus the working behind the figure: the (owner, store, root) pairs THIS NODE serves, the epoch's pre-margin per-store requirement, the local margin in force (BASIS POINTS, `100` is +1%, never converted), the unreclaimed transition overlap, and the escalation headroom. Amounts are DIG base units (3 decimals, one base unit is 0.001 DIG) and never mojos, which are XCH's 1e-12 unit. The HORIZON the headroom assumed travels in the payload and is never implied: escalation is bounded at +12.5% per epoch and COMPOUNDS (x1.12 at one epoch, x1.60 at four, x4.62 at thirteen), so the same buffer over a different horizon is a different claim; `escalation_ceiling_micros` is a WORST CASE, not a forecast -- in the dead band the multiplier does not move. The FUNDING STATE is carried rather than left to each client to re-derive from thresholds, because two clients deriving it will disagree and the one that disagrees about a funding warning is the one an operator acts on; `short_now` and `dangerously_low` leave an epoch uncovered, `below_recommended_buffer` covers every epoch with no cushion and is a READOUT, never a notification. A node that cannot enumerate its served set, cannot read its reclaim state, cannot see its balance, or has no requirement to scale answers `unknown` WITH the reason -- never a zero, which here reads as NO BUFFER NEEDED and would have an operator post nothing. It is a SEPARATE method from `control.collateral.requirement` because that figure is consensus-derived while this one is local: it depends on this node's own served set, an operator preference, and a horizon this node chose. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN served set, preference and balance.", ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.", ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.", } @@ -640,6 +645,7 @@ impl ControlMethod { ControlMethod::CollateralRequirement, ControlMethod::CollateralMarginGet, ControlMethod::CollateralMarginSet, + ControlMethod::CollateralBuffer, ControlMethod::ProfilePutBody, ControlMethod::ProfileGetBody, ControlMethod::PairingRequest, diff --git a/src/params.rs b/src/params.rs index 04a94b8..585dbba 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1796,6 +1796,32 @@ no_params!( CollateralRequirementParams => ControlMethod::CollateralRequirement, results::CollateralRequirementResult ); +/// The horizon, in future epochs, a node covers when it recommends a buffer without being told +/// otherwise — `4`. +/// +/// Published on the contract so a client can recognise the ordinary case, NOT so it can assume one: +/// the horizon a node actually used always travels in +/// [`CollateralBufferResult::Known`](crate::results::CollateralBufferResult::Known), and a reader +/// that substituted this constant for a payload it failed to read would state a claim the node +/// never made. +/// +/// Four epochs bounds the compounded escalation ceiling at roughly x1.60. Fewer leaves a node one +/// bad epoch from `dangerously_low`; many more prices in a x4.62 worst case the controller reaches +/// only by escalating every single epoch, and locking $DIG against it has a real opportunity cost. +pub const DEFAULT_BUFFER_HORIZON_EPOCHS: u32 = 4; + +/// The per-epoch escalation step denominator — `8`, i.e. the requirement can rise by at most +/// `+1/8` (`+12.5%`) in one epoch. +/// +/// The same value as `dig_mirror_collateral::UP_STEP_DENOM`, restated here for the same reason +/// [`DEFAULT_SAFETY_MARGIN_BP`] is: `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 a CEILING on one epoch's rise, and it compounds across epochs — which is precisely why the +/// horizon must travel with any buffer derived from it. It is not a forecast: inside the +/// controller's dead band the requirement does not move at all. +pub const ESCALATION_UP_STEP_DENOM: u64 = 8; + no_params!( /// `control.collateral.margin.get` params (none). CollateralMarginGetParams => ControlMethod::CollateralMarginGet, @@ -1839,3 +1865,14 @@ impl CollateralMarginSetParams { } } control_call!(CollateralMarginSetParams => ControlMethod::CollateralMarginSet, results::CollateralMarginResult); + +no_params!( + /// `control.collateral.buffer` params (none). + /// + /// The caller names neither an epoch nor a horizon. Both are the NODE's: the served set and + /// the reclaim state the buffer rests on exist only for the epoch being posted against, and a + /// caller-chosen horizon would let a client quietly shrink the recommendation on a money + /// surface by asking for a shorter one. The horizon the node used is returned instead. + CollateralBufferParams => ControlMethod::CollateralBuffer, + results::CollateralBufferResult +); diff --git a/src/results.rs b/src/results.rs index 574e790..ef5d28a 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2904,6 +2904,212 @@ pub enum CollateralRequirementResult { }, } +/// The node's funding position against its own recommended $DIG buffer. +/// +/// **The state is carried, never re-derived by each client.** Every field needed to compute it does +/// travel in [`CollateralBufferResult::Known`], so a client COULD compare numbers itself — and two +/// clients that did would pick their own thresholds and disagree. The one that disagreed about a +/// funding warning is the one an operator would act on, so which state this node is in is the +/// node's answer, not a rendering decision. +/// +/// **Whether a state is worth interrupting somebody over is the CLIENT's decision; this enum states +/// only what is true.** [`is_shortfall`](CollateralFundingState::is_shortfall) names the two states +/// in which some epoch is not covered. [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) is +/// deliberately not one of them: a healthy node sits there much of the time, and a client that +/// raised a recurring alert for it would teach an operator to dismiss the two that matter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollateralFundingState { + /// Cannot cover the CURRENT epoch: stores this node serves are already going uncollateralised. + ShortNow, + /// Covers the current epoch but could not cover the NEXT one if the requirement rose to the + /// escalation ceiling. The rise is a bound, not a prediction — see + /// `escalation_ceiling_micros` — so this state says the node has no room for the worst case, + /// not that the worst case is coming. + DangerouslyLow, + /// Covers several epochs at the ceiling but holds less than the recommended buffer: funded, with + /// no cushion. A READOUT, never a notification. + BelowRecommendedBuffer, + /// Holds at least the recommended buffer over the stated horizon. + Funded, +} + +impl CollateralFundingState { + /// Every state, for exhaustive rendering and for the wire-token uniqueness KAT. + pub const ALL: &'static [CollateralFundingState] = &[ + CollateralFundingState::ShortNow, + CollateralFundingState::DangerouslyLow, + CollateralFundingState::BelowRecommendedBuffer, + CollateralFundingState::Funded, + ]; + + /// The stable snake_case wire token, matching the `funding_state` field. + pub const fn as_wire(self) -> &'static str { + match self { + CollateralFundingState::ShortNow => "short_now", + CollateralFundingState::DangerouslyLow => "dangerously_low", + CollateralFundingState::BelowRecommendedBuffer => "below_recommended_buffer", + CollateralFundingState::Funded => "funded", + } + } + + /// Is some epoch actually UNCOVERED — now, or next at the escalation ceiling? + /// + /// A statement about the world, not about a client's UI. It is the honest input to a client's + /// own decision about what deserves an interruption, and it excludes + /// [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) because nothing is uncovered there. + pub const fn is_shortfall(self) -> bool { + matches!( + self, + CollateralFundingState::ShortNow | CollateralFundingState::DangerouslyLow + ) + } +} + +/// Why a node cannot state its recommended buffer or its funding position. +/// +/// Separate from [`CollateralUnknownReason`] because the buffer needs three facts the epoch +/// requirement does not, and each has a different remedy: a node missing its served set needs its +/// hosted-store view, one missing reclaim state needs its transition bookkeeping, and one missing +/// its balance needs a chain source. Collapsing them into the requirement's reasons would answer +/// every one of those with "not censused", which is both false and unactionable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollateralBufferUnknownReason { + /// The epoch requirement itself is unknown, so every term scaled by it is unknown too. Call + /// `control.collateral.requirement` for WHICH fact is missing — this variant deliberately does + /// not restate that taxonomy, because a copy of it here would drift from the original. + RequirementUnknown, + /// The node cannot enumerate the `(owner, store, root)` pairs IT serves. Nothing may be + /// substituted for this: the census `stores` figure counts network-wide advertisements and is + /// not a count of this node's own set. + ServedSetUnknown, + /// The node cannot read how much collateral is still locked against positions it has not yet + /// reclaimed, so the transition-overlap term is unknown. + ReclaimStateUnknown, + /// The node cannot read its own spendable $DIG, so it can state a buffer but not a position + /// against it. + BalanceUnknown, +} + +impl CollateralBufferUnknownReason { + /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT. + pub const ALL: &'static [CollateralBufferUnknownReason] = &[ + CollateralBufferUnknownReason::RequirementUnknown, + CollateralBufferUnknownReason::ServedSetUnknown, + CollateralBufferUnknownReason::ReclaimStateUnknown, + CollateralBufferUnknownReason::BalanceUnknown, + ]; + + /// The stable snake_case wire token, matching the `reason` field. + pub const fn as_wire(self) -> &'static str { + match self { + CollateralBufferUnknownReason::RequirementUnknown => "requirement_unknown", + CollateralBufferUnknownReason::ServedSetUnknown => "served_set_unknown", + CollateralBufferUnknownReason::ReclaimStateUnknown => "reclaim_state_unknown", + CollateralBufferUnknownReason::BalanceUnknown => "balance_unknown", + } + } +} + +/// `control.collateral.buffer` — the $DIG this node recommends holding, and where it stands against +/// that figure. +/// +/// **Every amount here is in DIG BASE UNITS.** $DIG carries 3 decimals, so one base unit is +/// `0.001 DIG`. It is NOT a mojo: a mojo is XCH's base unit at `1e-12` XCH, nine orders of magnitude +/// away. `margin_bp` is the one field that is not an amount and is in BASIS POINTS (`100` is `+1%`), +/// the unit the collateral crate's own presets and rounding use, never converted. +/// +/// **UNKNOWN is a first-class answer, and a zero here is the money lie in its purest form.** On +/// `control.collateral.requirement` a fabricated zero reads as a free requirement; here it reads as +/// *no buffer needed*, which is worse, because an operator acting on it would post nothing and lose +/// the epoch. A node that cannot enumerate the pairs it serves, cannot read its reclaim state, or +/// cannot see its balance says so WITH the reason — the tagged variant means there is no +/// representable state in which a client holds a figure it was never given. +/// +/// **The horizon travels with the buffer, because a buffer without one is a magic number.** The +/// escalation of the per-store requirement is bounded at `+12.5%` per epoch and COMPOUNDS: about +/// x1.12 at one epoch, x1.60 at four, x4.62 at thirteen. Two nodes quoting a buffer over different +/// horizons are answering different questions, and neither figure can be checked without knowing +/// which. `escalation_ceiling_micros` states the multiplier this node assumed, and it is a WORST +/// CASE, not a forecast: inside the controller's dead band the multiplier does not move at all. +/// +/// **The total is authoritative; the terms are the working.** `recommended_buffer_dig_base_units` +/// is the figure to hold and the figure `funding_state` was decided against. The other fields exist +/// so a client can show WHY that number is what it is — which is the difference between a figure an +/// operator can weigh and one they can only accept — and a client MUST NOT re-add them and prefer +/// its own sum, because rounding lives in the node's arithmetic, not the client's. +/// +/// **Why this is not part of [`CollateralRequirementResult`].** The requirement is consensus-derived +/// and every node derives it identically; the buffer is LOCAL — it depends on the pairs this +/// particular node serves, on an operator preference (the margin), and on a horizon this node chose. +/// 0.23.0 kept the margin out of the requirement on exactly that grounds, and the same reasoning +/// binds harder here, because a buffer folded into the requirement's result would make one node's +/// preferences look like the network's price. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum CollateralBufferResult { + /// The node can state its buffer and its position against it. + Known { + /// The epoch the underlying requirement governs, one-based. + epoch: u64, + /// The collateral protocol version that COMPUTED that epoch, carried for the same reason + /// [`CollateralRequirementResult::Known`] carries it: a client holding only numbers cannot + /// tell a disagreement from a rule change. + protocol_version: u16, + /// Where this node stands against `recommended_buffer_dig_base_units`. + funding_state: CollateralFundingState, + /// The $DIG this node recommends holding, in DIG base units. The authoritative figure. + recommended_buffer_dig_base_units: u64, + /// The spendable $DIG the node compared against the buffer, in DIG base units. + /// + /// Carried so `funding_state` is checkable rather than merely assertive: a client can show + /// the two numbers the verdict came from. It is what is SPENDABLE — collateral already + /// locked is not in it. + spendable_dig_base_units: u64, + /// Qualifying `(owner, store, root)` pairs THIS NODE serves. + /// + /// This node's own set, never the census `stores` count, which is a network-wide + /// advertisement count. Multiplying the census figure by the requirement is the confident, + /// badly wrong number this field exists to prevent. + pairs_served_by_this_node: u64, + /// The epoch's per-store requirement, in DIG base units, BEFORE any local safety margin — + /// the same value `control.collateral.requirement` returns. + required_per_store_dig_base_units: u64, + /// The local safety margin in force, in BASIS POINTS (`100` is `+1%`). + margin_bp: u64, + /// Collateral still locked against positions this node has not yet reclaimed, in DIG base + /// units. + /// + /// A transition overlap: during the changeover the node must be able to cover the new + /// epoch while the previous epoch's posting is not yet back. It is NOT derivable from any + /// other field here, which is why a node that cannot read its reclaim state answers + /// [`ReclaimStateUnknown`](CollateralBufferUnknownReason::ReclaimStateUnknown) rather than + /// omitting the term. + overlap_dig_base_units: u64, + /// The headroom included for the requirement escalating over `horizon_epochs`, in DIG base + /// units. Also not derivable client-side, because it depends on the horizon and ceiling + /// this node chose. + escalation_headroom_dig_base_units: u64, + /// How many future epochs the headroom covers. Never implied, never defaulted by a reader: + /// the same buffer over a different horizon is a different claim. + horizon_epochs: u32, + /// The compounded WORST-CASE escalation multiplier assumed over `horizon_epochs`, in + /// millionths (`1_000_000` is x1.0). + /// + /// A ceiling, not a forecast. Escalation is capped at `+12.5%` per epoch, so four epochs + /// bound at roughly `1_601_806` (x1.60); in the dead band the multiplier does not move at + /// all and the realised figure is `1_000_000`. A surface presenting this as an expectation + /// would tell an operator to hold money for a rise the controller may never make. + escalation_ceiling_micros: u64, + }, + /// The node cannot state the buffer, and names which fact is missing. + Unknown { + /// Which fact the node is missing. + reason: CollateralBufferUnknownReason, + }, +} + /// `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 diff --git a/src/traits.rs b/src/traits.rs index c063ede..7667c55 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -524,6 +524,35 @@ pub trait ControlHandler: Sync { params: params::CollateralMarginSetParams, ) -> Result; + /// `control.collateral.buffer` (TOKEN-GATED) + /// + /// The $DIG this node recommends holding, and its funding position against that figure. + /// + /// Gated although it is a read, for the same reason `control.wallet.watched` is: the caller + /// supplies nothing, so the answer is this node's OWN served set, operator preference and + /// balance — an association, not a relayed public fact. + /// + /// Four obligations, each of which a plausible implementation gets wrong: + /// + /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A zero here + /// reads as *no buffer needed* — the money lie in its purest form, because an operator acting + /// on it posts nothing and loses the epoch. An implementation MUST NOT substitute a zero, a + /// previous epoch's buffer presented as this one's, or an error a client renders as "nothing + /// required". + /// - **Count the pairs THIS NODE serves.** `pairs_served_by_this_node` is this node's own + /// `(owner, store, root)` set. The census `stores` figure from + /// `control.collateral.requirement` is a network-wide advertisement count and MUST NOT be + /// substituted for it; a node that cannot enumerate its own set answers + /// [`ServedSetUnknown`](results::CollateralBufferUnknownReason::ServedSetUnknown). + /// - **State the horizon actually used.** `horizon_epochs` and `escalation_ceiling_micros` MUST + /// describe the headroom this answer contains, not a documented default. Escalation compounds + /// at up to +12.5% per epoch ([`ESCALATION_UP_STEP_DENOM`](params::ESCALATION_UP_STEP_DENOM)), so a + /// buffer quoted against an unstated horizon cannot be checked by anyone. + /// - **Decide the funding state here, once.** `funding_state` is the node's verdict, not a hint; + /// an implementation that returned a placeholder and left clients to threshold the numbers + /// themselves recreates the rival derivations this method exists to prevent. + async fn collateral_buffer(&self) -> Result; + /// `control.profile.putBody` (TOKEN-GATED) /// /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root @@ -689,6 +718,7 @@ pub trait ControlHandler: Sync { encode(self.wallet_reservations_release(decode(params)?).await?) } ControlMethod::CollateralRequirement => encode(self.collateral_requirement().await?), + ControlMethod::CollateralBuffer => encode(self.collateral_buffer().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`