From 6b7ef48ba9882b084ae03b4ed9d8b8d4a6748279 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 12:07:55 -0700 Subject: [PATCH] feat(mirror): a bond page names WHICH WALLET its amounts are about Every figure on a bond page -- each `unfunded` shortfall, each bonded amount, `locked_dig_base_units` -- is a statement about the node's own machine-custody operator wallet, not the user's. The payload named an amount and no wallet, which is the literal shape of the misdiagnosis this family exists to fix: a node said `unfunded, short 1010` while its operator's own wallet held 1,015,000 base units of $DIG. Both true, each about a different wallet, and nobody could tell from the answer. `MirrorBondStatesResult::Known` gains `funding_wallet`, typed as the same `WalletOperatorAddressResult` the dedicated method returns -- reused rather than restated, so the two surfaces cannot drift into two spellings of one fact, and so a node with no wallet says `not_initialized` here too instead of a blank string a client might render as a destination. On the ANSWER, not per row, and the distinction is not cosmetic. The funding wallet is node-wide, so a per-row copy would be one string repeated for every entry: a field that cannot vary while reading as though it could, and two rows of one answer could then be written to disagree. One value per answer can be wrong; it cannot be inconsistent with itself. Carried rather than left to a second call for the same reason `epoch` is carried, in that field's own words: a second call is a second observation and may have moved. A page of amounts can be rendered, screenshotted and acted on before a follow-up returns. Also corrects a README row that still said "seven states, six of which" after 0.28.0 added `unadvertised` -- drift introduced by that PR and caught here. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- SPEC.md | 12 ++++++++- src/kats.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/results.rs | 29 +++++++++++++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5425105..6654578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.29.0" +version = "0.30.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index cb956dc..c9092ac 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.29.0" +version = "0.30.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index 5062e53..d4acf47 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,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.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, funding_wallet}` \| `{state:"unknown", reason}`; the per-`(store, root)` mirror bond state and the $DIG those bonds LOCK. Eight states, seven 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 — the operator's own switch, which a client MUST NOT show as a fault) \| `unadvertised` (that switch ON, but no entry in `DIG_MIRROR_ADVERTISE_URLS` is publishable, so the node advertises nothing and a coin would bond nothing — a fault a client MUST surface, and NOT the same as `disabled`) \| `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. `funding_wallet` names WHICH WALLET every amount on the page is about — the node's own machine-custody operator wallet, the same value and the same type `control.wallet.operatorAddress` returns — on the ANSWER rather than per row, since the wallet is node-wide and a per-row copy could be written to disagree with itself | | `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 cbd184d..68e0e22 100644 --- a/SPEC.md +++ b/SPEC.md @@ -133,7 +133,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.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, funding_wallet}` \| `{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}` | @@ -1146,6 +1146,16 @@ NOT serve it as `disabled` -- doing so obliges a conforming client to stay silen that is wrong. It MUST NOT serve it as `unfunded` either: the wallet may be full, and no amount of $DIG creates a coin that would advertise nothing. +**A `known` answer NAMES THE WALLET its amounts are about, in `funding_wallet`.** Every figure on the +page — each `unfunded` shortfall, each bonded amount, and `locked_dig_base_units` — is a statement +about the node's own MACHINE-custody operator wallet and not about the user's. It MUST be the same +value `control.wallet.operatorAddress` reports, carried in the same type so the two surfaces cannot +drift into two spellings of one fact, and it MUST appear on the ANSWER rather than on each row: the +funding wallet is node-wide, so a per-row copy is a field that cannot vary while reading as though it +could, and two rows of one answer could then be written to disagree. It is carried rather than left +to a second call for the same reason `epoch` is: a follow-up call is a second observation, and a page +of amounts can be rendered and acted on before it returns. + **`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. diff --git a/src/kats.rs b/src/kats.rs index 25cad1f..af64300 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -1690,6 +1690,10 @@ impl ControlHandler for MockNode { complete: true, locked_dig_base_units: 1_047, epoch: 7, + funding_wallet: results::WalletOperatorAddressResult::Known { + address: OPERATOR_ADDRESS.into(), + puzzle_hash: "7c".repeat(32), + }, }) } @@ -4901,6 +4905,65 @@ fn every_collateral_unknown_reason_is_listed_in_all() { /// `balance_unreadable` token beside it. The nearest wrong implementation reuses one of the four /// census/record/chain reasons, and it is distinguishable here only because the token itself is /// asserted — a test that merely round-tripped the value would pass against every one of them. +/// **A page of bond amounts names the wallet those amounts are about, and it is the SAME wallet +/// `control.wallet.operatorAddress` reports.** +/// +/// This is the defect stated as a test. A node said `unfunded, short 1010` while its operator's own +/// wallet held 1,015,000 base units of $DIG; both were true, each about a different wallet, and the +/// payload carried an amount and no wallet. +/// +/// The fixture is built to distinguish the fix from the nearest wrong implementations, and there +/// are two of them: +/// +/// - **A page that carries an address which is not the funding wallet's.** Caught by comparing the +/// bond page's answer against the dedicated method's, THROUGH THE DISPATCHER, so both come from +/// the same handler by the same route a client uses. A second hardcoded expectation here would +/// agree with itself while the two surfaces disagreed. +/// - **A field that is present and empty.** Caught by asserting the `known` tag and a non-empty +/// address, because a blank destination rendered on a funding screen is the money statement this +/// whole family exists to prevent. +/// +/// The page deliberately contains an `unfunded` row, so the assertion is made on an answer that is +/// actually demanding money -- the exact case where naming the wrong wallet costs somebody a +/// transfer. +#[test] +fn a_page_of_bond_amounts_names_the_wallet_those_amounts_are_about() { + let handler = MockNode; + + let page = block_on(handler.mirror_bond_states(MirrorBondStatesParams::default())) + .expect("the fixture answers"); + let results::MirrorBondStatesResult::Known { + entries, + funding_wallet, + .. + } = page + else { + panic!("the fixture is a known answer"); + }; + + assert!( + entries + .iter() + .any(|e| matches!(e.state, results::MirrorBondState::Unfunded { .. })), + "the fixture must contain a row that DEMANDS money, or this proves nothing" + ); + + let results::WalletOperatorAddressResult::Known { address, .. } = &funding_wallet else { + panic!("a node that can state its bonds can state whose bonds they are"); + }; + assert!( + !address.is_empty(), + "an empty address renders as a destination on a funding screen" + ); + + let direct = block_on(handler.wallet_operator_address()).expect("the fixture answers"); + assert_eq!( + funding_wallet, direct, + "the wallet a bond page names must be the wallet the dedicated method reports -- two \ + answers to one question is the drift this contract exists to prevent" + ); +} + /// **The operator-address method is TOKEN-GATED and OWNED, and both halves are the contract.** /// /// Neither property is cosmetic and each has a distinct wrong version. @@ -5496,6 +5559,7 @@ fn golden_bond_state_vectors_pin_every_state() { "cursor": {"store_id": BOND_STORE_B, "root": BOND_ROOT_B}, "locked_dig_base_units": 3_094u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, })); // The three states with no payload, plus `reclaiming`, which HAS one because its money is @@ -5514,6 +5578,7 @@ fn golden_bond_state_vectors_pin_every_state() { "cursor": {"store_id": BOND_STORE_B, "root": ROOT}, "locked_dig_base_units": 2_047u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, })); for reason in results::MirrorBondStatesUnknownReason::ALL { @@ -5601,6 +5666,7 @@ fn an_absent_paging_key_never_becomes_a_definite_answer() { "cursor": null, "locked_dig_base_units": 0u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, }); // The control: with both keys present it decodes, so the failures below are about ABSENCE and // not about the rest of the payload. @@ -5635,6 +5701,7 @@ fn the_locked_total_spans_pages_and_is_never_the_page_sum() { "cursor": {"store_id": STORE, "root": ROOT}, "locked_dig_base_units": 5_000u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, }); assert_result_round_trips::(wire.clone()); @@ -5768,6 +5835,7 @@ fn a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page() { "cursor": {"store_id": STORE, "root": BOND_ROOT_B}, "locked_dig_base_units": 1_047u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, }); assert_result_round_trips::(truthful); @@ -5819,6 +5887,7 @@ fn a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page() { "cursor": {"store_id": STORE, "root": ROOT}, "locked_dig_base_units": 1_047u64, "epoch": 7u64, + "funding_wallet": {"state": "known", "address": OPERATOR_ADDRESS, "puzzle_hash": "7c".repeat(32)}, }); let short: results::MirrorBondStatesResult = serde_json::from_value(short_page).unwrap(); assert_ne!(short, parsed); diff --git a/src/results.rs b/src/results.rs index 7b75be9..1d1155c 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2576,6 +2576,35 @@ pub enum MirrorBondStatesResult { /// 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, + /// WHICH WALLET every figure on this page is about. + /// + /// # Why an answer about money must name the wallet + /// + /// Every amount here — each [`Unfunded`](MirrorBondState::Unfunded) shortfall, + /// [`locked_dig_base_units`](Self::Known::locked_dig_base_units), each bonded amount — is a + /// statement about the node's own MACHINE-custody operator wallet, not about the user's. A + /// node reported three bonds `unfunded, short 1010` while its operator's own wallet held + /// 1,015,000 base units of $DIG: both statements were true, each was about a different + /// wallet, and the payload named an amount and no wallet, so nobody could tell. Naming it + /// here is what makes the page self-describing rather than merely correct. + /// + /// # On the ANSWER, not on each row — and the distinction is not cosmetic + /// + /// The funding wallet is node-wide, so a per-row copy would be the same string repeated for + /// every entry: a field that cannot vary, which reads as though it could. Worse, two rows + /// of one answer could then be written to disagree, and a client would have to decide which + /// to believe. One value per answer can be wrong; it cannot be inconsistent with itself. + /// + /// Carried rather than left to `control.wallet.operatorAddress` for the same reason + /// [`epoch`](Self::Known::epoch) is carried: a second call is a second observation, and it + /// may have moved. A page of amounts that requires a follow-up call to learn whose amounts + /// they are can be rendered, screenshotted and acted on before that call returns. + /// + /// The same type `control.wallet.operatorAddress` returns, reused rather than restated, so + /// the two surfaces cannot drift into two spellings of one fact — and so a node with no + /// wallet yet says [`NotInitialized`](WalletOperatorAddressUnavailableReason::NotInitialized) + /// here too, instead of a blank string a client might render as a destination. + funding_wallet: WalletOperatorAddressResult, }, /// The node cannot state the bond states, and names which fact is missing. Unknown {