Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# is designed, matching the sibling dig-<x>-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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
12 changes: 11 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` |
Expand Down Expand Up @@ -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.

Expand Down
69 changes: 69 additions & 0 deletions src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
})
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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::<results::MirrorBondStatesResult>(wire.clone());

Expand Down Expand Up @@ -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::<results::MirrorBondStatesResult>(truthful);

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading