diff --git a/Cargo.lock b/Cargo.lock index b0dfe34..9ab3077 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.25.0" +version = "0.26.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 68d0fc3..3adfa62 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.25.0" +version = "0.26.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index 9d90568..c15755c 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ and pushes bytes somebody else signed. | `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.mirror.bondStates` | T | own | `{after?:{store_id,root}, limit?:u32}` | `{state:"known", entries:[{store_id, root, bond_state, …}], complete, cursor, locked_dig_base_units, epoch}` \| `{state:"unknown", reason}`; the per-`(store, root)` mirror bond state and the $DIG those bonds LOCK. Seven states, six of which mean *no coin yet* for different reasons: `bonded` (coin id, epoch and the amount THAT COIN locks, read from the coin) \| `pending` (submitted, unconfirmed) \| `unfunded` (`short_dig_base_units`; the ONLY genuine out-of-funds state) \| `deferred` (the requirement is unknown so no create can be priced — the wallet may be full) \| `withheld` (`Relayed` provenance: held, deliberately never advertised) \| `disabled` (collateralisation switched off node-wide) \| `reclaiming` (a live coin whose money is STILL LOCKED). Conflating `unfunded` with `withheld` or `disabled` is what produces hourly funding alarms about a healthy node. Amounts are DIG base units (3 decimals, `0.001 DIG`), never mojos. `locked_dig_base_units` is the WHOLE-SET total including reclaiming coins — a client MUST NOT sum the page. `unknown` is a first-class answer for the WHOLE call with a named reason (`served_set_unknown` \| `chain_unreadable` \| `in_flight_unknown` \| `provenance_unknown`, the answer a node that cannot see `Relayed` provenance MUST give instead of a short `known` page); there is no per-row unknown and no empty-list fallback. Paged ascending `(store_id, root)` over LOWERCASE unprefixed 64-hex, a `0x`-prefixed or otherwise malformed `after` REFUSED rather than restarted; `complete` and `cursor` are REQUIRED keys and an absent one MUST NOT be defaulted | | `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 4f7d929..fbe9470 100644 --- a/SPEC.md +++ b/SPEC.md @@ -132,6 +132,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `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.mirror.bondStates` | yes | owned | `{after?:{store_id,root}, limit?:u32}` | `MirrorBondStatesResult` (`{state:"known", entries:[{store_id, root, bond_state, …}], complete, cursor, locked_dig_base_units, epoch}` \| `{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}` | @@ -1076,6 +1077,93 @@ 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. +### 4.2g The per-`(store, root)` mirror bond state (`control.mirror.bondStates`) + +`control.mirror.bondStates` states, for every mirror bond this node holds, whether it is bonded and — +when it is not — WHY, together with the $DIG those bonds have locked. It is the surface dig-node +`SPEC.md` §25.8 requires, and it is served over the control plane before the node adopts it +(release-first). + +**Its whole purpose is that "no coin yet" is never one answer.** Six of the seven states mean there is +no current-epoch coin, and each calls for a different response from a person. A client MUST NOT +collapse any two of them; conflating "out of funds" with "withheld on purpose" produces hourly funding +alarms about a perfectly healthy node, which is the defect this method exists to remove. + +| `bond_state` | payload | means | remedy | +| --- | --- | --- | --- | +| `bonded` | `coin_id`, `epoch`, `amount_dig_base_units` | a coin for this pair and epoch is on chain | none | +| `pending` | — | a create is submitted and unconfirmed | wait | +| `unfunded` | `short_dig_base_units` | the wallet cannot cover this create | add $DIG | +| `deferred` | `reason` | the epoch requirement is unknown, so no create can be PRICED | none; the wallet may be full | +| `withheld` | — | the capsule has `Relayed` provenance: held, deliberately never advertised | none | +| `disabled` | — | collateralisation is switched OFF for this node | the operator's own switch | +| `reclaiming` | `coin_id`, `epoch`, `amount_dig_base_units` | a live coin is being reclaimed; the money is STILL LOCKED | wait | + +**`unfunded` is the ONLY state a client may raise a funding alarm on.** `deferred` in particular is not +one: the node does not know the price, and an operator sending money in response changes nothing. + +**`withheld`, `disabled` and `reclaiming` are three different states and this specification names them +apart deliberately.** dig-node's internal `BondState` used `Withheld` for the node-wide switch, while +dig-node `SPEC.md` §25.8 used the same word for `Relayed` provenance. They differ in SCOPE — one switch +for the node, versus one capsule's provenance — and in REMEDY, so an implementation MUST NOT serve one +under the other's name. In this contract `withheld` carries §25.8's meaning, `disabled` is the switch, +and `reclaiming` is §25.8's seventh state, which `BondState` had no variant for even though its money +is still locked. Consequently dig-node MUST rename `BondState::Withheld` to `Disabled` and add +`Withheld` and `Reclaiming`, and dig-node `SPEC.md` §25.8 MUST gain `disabled`. + +**A node that enumerates only its desired-bond set can never emit `withheld`.** A `Relayed` capsule is +by construction absent from the `Held` set, so such an implementation answers "no such row" where this +contract promises "withheld on purpose". An implementation MUST enumerate the SERVED set, and one that +cannot MUST say so rather than report the state as satisfied. **Saying so has exactly one spelling: +`{state:"unknown", reason:"provenance_unknown"}` for the WHOLE call.** An implementation that cannot +determine a held capsule's provenance MUST answer that, and MUST NOT return a `known` page — a page +whose withheld rows are silently absent asserts, via `complete`, a completeness the node knows it does +not have. `provenance_unknown` is the only reason of the four that is not an infrastructure failure, +and it exists so that this limitation is REPORTABLE: without it a provenance-blind node has no +conforming answer at all, and `withheld` would be a state a conformance list could tick while no +surface could ever emit it. + +**"No bond" and "cannot tell" are answers at DIFFERENT levels.** Every per-row state is a definite +statement. A node that cannot enumerate its bonds, cannot read chain, or cannot read its own in-flight +creates, or cannot determine the provenance of what it holds, MUST answer `{state:"unknown", reason}` +for the WHOLE call — `served_set_unknown`, `chain_unreadable`, `in_flight_unknown` or +`provenance_unknown` — and MUST NOT degrade individual rows or return a shorter +list. There is deliberately no per-row unknown: a truncated list and a complete one read the same, and +the rows a broken read would drop are exactly the bonds nobody is then watching. `entries: []` with +`complete: true` is an ANSWER — this node holds no mirror bonds — and MUST NOT be returned for a fact +the node could not read. The epoch requirement being unknown is NOT one of these reasons; it is the +definite per-row state `deferred`. + +**All amounts are in DIG BASE UNITS**, `$DIG` carrying 3 decimals so 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. A `bonded` or +`reclaiming` amount MUST be read FROM THE COIN and never from this epoch's requirement: a coin created +under a previous requirement locks the previous amount. + +**`locked_dig_base_units` covers the WHOLE bond set, including reclaiming coins, and a client MUST NOT +sum the page instead.** A page sum under-reports the locked total by exactly one page boundary and +shows unspendable money as available. It is the figure a locked-total surface reads. + +**The read is PAGED and says so.** Rows come in ascending `(store_id, root)`, an order an +implementation MUST keep stable across the pages of one walk. Both halves of the key are LOWERCASE +64-hex characters, unprefixed, on the wire and in `after`; the order is defined over that canonical +spelling, because uppercase and lowercase hex order differently as strings and two producers spelling +it differently would disagree about what `after` names. A `0x` prefix is TOLERATED on input to `after` +and normalized away; it is never emitted. Any other spelling of either half of `after` MUST be REFUSED +as `-32602 INVALID_PARAMS` and MUST NOT be treated as an absent cursor: a `0x`-prefixed key sorts +before every canonical one, so a node that ignored it would silently RESTART the walk while appearing +to resume, and a repeated page inflates a total a client is accumulating. `complete` states whether the page is the +whole set and MUST NOT be inferred from the page's length. `cursor` is the key of the LAST row actually +handed back, or `null` for an empty page; both keys are REQUIRED on the wire and a reader MUST reject a +payload missing either rather than defaulting it. `after` means *strictly after this key in that +order*, and it names BOTH halves of the key: resuming by `store_id` alone would drop every remaining +root of the store the boundary fell inside. `limit` is bounded by `MIRROR_BOND_STATES_MAX_LIMIT` and an +out-of-range value is REFUSED as `-32602 INVALID_PARAMS` rather than clamped, so the caller's model of +the page and the node's stay identical. + +**The read is TOKEN-GATED although it is a read.** The caller supplies nothing, so the answer is this +node's own bond set and funding position — an association, not a relayed public fact. It is NOT an open +read (§4.2) and NOT a master-token method (§2.1). + ### 4.3 The custody boundary (§908) The node holds no user key and produces no signature. `control.wallet.broadcast` carries signed bytes diff --git a/src/kats.rs b/src/kats.rs index b8662eb..73f8dab 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -1646,6 +1646,43 @@ impl ControlHandler for MockNode { }) } + async fn mirror_bond_states( + &self, + params: crate::params::MirrorBondStatesParams, + ) -> Result { + // Two roots of the SAME store in DIFFERENT states, so a dispatch test cannot pass against + // an implementation that answers per store rather than per (store, root). + let entries = vec![ + results::MirrorBondEntry { + store_id: STORE.into(), + root: ROOT.into(), + state: results::MirrorBondState::Bonded { + coin_id: BOND_COIN_A.into(), + epoch: 7, + amount_dig_base_units: 1_047, + }, + }, + results::MirrorBondEntry { + store_id: STORE.into(), + root: BOND_ROOT_B.into(), + state: results::MirrorBondState::Unfunded { + short_dig_base_units: 1_047, + }, + }, + ]; + let _ = params.effective_limit(); + Ok(results::MirrorBondStatesResult::Known { + cursor: Some(results::MirrorBondKey { + store_id: STORE.into(), + root: BOND_ROOT_B.into(), + }), + entries, + complete: true, + locked_dig_base_units: 1_047, + epoch: 7, + }) + } + async fn collateral_margin_get(&self) -> Result { Ok(results::CollateralMarginResult { margin_bp: MARGIN_BP.with(|m| *m.borrow()), @@ -5147,3 +5184,479 @@ fn the_buffer_read_returns_this_nodes_served_set_not_the_census_count() { } } } + +// --------------------------------------------------------------------------- +// Mirror bonds (#38): the per-`(store, root)` bond state surface. +// --------------------------------------------------------------------------- + +/// A second store, so a fixture can carry two DIFFERENT bonds rather than one repeated. +const BOND_STORE_B: &str = "a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2"; +/// A second root under [`STORE`], so a fixture can show the SAME store at two roots in two +/// different states — the case a store-keyed surface would silently merge. +const BOND_ROOT_B: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; +const BOND_COIN_A: &str = "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1"; +const BOND_COIN_B: &str = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"; + +/// **`control.mirror.bondStates` carries its exact wire name, category, routing and auth tier.** +/// +/// TOKEN-GATED although it is a read, for the same reason `control.spends.list` is: the caller +/// names nothing, so the answer is this node's OWN bond set and funding position. Asserted against +/// the open-surface predicate rather than by reading the summary, so widening the open set by +/// analogy trips here. +#[test] +fn the_bond_state_method_is_named_categorised_and_gated() { + assert_eq!( + ControlMethod::MirrorBondStates.name(), + "control.mirror.bondStates" + ); + assert_eq!( + ControlMethod::from_name("control.mirror.bondStates"), + Some(ControlMethod::MirrorBondStates) + ); + assert_eq!( + ControlMethod::MirrorBondStates.category(), + crate::method::Category::Collateral + ); + assert!(ControlMethod::MirrorBondStates.requires_auth()); + assert!(!ControlMethod::MirrorBondStates.is_open_read()); + assert!(!ControlMethod::MirrorBondStates.requires_master_token()); + assert!(ControlMethod::ALL.contains(&ControlMethod::MirrorBondStates)); +} + +/// **The seven bond states are seven DISTINCT wire tokens, and each pins its own payload.** +/// +/// One vector per state, each on a different `(store, root)` — including two roots of the SAME +/// store in different states, which is the case a store-keyed surface would merge into one +/// misleading row. Byte-stable in both directions, so a renamed token or a dropped payload field +/// fails here rather than at a client six weeks later. +/// +/// `bonded` and `reclaiming` deliberately carry DIFFERENT amounts from each other and from any +/// requirement in this file: the amount is read from the coin, so a fixture reusing one number +/// could not tell a coin-read implementation from one substituting today's price. +#[test] +fn golden_bond_state_vectors_pin_every_state() { + let rows = json!([ + { + "store_id": STORE, "root": ROOT, "bond_state": "bonded", + "coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64 + }, + { + "store_id": STORE, "root": BOND_ROOT_B, "bond_state": "unfunded", + "short_dig_base_units": 1_047u64 + }, + {"store_id": BOND_STORE_B, "root": ROOT, "bond_state": "pending"}, + { + "store_id": BOND_STORE_B, "root": BOND_ROOT_B, "bond_state": "deferred", + "reason": "not_censused" + }, + ]); + + assert_result_round_trips::(json!({ + "state": "known", + "entries": rows, + "complete": false, + "cursor": {"store_id": BOND_STORE_B, "root": BOND_ROOT_B}, + "locked_dig_base_units": 3_094u64, + "epoch": 7u64, + })); + + // The three states with no payload, plus `reclaiming`, which HAS one because its money is + // still locked. + assert_result_round_trips::(json!({ + "state": "known", + "entries": [ + {"store_id": STORE, "root": ROOT, "bond_state": "withheld"}, + {"store_id": STORE, "root": BOND_ROOT_B, "bond_state": "disabled"}, + { + "store_id": BOND_STORE_B, "root": ROOT, "bond_state": "reclaiming", + "coin_id": BOND_COIN_B, "epoch": 6u64, "amount_dig_base_units": 2_047u64 + }, + ], + "complete": true, + "cursor": {"store_id": BOND_STORE_B, "root": ROOT}, + "locked_dig_base_units": 2_047u64, + "epoch": 7u64, + })); + + for reason in results::MirrorBondStatesUnknownReason::ALL { + assert_result_round_trips::(json!({ + "state": "unknown", + "reason": reason.as_wire(), + })); + } +} + +/// **Every wire token in this surface is unique, and the tokens are the contract.** +/// +/// Asserted over `ALL` rather than over a hand-written list, so a variant added without a token — +/// or with a token that collides — fails here. A collision would silently merge two states that +/// this method exists to keep apart. +#[test] +fn the_bond_surface_wire_tokens_are_unique() { + let reasons: Vec<&str> = results::MirrorBondStatesUnknownReason::ALL + .iter() + .map(|r| r.as_wire()) + .collect(); + let mut sorted = reasons.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), reasons.len(), "unknown-reason tokens collide"); + + // The bond-state tokens come from serde, so they are read back off the wire rather than off a + // second list that could agree with itself while disagreeing with the encoder. + let states = [ + results::MirrorBondState::Bonded { + coin_id: BOND_COIN_A.into(), + epoch: 7, + amount_dig_base_units: 1_047, + }, + results::MirrorBondState::Pending, + results::MirrorBondState::Unfunded { + short_dig_base_units: 1_047, + }, + results::MirrorBondState::Deferred { + reason: results::CollateralUnknownReason::NoChainSource, + }, + results::MirrorBondState::Withheld, + results::MirrorBondState::Disabled, + results::MirrorBondState::Reclaiming { + coin_id: BOND_COIN_B.into(), + epoch: 6, + amount_dig_base_units: 2_047, + }, + ]; + let mut tokens: Vec = states + .iter() + .map(|s| { + serde_json::to_value(s).unwrap()["bond_state"] + .as_str() + .expect("every state carries a bond_state token") + .to_owned() + }) + .collect(); + assert_eq!(tokens.len(), 7, "all seven states must be represented"); + tokens.sort(); + tokens.dedup(); + assert_eq!(tokens.len(), 7, "bond-state tokens collide"); +} + +/// **An ABSENT `cursor` or `complete` must FAIL to decode — neither may collapse into a value.** +/// +/// This is the whole reason `cursor` carries `deserialize_with = "required_option"` and `complete` +/// carries no `default`. An absent `cursor` decoding to `null` would read as *there was nothing to +/// resume from*, ending a walk at whatever the truncation left; an absent `complete` defaulting to +/// `false` merely costs a redundant request, but defaulting it at all would let a payload that +/// never said so assert a page boundary. +/// +/// The mutation that proves this test load-bearing is on the FIELD ATTRIBUTES — remove +/// `deserialize_with` (or add `#[serde(default)]`) and this fails. Mutating the body of +/// `required_option` would NOT be caught, and correctly so: an absent key never reaches a +/// `deserialize_with` function, so the two cover disjoint inputs and only the attribute is under +/// test here. +#[test] +fn an_absent_paging_key_never_becomes_a_definite_answer() { + let complete_known = json!({ + "state": "known", + "entries": [], + "complete": true, + "cursor": null, + "locked_dig_base_units": 0u64, + "epoch": 7u64, + }); + // The control: with both keys present it decodes, so the failures below are about ABSENCE and + // not about the rest of the payload. + assert_result_round_trips::(complete_known.clone()); + + for missing in ["cursor", "complete"] { + let mut wire = complete_known.clone(); + wire.as_object_mut().unwrap().remove(missing); + assert!( + serde_json::from_value::(wire).is_err(), + "an absent `{missing}` must be a decode failure, never a default" + ); + } +} + +/// **The locked total is the NODE's figure over the whole set, and is not the page's sum.** +/// +/// The fixture is a TRUNCATED page whose visible amounts sum to less than `locked_dig_base_units`, +/// which is exactly the shape a client-side sum gets wrong. A fixture whose page happened to sum to +/// the total could not tell the two implementations apart — it would pass against a client that +/// re-derived the number and against one that read it, which is the false green this test is built +/// to avoid. +#[test] +fn the_locked_total_spans_pages_and_is_never_the_page_sum() { + let wire = json!({ + "state": "known", + "entries": [{ + "store_id": STORE, "root": ROOT, "bond_state": "bonded", + "coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64 + }], + "complete": false, + "cursor": {"store_id": STORE, "root": ROOT}, + "locked_dig_base_units": 5_000u64, + "epoch": 7u64, + }); + assert_result_round_trips::(wire.clone()); + + let parsed: results::MirrorBondStatesResult = serde_json::from_value(wire).unwrap(); + let results::MirrorBondStatesResult::Known { + entries, + complete, + locked_dig_base_units, + .. + } = parsed + else { + panic!("the vector is a known answer"); + }; + assert!(!complete, "the fixture must be a TRUNCATED page"); + let page_sum: u64 = entries + .iter() + .map(|e| match &e.state { + results::MirrorBondState::Bonded { + amount_dig_base_units, + .. + } + | results::MirrorBondState::Reclaiming { + amount_dig_base_units, + .. + } => *amount_dig_base_units, + _ => 0, + }) + .sum(); + assert!( + page_sum < locked_dig_base_units, + "the page must under-count the locked total, or this proves nothing" + ); +} + +/// **The page bound is refused on BOTH sides, and the bound itself is at the cap, not below it.** +/// +/// `MAX` must PASS and `MAX + 1` must fail: a bound checked only from below confirms only itself. +/// Zero is refused because a page that holds nothing makes no progress and a caller looping until +/// `complete` would loop forever. +#[test] +fn the_bond_page_bound_is_enforced_from_both_sides() { + let at_bound = MirrorBondStatesParams { + limit: Some(MIRROR_BOND_STATES_MAX_LIMIT), + ..MirrorBondStatesParams::default() + }; + assert!( + at_bound.clone().validated().is_ok(), + "the cap itself must be accepted" + ); + assert_eq!(at_bound.effective_limit(), MIRROR_BOND_STATES_MAX_LIMIT); + + for bad in [0, MIRROR_BOND_STATES_MAX_LIMIT + 1] { + let err = MirrorBondStatesParams { + limit: Some(bad), + ..MirrorBondStatesParams::default() + } + .validated() + .expect_err("an out-of-range page size must be refused"); + assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams)); + let over = serde_json::to_value(bad).unwrap(); + assert!( + serde_json::from_value::(json!({"limit": over})).is_err(), + "the refusal must be enforced on the way IN, so a node cannot forget it" + ); + } + + // An omitted limit is not a refusal — it is the contract's own default. + let defaulted: MirrorBondStatesParams = serde_json::from_value(json!({})).unwrap(); + assert_eq!( + defaulted.effective_limit(), + MIRROR_BOND_STATES_DEFAULT_LIMIT + ); + assert_eq!(defaulted.after, None); +} + +/// **The request envelope, pinned — including a cursor that names BOTH halves of the key.** +/// +/// Resuming by `store_id` alone would drop every remaining root of the store the page boundary fell +/// inside, so the cursor is a pair on the wire and this vector is what proves it. +#[test] +fn bond_states_request_wire_vector_is_pinned() { + assert_request( + &MirrorBondStatesParams::default(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "control.mirror.bondStates", + "params": {}, + }), + ); + assert_request( + &MirrorBondStatesParams { + after: Some(results::MirrorBondKey { + store_id: STORE.into(), + root: ROOT.into(), + }), + limit: Some(2), + }, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "control.mirror.bondStates", + "params": {"after": {"store_id": STORE, "root": ROOT}, "limit": 2}, + }), + ); +} + +/// **A provenance-blind producer has a CONFORMING answer, and it is not a silently-short page.** +/// +/// `withheld` means a held capsule with `Relayed` provenance, which is by construction absent from +/// a node's desired-bond (`Held`) set. A producer keyed on that set enumerates its pairs perfectly +/// well, so none of the three infrastructure reasons fits it — and without a fourth reason its only +/// conforming-LOOKING move is a `known` page with `complete: true` and every withheld row missing. +/// That page asserts a completeness the node knows it lacks, and it makes `withheld` a state a +/// conformance list can tick while no surface can ever emit it. +/// +/// The fixture is built against exactly that nearest wrong implementation: the truthful answer +/// CONTAINS a withheld row, so the short page and the sanctioned refusal are two observably +/// different answers about the same node rather than two spellings of one. +#[test] +fn a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page() { + // What a node that CAN see provenance would answer about this set. + let truthful = json!({ + "state": "known", + "entries": [ + {"store_id": STORE, "root": ROOT, "bond_state": "bonded", + "coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64}, + {"store_id": STORE, "root": BOND_ROOT_B, "bond_state": "withheld"}, + ], + "complete": true, + "cursor": {"store_id": STORE, "root": BOND_ROOT_B}, + "locked_dig_base_units": 1_047u64, + "epoch": 7u64, + }); + assert_result_round_trips::(truthful); + + // The sanctioned answer for a node that cannot see provenance. A golden vector, because this + // token is what dig-app renders and what the node must emit. + let sanctioned = json!({"state": "unknown", "reason": "provenance_unknown"}); + assert_result_round_trips::(sanctioned.clone()); + let parsed: results::MirrorBondStatesResult = serde_json::from_value(sanctioned).unwrap(); + assert_eq!( + parsed, + results::MirrorBondStatesResult::Unknown { + reason: results::MirrorBondStatesUnknownReason::ProvenanceUnknown, + }, + "a provenance-blind node's answer must be the WHOLE-call refusal, never a page" + ); + + // It is a REASON IN ITS OWN RIGHT, not an alias of the enumerate-failure. Binding it to + // `served_set_unknown` would tell an operator the node cannot list its bonds, which is false + // and sends them looking in the wrong place. + assert_ne!( + results::MirrorBondStatesUnknownReason::ProvenanceUnknown.as_wire(), + results::MirrorBondStatesUnknownReason::ServedSetUnknown.as_wire() + ); + assert!( + results::MirrorBondStatesUnknownReason::ALL + .contains(&results::MirrorBondStatesUnknownReason::ProvenanceUnknown), + "a reason a producer must be able to emit has to be enumerable by a renderer" + ); + // The `as_wire` token and the serde token are the SAME string, read back off the wire rather + // than off a second list that could agree with itself. + let encoded = serde_json::to_value(results::MirrorBondStatesResult::Unknown { + reason: results::MirrorBondStatesUnknownReason::ProvenanceUnknown, + }) + .unwrap(); + assert_eq!( + encoded["reason"], + json!(results::MirrorBondStatesUnknownReason::ProvenanceUnknown.as_wire()) + ); + + // And the forbidden alternative is a DIFFERENT observable answer, not a variant spelling of + // this one: it decodes as `known`, claims completeness, and is one row short of the truth. + let short_page = json!({ + "state": "known", + "entries": [ + {"store_id": STORE, "root": ROOT, "bond_state": "bonded", + "coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64}, + ], + "complete": true, + "cursor": {"store_id": STORE, "root": ROOT}, + "locked_dig_base_units": 1_047u64, + "epoch": 7u64, + }); + let short: results::MirrorBondStatesResult = serde_json::from_value(short_page).unwrap(); + assert_ne!(short, parsed); + let results::MirrorBondStatesResult::Known { + entries, complete, .. + } = &short + else { + panic!("the forbidden alternative is a `known` page by construction") + }; + assert!( + *complete && entries.len() == 1, + "the failure this reason removes is a complete-looking page missing its withheld rows" + ); +} + +/// **A malformed resume cursor is REFUSED, never read as "start from the beginning".** +/// +/// The order is ascending over the key's STRING form, so a `0x`-prefixed key sorts before every +/// canonical one: a node that dropped it would restart the walk while looking like it resumed, and +/// a repeated page inflates the locked-$DIG total a client accumulates across a walk — wrong in the +/// reassuring direction and indistinguishable from a correct answer. So `0x` is NORMALIZED (callers +/// hand-copy ids out of block explorers) and everything else is refused, matching +/// `WalletCoinsParams::validated` rather than inventing a second convention. +#[test] +fn a_malformed_bond_cursor_is_refused_rather_than_restarting_the_walk() { + let key = |store: &str, root: &str| results::MirrorBondKey { + store_id: store.into(), + root: root.into(), + }; + let with = |k: results::MirrorBondKey| MirrorBondStatesParams { + after: Some(k), + ..MirrorBondStatesParams::default() + }; + + // The well-formed control: a canonical key passes and is handed back UNCHANGED. + let canonical = with(key(STORE, ROOT)).validated().expect("canonical key"); + assert_eq!(canonical.after, Some(key(STORE, ROOT))); + + // `0x` is tolerated on input and normalized away; it is never emitted. + let prefixed = with(key(&format!("0x{STORE}"), &format!("0x{ROOT}"))) + .validated() + .expect("a 0x-prefixed key is tolerated"); + assert_eq!(prefixed.after, Some(key(STORE, ROOT))); + assert_eq!( + serde_json::to_value(&prefixed).unwrap()["after"], + json!({"store_id": STORE, "root": ROOT}), + "the normalized form is what goes back on the wire" + ); + + // Every other spelling is refused — and refused in EITHER half, so a good store id cannot + // launder a bad root past the check. + let uppercase = STORE.to_ascii_uppercase(); + let short = &STORE[1..]; + let long = format!("{STORE}a"); + let non_hex = "z".repeat(64); + let bad: [&str; 6] = [&uppercase, short, &long, &non_hex, "", "0xnothex"]; + for spelling in bad { + for candidate in [key(spelling, ROOT), key(STORE, spelling)] { + let err = with(candidate.clone()) + .validated() + .expect_err("a malformed cursor must be refused"); + assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams)); + + // Enforced on the way IN, so a node cannot forget to call `validated`... + let wire = json!({"after": { + "store_id": candidate.store_id, "root": candidate.root + }}); + let decoded = serde_json::from_value::(wire); + assert!( + decoded.is_err(), + "a malformed cursor must not decode into a request at all" + ); + // ...and REFUSED, never coerced to `None`, which would silently restart the walk. + assert!( + decoded.map(|p| p.after).unwrap_or(None).is_none(), + "an unparseable cursor must never become start-of-set" + ); + } + } +} diff --git a/src/method.rs b/src/method.rs index fa71346..dacd645 100644 --- a/src/method.rs +++ b/src/method.rs @@ -66,8 +66,8 @@ 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 + /// Mirror-collateral: this epoch's derived per-store requirement, the node's LOCAL safety + /// margin over it, and the per-`(store, root)` state of the bonds this node actually holds. 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, } @@ -221,6 +221,9 @@ pub enum ControlMethod { CollateralMarginSet, /// `control.collateral.buffer` -- the $DIG this node recommends holding, and its funding state. CollateralBuffer, + /// `control.mirror.bondStates` -- the per-`(store, root)` mirror bond state, and the $DIG + /// those bonds have locked. + MirrorBondStates, // ---- dig-profile bodies (delegated to the engine) ---- /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to. @@ -291,6 +294,7 @@ impl ControlMethod { ControlMethod::CollateralMarginGet => "control.collateral.margin.get", ControlMethod::CollateralMarginSet => "control.collateral.margin.set", ControlMethod::CollateralBuffer => "control.collateral.buffer", + ControlMethod::MirrorBondStates => "control.mirror.bondStates", ControlMethod::ProfilePutBody => "control.profile.putBody", ControlMethod::ProfileGetBody => "control.profile.getBody", ControlMethod::PairingRequest => "pairing.request", @@ -524,7 +528,8 @@ impl ControlMethod { ControlMethod::CollateralRequirement | ControlMethod::CollateralMarginGet | ControlMethod::CollateralMarginSet - | ControlMethod::CollateralBuffer => Category::Collateral, + | ControlMethod::CollateralBuffer + | ControlMethod::MirrorBondStates => Category::Collateral, ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile, } } @@ -586,6 +591,7 @@ impl ControlMethod { 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::MirrorBondStates => "READ-only: the state of every mirror bond this node holds, keyed per (store, root), plus the $DIG those bonds have LOCKED. Seven states, and six of them mean `no coin yet` for entirely different reasons: `bonded` (a coin id, epoch and the amount THAT COIN locks, read from the coin and not from today's requirement), `pending` (submitted, unconfirmed -- never a shortfall), `unfunded` (the ONLY genuine out-of-funds state, carrying how many DIG BASE UNITS this bond alone is short), `deferred` (the epoch requirement is unknown so no create can be priced -- the wallet may be full), `withheld` (Relayed provenance: held and deliberately never advertised), `disabled` (collateralisation is switched off node-wide) and `reclaiming` (a live coin whose money is STILL LOCKED until the reclaim confirms). Conflating `unfunded` with `withheld` or `disabled` produces hourly out-of-funds alarms about a healthy node, which is the defect this method removes. Amounts are DIG base units (3 decimals, one base unit is 0.001 DIG) and NEVER mojos, which are XCH's 1e-12 unit. `locked_dig_base_units` is the WHOLE-SET total including reclaiming coins, computed by the node: a client MUST NOT sum the page, which would under-report locked money by a page boundary and show unspendable funds as available. A node that cannot enumerate its bonds, cannot read chain, cannot see its own in-flight creates, or cannot determine the provenance of what it holds answers `unknown` for the WHOLE call WITH the reason -- there is no per-row unknown and no empty-list fallback, because a truncated list and a complete one read the same. A page is bounded, ordered by ascending (store_id, root), and says via `complete` whether it is the whole set; resume from the `cursor` key you were HANDED. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN bond set and funding position.", 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.", } @@ -646,6 +652,7 @@ impl ControlMethod { ControlMethod::CollateralMarginGet, ControlMethod::CollateralMarginSet, ControlMethod::CollateralBuffer, + ControlMethod::MirrorBondStates, ControlMethod::ProfilePutBody, ControlMethod::ProfileGetBody, ControlMethod::PairingRequest, diff --git a/src/params.rs b/src/params.rs index ddc779e..534395f 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1655,6 +1655,143 @@ impl<'de> Deserialize<'de> for SpendsListParams { } control_call!(SpendsListParams => ControlMethod::SpendsList, results::SpendsListResult); +/// The page size `control.mirror.bondStates` returns when a caller names none. +/// +/// Sized for a pane rather than for a walk: dig-app#300 renders every bond of an ordinary node in +/// one view, and a node serving fewer than this many `(store, root)` pairs never pages at all. +pub const MIRROR_BOND_STATES_DEFAULT_LIMIT: u32 = 100; + +/// The largest page `control.mirror.bondStates` will return. +/// +/// Pinned TO [`COINS_BY_PARENT_MAX_LIMIT`] rather than repeating its number, because it is bounded +/// by the same arithmetic: a bond row carries a coin id and a handful of integers, so a page of +/// bonds and a page of coins occupy the same order of envelope. Written as the constant so the two +/// cannot drift into two different numbers that were only ever meant to be one. +pub const MIRROR_BOND_STATES_MAX_LIMIT: u32 = COINS_BY_PARENT_MAX_LIMIT; + +const MIRROR_BOND_STATES_LIMIT_ERROR: &str = "limit must be between 1 and 1000 bonds per page"; + +const MIRROR_BOND_AFTER_ERROR: &str = + "after.store_id and after.root must each be lowercase 64-hex, optionally 0x-prefixed"; + +/// `control.mirror.bondStates` params — one page of this node's mirror bond states. +/// +/// # The caller narrows nothing +/// +/// There is no store filter. The answer is this node's OWN bond set, and both consuming surfaces +/// (dig-app#300's pane, dig-app#289's locked total) want all of it; a client wanting one store +/// filters the page it was handed. Adding a filter would also make +/// [`locked_dig_base_units`](results::MirrorBondStatesResult::Known::locked_dig_base_units) — a +/// whole-set figure — read as a filtered one, which is the money lie this method is built to avoid. +/// +/// # Paged, and the page boundary is the caller's +/// +/// Rows come in ascending `(store_id, root)` and a caller resumes from [`after`](Self::after) — the +/// key of the last row it was actually HANDED. An out-of-range [`limit`](Self::limit) is REFUSED +/// rather than clamped, matching [`WalletCoinsByParentParams`] and for the same reason: a silently +/// shrunk page hands back a cursor for a position the caller did not ask about. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct MirrorBondStatesParams { + /// Resume STRICTLY AFTER this `(store_id, root)`, in ascending key order. `None` starts at the + /// first bond. + /// + /// The value the previous page handed back as + /// [`cursor`](results::MirrorBondStatesResult::Known::cursor) — never a key the caller kept for + /// another reason. Resuming by `store_id` alone would drop every remaining root of the store + /// the boundary fell inside. + /// + /// Both halves are LOWERCASE 64-hex, unprefixed, and a `0x` prefix is tolerated on input and + /// normalized away by [`validated`](Self::validated). Anything else is REFUSED — see there for + /// why a malformed cursor must never be read as "start from the beginning". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, + /// The page size. `None` asks for [`MIRROR_BOND_STATES_DEFAULT_LIMIT`]. + /// + /// A zero, or a value above [`MIRROR_BOND_STATES_MAX_LIMIT`], is REFUSED as `INVALID_PARAMS` + /// rather than clamped — see [`Self::validated`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl MirrorBondStatesParams { + /// The page size this request asks for, resolving `None` to + /// [`MIRROR_BOND_STATES_DEFAULT_LIMIT`]. + /// + /// Stated once here so the node and the client cannot resolve the same omitted field to two + /// different numbers. + pub fn effective_limit(&self) -> u32 { + self.limit.unwrap_or(MIRROR_BOND_STATES_DEFAULT_LIMIT) + } + + /// Check the page bound, or reject as `-32602 INVALID_PARAMS`. + /// + /// `limit: 0` is refused because a page that can hold nothing makes no progress: a caller + /// looping until `complete` would loop forever. A limit above the cap is refused rather than + /// clamped so the caller's model of the page and the node's stay identical. + /// + /// [`after`](Self::after) is normalized on the same terms as every other hex id in this crate + /// ([`WalletCoinsParams::validated`]) and a malformed one is REFUSED rather than dropped. That + /// refusal is the point: this order is ascending over the key's STRING form, so a + /// `0x`-prefixed key sorts before every canonical one and a node that quietly ignored it would + /// RESTART the walk while looking like it resumed. On the surface a locked-$DIG total is + /// summed from, a silently repeated page is wrong in the reassuring direction, and it is + /// indistinguishable from a correct answer. Coercing an unparseable cursor to start-of-set is + /// the same defect by another route, and it would contradict this method's own + /// refuse-don't-clamp rule for `limit`. + pub fn validated(self) -> Result { + if let Some(limit) = self.limit { + if limit == 0 || limit > MIRROR_BOND_STATES_MAX_LIMIT { + return Err(ControlError::of( + ControlErrorCode::InvalidParams, + MIRROR_BOND_STATES_LIMIT_ERROR, + )); + } + } + let after = self.after.map(normalize_bond_key).transpose()?; + Ok(MirrorBondStatesParams { after, ..self }) + } +} + +/// Normalize both halves of a bond cursor, or reject the whole key. +/// +/// Reuses [`normalize_coin_id`] rather than restating the rule: a store id, a root and a coin id +/// are all 32-byte hex on this wire, and a second copy of the rule is how two of three end up +/// accepting different spellings. +fn normalize_bond_key(key: results::MirrorBondKey) -> Result { + let malformed = || ControlError::of(ControlErrorCode::InvalidParams, MIRROR_BOND_AFTER_ERROR); + let store_id = normalize_coin_id(&key.store_id) + .ok_or_else(malformed)? + .to_owned(); + let root = normalize_coin_id(&key.root) + .ok_or_else(malformed)? + .to_owned(); + Ok(results::MirrorBondKey { store_id, root }) +} + +impl<'de> Deserialize<'de> for MirrorBondStatesParams { + /// Validates on the way in, so a node cannot forget to call [`Self::validated`]. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + after: Option, + #[serde(default)] + limit: Option, + } + let raw = Raw::deserialize(deserializer)?; + MirrorBondStatesParams { + after: raw.after, + limit: raw.limit, + } + .validated() + .map_err(serde::de::Error::custom) + } +} +control_call!(MirrorBondStatesParams => ControlMethod::MirrorBondStates, results::MirrorBondStatesResult); + #[cfg(test)] mod tests { use super::*; diff --git a/src/results.rs b/src/results.rs index a2a2a3f..97ae789 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2201,6 +2201,291 @@ pub struct SpendsListResult { pub unreadable_lines: u32, } +// --------------------------------------------------------------------------- +// Mirror bonds: the per-`(store, root)` bond state surface (dig-node SPEC §25.8). +// --------------------------------------------------------------------------- + +/// What this node can say about ONE `(store, root)` bond right now. +/// +/// # The whole point is that "no coin yet" is never one answer +/// +/// Six of these seven variants mean "there is no current-epoch coin", and every one of them calls +/// for a different response from a person: add funds, wait, do nothing, turn a switch back on, or +/// nothing at all because the capsule was never this node's to advertise. Collapsing any two of +/// them is what produces an hourly out-of-funds alarm about a perfectly healthy node +/// (dig-app#300), which is the defect this method exists to remove. +/// +/// # Vocabulary: `withheld`, `disabled` and `reclaiming` are three different things +/// +/// dig-node's internal `BondState` (`mirror/pass.rs`) used the single word `Withheld` for the +/// node-wide collateralisation switch being OFF, while dig-node `SPEC.md` §25.8 used the same word +/// for a capsule of `Relayed` provenance — one this node holds but deliberately never advertises. +/// They are not the same state. They differ in SCOPE (one switch for the node, versus one +/// capsule's provenance) and, more importantly, in REMEDY: an operator told "withheld" about a +/// disabled node goes looking at content, and one told "withheld" about a relayed capsule goes +/// looking for a switch. **This contract keeps them apart, and neither existing use survives +/// unchanged:** +/// +/// - [`Withheld`](Self::Withheld) carries §25.8's meaning — `Relayed` provenance, per capsule. +/// - [`Disabled`](Self::Disabled) is the node-wide switch, which §25.8 could not express at all. +/// - [`Reclaiming`](Self::Reclaiming) is §25.8's seventh state, which `BondState` had no variant +/// for even though the money is still locked while it lasts. +/// +/// So dig-node MUST rename `BondState::Withheld` to `Disabled` and add `Withheld` + `Reclaiming`, +/// and §25.8 MUST gain `disabled`. Serving the old enum under §25.8's words would publish a +/// contract whose terms mean something else — the drift class this crate exists to prevent. +/// +/// **[`Withheld`](Self::Withheld) is VACUOUS until dig-node's surface enumerates its SERVED set +/// rather than its `Held` set.** A relayed capsule is by construction absent from the desired-bond +/// set, so a derivation keyed on `Held` bonds can never emit this variant — it would silently +/// answer "no such row" where §25.8 promises "withheld on purpose". Declaring it here is correct; +/// a producer that cannot reach it MUST say so rather than report the state as satisfied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "bond_state", rename_all = "snake_case")] +pub enum MirrorBondState { + /// A coin bonding this `(store, root)` for the CURRENT epoch is on chain. + Bonded { + /// The coin a person can look up, hex, no `0x`. + coin_id: String, + /// The epoch it bonds, one-based. + epoch: u64, + /// What the coin actually LOCKS, in DIG base units, read from the coin — never from this + /// epoch's requirement. A coin created under a previous requirement locks the previous + /// amount, and rendering today's price against yesterday's coin is a figure nobody holds. + amount_dig_base_units: u64, + }, + /// A create for this bond has been submitted and has not confirmed. + /// + /// Nothing is wrong and no money is missing. A client MUST NOT render this as a shortfall. + Pending, + /// The wallet cannot cover the create for this bond. + /// + /// The genuine out-of-funds state, and the ONLY one a client may raise a funding alarm on. + Unfunded { + /// How many more DIG base units THIS BOND alone needs. + /// + /// DIG base units — $DIG has 3 decimals, so one unit is `0.001 DIG`. It is NOT a mojo, + /// which is XCH's `1e-12` unit, nine orders of magnitude away. A mirror amount is never + /// quoted in mojos. + /// + /// Per bond, never a total. "How short is this node overall" is + /// `control.collateral.buffer`'s question, and it answers it authoritatively. + short_dig_base_units: u64, + }, + /// The epoch's collateral requirement is not known, so no create can be PRICED. + /// + /// **NOT an out-of-funds state.** The wallet may be full. A client that renders this as a + /// shortfall tells an operator to send money that would change nothing. + Deferred { + /// Why the requirement is unknown, in the SAME taxonomy + /// [`CollateralRequirementResult::Unknown`] uses. + /// + /// Reused rather than restated: a second copy of that taxonomy here would drift from the + /// original, and a client already renders these tokens for + /// `control.collateral.requirement`. + reason: CollateralUnknownReason, + }, + /// This node holds the capsule with `Relayed` provenance: it does not claim to serve it, and + /// deliberately never advertises it. + /// + /// §25.8's `withheld`. Nothing is wrong, nothing is owed, and there is no remedy — which is + /// exactly why conflating it with [`Unfunded`](Self::Unfunded) is the dig-app#300 defect. + Withheld, + /// Collateralisation is switched OFF for this node, so no bond is advertised regardless of + /// funds, provenance or price. + /// + /// Node-wide, not per capsule: every row reads `disabled` together. The remedy is a switch, and + /// it is the operator's own earlier decision — a client MUST NOT present it as a fault. + Disabled, + /// A live coin is being reclaimed: the bond is going away and the money is not back yet. + /// + /// Carries the coin because the funds are STILL LOCKED for the duration. A surface that showed + /// this as unbonded-and-unlocked would report money as available that cannot be spent, and a + /// reclaim that fails leaves the coin exactly where this says it is. + Reclaiming { + /// The coin being reclaimed, hex, no `0x`. + coin_id: String, + /// The epoch that coin bonds — frequently a PREVIOUS epoch, which is usually why it is + /// being reclaimed. + epoch: u64, + /// What it still locks, in DIG base units, read from the coin. + amount_dig_base_units: u64, + }, +} + +/// The `(store_id, root)` pair that identifies one mirror bond — the read's sort key and its cursor. +/// +/// A composite key rather than an opaque string, because the order it names is the contract's own +/// (ascending `store_id`, then ascending `root`) and a caller resuming a walk can check its position +/// rather than trust an encoding it cannot read. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct MirrorBondKey { + /// The store id — LOWERCASE 64-hex, unprefixed. + /// + /// The canonical form is part of the key's contract rather than a formatting preference: the + /// order this key names is ascending over these STRINGS, and uppercase hex sorts differently + /// from lowercase, so two producers spelling it differently would disagree on the order and + /// `after` would mean two different positions. A `0x` prefix is TOLERATED on input to + /// [`MirrorBondStatesParams`](crate::params::MirrorBondStatesParams) and normalized away; it + /// is never emitted. + pub store_id: String, + /// The root — LOWERCASE 64-hex, unprefixed, on the same terms as [`store_id`](Self::store_id). + pub root: String, +} + +/// One row of [`MirrorBondStatesResult`]: which bond, and what its state is. +/// +/// The state is FLATTENED into the row, so a row is one object carrying `store_id`, `root`, +/// `bond_state` and that state's own payload — never a nested envelope a client has to unwrap +/// before it can branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MirrorBondEntry { + /// The store id this bond is for, hex, no `0x`. + pub store_id: String, + /// The ROOT this bond is for, hex, no `0x`. + /// + /// Part of the key, never decoration. A publisher funds the latest root and may decline to fund + /// older ones, so a coin bonds one `(store, root)` pair and a surface keyed on the store alone + /// would merge a funded root with an unfunded one into a single misleading row. + pub root: String, + /// What this node can say about the bond. + #[serde(flatten)] + pub state: MirrorBondState, +} + +/// Why a node cannot state its bond states AT ALL. +/// +/// The "cannot tell" axis, and it is deliberately separate from every per-bond state, all of which +/// are DEFINITE statements. A fact the node could not read makes the WHOLE answer +/// [`MirrorBondStatesResult::Unknown`] rather than degrading individual rows: a partial list is +/// indistinguishable from a complete one, and the rows a broken read would drop are exactly the +/// bonds nobody is then watching. +/// +/// The epoch requirement being unknown is NOT a member — that is a definite per-bond state +/// ([`MirrorBondState::Deferred`]) and the node can still answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MirrorBondStatesUnknownReason { + /// The node cannot enumerate the `(store, root)` pairs it holds, so it does not know which + /// bonds exist to have a state. Nothing may be substituted: the census `stores` figure counts + /// network-wide advertisements and is not this node's set. + ServedSetUnknown, + /// The node cannot read mirror coins from chain, so it cannot tell a bonded pair from an + /// unfunded one. Answering `unfunded` here would be a fabricated shortfall. + ChainUnreadable, + /// The node cannot read its own in-flight creates, so it cannot tell + /// [`Pending`](MirrorBondState::Pending) from [`Unfunded`](MirrorBondState::Unfunded) — a + /// submitted create and no create at all look identical from chain alone during the gap. + InFlightUnknown, + /// The node can enumerate the `(store, root)` pairs it holds, but cannot determine their + /// PROVENANCE, so it cannot tell a `Relayed` capsule apart from one that is simply absent. + /// + /// The one non-infrastructure reason, and it exists because the alternative is a lie. A + /// derivation keyed on the desired-bond (`Held`) set enumerates perfectly well and yet can + /// never emit [`Withheld`](MirrorBondState::Withheld), because a `Relayed` capsule is by + /// construction absent from that set. Without this reason its only conforming-LOOKING answer + /// is a `known` page with `complete: true` and every withheld row silently missing — the exact + /// "no such row where the contract promises withheld on purpose" failure, wearing the shape of + /// a complete answer. This reason is how such a node says so instead. + ProvenanceUnknown, +} + +impl MirrorBondStatesUnknownReason { + /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT. + pub const ALL: &'static [MirrorBondStatesUnknownReason] = &[ + MirrorBondStatesUnknownReason::ServedSetUnknown, + MirrorBondStatesUnknownReason::ChainUnreadable, + MirrorBondStatesUnknownReason::InFlightUnknown, + MirrorBondStatesUnknownReason::ProvenanceUnknown, + ]; + + /// The stable snake_case wire token, matching the `reason` field. + pub const fn as_wire(self) -> &'static str { + match self { + MirrorBondStatesUnknownReason::ServedSetUnknown => "served_set_unknown", + MirrorBondStatesUnknownReason::ChainUnreadable => "chain_unreadable", + MirrorBondStatesUnknownReason::InFlightUnknown => "in_flight_unknown", + MirrorBondStatesUnknownReason::ProvenanceUnknown => "provenance_unknown", + } + } +} + +/// `control.mirror.bondStates` — the per-`(store, root)` state of every mirror bond this node +/// holds, and the $DIG those bonds have locked. +/// +/// # "No bond" and "cannot tell" are different answers, at different levels +/// +/// Every per-row [`MirrorBondState`] is a DEFINITE statement, including the six that mean "no coin +/// yet": each names WHY, and each has its own remedy. A node that could not read a fact it needs +/// does not report a row at all — it answers [`Unknown`](Self::Unknown) for the WHOLE call, with +/// the reason. There is deliberately no per-row "unknown" and no empty-list fallback: a short list +/// and a complete one look the same, and the rows a broken read would drop are precisely the bonds +/// an operator most needs to see. +/// +/// `entries: []` with `complete: true` is therefore an ANSWER — this node holds no mirror bonds — +/// and it is never what a caller gets when something could not be read. +/// +/// # The locked total is the node's, and a client MUST NOT sum the page +/// +/// `locked_dig_base_units` covers the WHOLE bond set, not this page, and includes +/// [`Reclaiming`](MirrorBondState::Reclaiming) coins because their money is still locked. A client +/// that summed `entries` instead would under-report the locked total by exactly one page boundary +/// and would show money as available that cannot be spent — the money lie this method's paging +/// makes easiest to tell. It is what dig-app#289's locked-total surface reads. +/// +/// # Every amount is DIG BASE UNITS +/// +/// $DIG carries 3 decimals, so one base unit is `0.001 DIG`. It is NOT a mojo — XCH's `1e-12` base +/// unit, nine orders of magnitude away. A mirror amount is never quoted in mojos. +/// +/// # A page, and it says so +/// +/// Rows come in ASCENDING `(store_id, root)`, a total order over the LOWERCASE unprefixed hex +/// spelling of both halves, stable across the pages of one walk. `complete` states whether the page is the whole set and is never inferred from the +/// page's length: a node may return a short page for its own reasons, and a set that is an exact +/// multiple of the page size makes the last full page indistinguishable from a truncated one. +/// Resume from `cursor` — the key of the last row you were actually HANDED. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum MirrorBondStatesResult { + /// The node can state every bond's state. + Known { + /// One page of bonds, ascending by `(store_id, root)`, possibly empty. + entries: Vec, + /// Is this page the WHOLE bond set? + /// + /// Required on the wire, and stated positively so the reading a caller falls into when the + /// field is absent or defaulted is the SAFE one — `complete` defaults to "there may be + /// more", which costs at worst one redundant request, whereas a `truncated` spelling would + /// default to "this is everything" and end a walk early. + complete: bool, + /// The key of the LAST row in this page — the value to resume from — or `null` for an empty + /// page. + /// + /// The key the caller was HANDED, never a position the node "got to". Pass it as + /// [`MirrorBondStatesParams::after`](crate::params::MirrorBondStatesParams::after). + /// + /// The key MUST be present; `null` is meaningful and an absent key must NOT decode into it. + #[serde(deserialize_with = "required_option")] + cursor: Option, + /// The $DIG this node has LOCKED in mirror coins across the whole bond set, in DIG base + /// units. + /// + /// Authoritative and node-computed. Includes reclaiming coins. Spans every page. + locked_dig_base_units: u64, + /// The epoch in force when this answer was taken, one-based. + /// + /// Carried so a client can tell a bond at the current epoch from one it is reading across a + /// rollover, without consulting a second method whose answer may have moved in between. + epoch: u64, + }, + /// The node cannot state the bond states, and names which fact is missing. + Unknown { + /// Which fact the node is missing. + reason: MirrorBondStatesUnknownReason, + }, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/traits.rs b/src/traits.rs index 7667c55..dbf2092 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -553,6 +553,47 @@ pub trait ControlHandler: Sync { /// themselves recreates the rival derivations this method exists to prevent. async fn collateral_buffer(&self) -> Result; + /// `control.mirror.bondStates` (TOKEN-GATED) + /// + /// The per-`(store, root)` state of every mirror bond this node holds, and the $DIG they lock. + /// + /// An implementation MUST: + /// + /// - **Keep the seven states apart.** `unfunded` is the only genuine out-of-funds state. + /// `deferred` (no priced requirement), `pending` (submitted, unconfirmed), `withheld` + /// (`Relayed` provenance) and `disabled` (the node-wide switch) all mean "no coin yet" and + /// none of them means "send money". Collapsing any of them into `unfunded` is the dig-app#300 + /// defect this method exists to remove. + /// - **Read `bonded` and `reclaiming` amounts FROM THE COIN**, never from this epoch's + /// requirement. A coin created under a previous requirement locks the previous amount, and + /// the current price is not a fact about an existing coin. + /// - **Enumerate the SERVED set, not only the desired-bond set.** + /// [`Withheld`](results::MirrorBondState::Withheld) means a capsule this node holds with + /// `Relayed` provenance, which is by construction absent from the `Held` set; a derivation + /// keyed on `Held` alone can never emit it and silently answers "no such row" where the + /// contract promises "withheld on purpose". An implementation that CANNOT see provenance MUST + /// answer + /// [`ProvenanceUnknown`](results::MirrorBondStatesUnknownReason::ProvenanceUnknown) for the + /// whole call and MUST NOT return a `known` page — a page with its withheld rows silently + /// missing claims a completeness the node knows it lacks. + /// - **Answer [`Unknown`](results::MirrorBondStatesResult::Unknown) for the WHOLE call** when it + /// cannot enumerate its bonds, cannot read chain, cannot read its own in-flight creates, or + /// cannot determine provenance. + /// There is no per-row unknown and no empty-list fallback: `entries: []` with + /// `complete: true` asserts this node holds no bonds, and a partial list read as a complete + /// one hides exactly the bonds nobody is watching. + /// - **Compute `locked_dig_base_units` over the WHOLE set, including reclaiming coins**, and + /// never over the page. A reclaim in flight still locks its money. + /// - **Order rows by ascending `(store_id, root)` and keep that order stable across the pages of + /// one walk**, over the LOWERCASE unprefixed hex spelling of both halves, since `after` means + /// *strictly after this key in that order* and uppercase hex sorts elsewhere. Set `complete` + /// explicitly, and set `cursor` to the key of the LAST row actually handed back (`null` for an + /// empty page) — never to a position the node "got to". + async fn mirror_bond_states( + &self, + params: params::MirrorBondStatesParams, + ) -> Result; + /// `control.profile.putBody` (TOKEN-GATED) /// /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root @@ -719,6 +760,13 @@ pub trait ControlHandler: Sync { } ControlMethod::CollateralRequirement => encode(self.collateral_requirement().await?), ControlMethod::CollateralBuffer => encode(self.collateral_buffer().await?), + // Re-validated here idempotently; `MirrorBondStatesParams`'s own `Deserialize` already + // enforced the page bound. A limit above the cap is REFUSED, never clamped, or the + // cursor handed back names a position the caller never asked about. + ControlMethod::MirrorBondStates => { + let params: params::MirrorBondStatesParams = decode(params)?; + encode(self.mirror_bond_states(params.validated()?).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`