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.27.0"
version = "0.28.0"
edition = "2021"
rust-version = "1.75.0"
license = "Apache-2.0 OR MIT"
Expand Down
12 changes: 11 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,7 @@ when it is not — WHY, together with the $DIG those bonds have locked. It is th
`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
**Its whole purpose is that "no coin yet" is never one answer.** Seven of the eight 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.
Expand All @@ -1104,8 +1104,18 @@ alarms about a perfectly healthy node, which is the defect this method exists to
| `deferred` | `reason` | the epoch requirement is unknown, so no create can be PRICED — including `balance_unreadable`, where the node cannot read its own $DIG balance | 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 |
| `unadvertised` | — | collateralisation is ON, but the node has no publishable advertise URL, so it advertises nothing and a coin would bond nothing | publish an advertise URL |
| `reclaiming` | `coin_id`, `epoch`, `amount_dig_base_units` | a live coin is being reclaimed; the money is STILL LOCKED | wait |

**`disabled` and `unadvertised` are node-wide, and only ONE of them is a fault.** Both mean every row
reads the same token together and no coin exists for any bond. They differ in whether the operator
already knows: `disabled` is that operator's own switch, and a client MUST NOT present it as a fault,
while `unadvertised` is that switch ON and the node silently unable to honour it because no entry in
its advertise-URL list is publishable. A client MUST surface `unadvertised`, and an implementation MUST
NOT serve it as `disabled` -- doing so obliges a conforming client to stay silent about the one thing
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.

**`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
37 changes: 35 additions & 2 deletions src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4891,6 +4891,38 @@ 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 node with nothing publishable to advertise is `unadvertised`, and that is NOT `disabled`.**
///
/// A node whose advertise-URL list is empty (or whose every entry was rejected) creates no mirror
/// coin, no matter how much $DIG it holds and no matter that its own collateralisation switch is
/// ON. dig-node reported that as [`Unfunded`](results::MirrorBondState::Unfunded) and sent an
/// operator to buy $DIG that would have bonded nothing.
///
/// The nearest wrong implementation maps it onto
/// [`Disabled`](results::MirrorBondState::Disabled) — the only pre-existing variant that does not
/// name a figure. That is why this fixture asserts BOTH halves rather than the new token alone:
/// `disabled` means the operator's own switch and this contract forbids a client presenting it as a
/// fault, so serving it here would oblige a conforming client to stay silent about the one thing
/// actually wrong. The two tokens must differ, and the difference is the assertion.
#[test]
fn an_unadvertised_bond_is_its_own_state_and_not_disabled() {
let unadvertised = serde_json::to_value(results::MirrorBondState::Unadvertised).unwrap();
let disabled = serde_json::to_value(results::MirrorBondState::Disabled).unwrap();

assert_eq!(
unadvertised,
serde_json::json!({ "bond_state": "unadvertised" }),
"the token is the contract, and it is a bare tag with no payload"
);
assert_ne!(
unadvertised, disabled,
"a node whose switch is ON must not be reported under the switch's own token"
);

let parsed: results::MirrorBondState = serde_json::from_value(unadvertised).unwrap();
assert_eq!(parsed, results::MirrorBondState::Unadvertised);
}

#[test]
fn a_balance_unreadable_bond_is_deferred_not_unfunded() {
let state = results::MirrorBondState::Deferred {
Expand Down Expand Up @@ -5411,6 +5443,7 @@ fn the_bond_surface_wire_tokens_are_unique() {
},
results::MirrorBondState::Withheld,
results::MirrorBondState::Disabled,
results::MirrorBondState::Unadvertised,
results::MirrorBondState::Reclaiming {
coin_id: BOND_COIN_B.into(),
epoch: 6,
Expand All @@ -5426,10 +5459,10 @@ fn the_bond_surface_wire_tokens_are_unique() {
.to_owned()
})
.collect();
assert_eq!(tokens.len(), 7, "all seven states must be represented");
assert_eq!(tokens.len(), 8, "all eight states must be represented");
tokens.sort();
tokens.dedup();
assert_eq!(tokens.len(), 7, "bond-state tokens collide");
assert_eq!(tokens.len(), 8, "bond-state tokens collide");
}

/// **An ABSENT `cursor` or `complete` must FAIL to decode — neither may collapse into a value.**
Expand Down
30 changes: 27 additions & 3 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2209,9 +2209,10 @@ pub struct SpendsListResult {
///
/// # 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
/// Seven of these eight 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,
/// publish an advertise URL, 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.
///
Expand All @@ -2228,6 +2229,10 @@ pub struct SpendsListResult {
///
/// - [`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.
/// - [`Unadvertised`](Self::Unadvertised) is that switch being ON while the node still has nothing
/// publishable to advertise. It is node-wide like `disabled` and is deliberately NOT the same
/// value: `disabled` is the operator's own decision and MUST NOT be shown as a fault, so a node
/// served under it would oblige a conforming client to stay silent about a real failure.
/// - [`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.
///
Expand Down Expand Up @@ -2297,6 +2302,25 @@ pub enum MirrorBondState {
/// 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,
/// This node has nothing publishable to advertise, so it advertises nothing and creates no
/// mirror coin.
///
/// The node holds the capsule, its own collateralisation switch is ON, its wallet may be full
/// and the epoch's requirement may be perfectly well known. It simply has no advertise URL a
/// peer could fetch from -- the list is empty, or every entry in it was rejected as
/// non-absolute or reachable only from this machine -- and a mirror coin that advertised no
/// URL would bond nothing.
///
/// **A client MUST surface this as a fault.** That is the whole difference between it and
/// [`Disabled`](Self::Disabled), which is also node-wide and also means "no coin", but is the
/// operator's own earlier decision and MUST NOT be presented as one. Here the operator decided
/// the opposite -- the switch is ON -- and the node is silently unable to honour it. The remedy
/// is a publishable advertise URL, and it is the only remedy: sending $DIG changes nothing,
/// which is why serving this as [`Unfunded`](Self::Unfunded) is a false statement about money.
///
/// Node-wide like `disabled`: every row reads `unadvertised` together, because the URL list is
/// one list for the node rather than a property of any capsule.
Unadvertised,
/// 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
Expand Down
16 changes: 12 additions & 4 deletions src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,11 +559,19 @@ pub trait ControlHandler: Sync {
///
/// An implementation MUST:
///
/// - **Keep the seven states apart.** `unfunded` is the only genuine out-of-funds state.
/// - **Keep the eight 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.
/// (`Relayed` provenance), `disabled` (the node-wide switch) and `unadvertised` (that switch
/// ON, but nothing publishable to advertise) 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.
/// - **Answer [`Unadvertised`](results::MirrorBondState::Unadvertised) -- never
/// [`Disabled`](results::MirrorBondState::Disabled) -- when the node's own switch is ON and
/// its advertise-URL list has no publishable entry.** The two are both node-wide and both
/// mean "no coin", but they differ in REMEDY and in whether anything is wrong: `disabled` is
/// the operator's own decision and a client MUST NOT present it as a fault, so serving it
/// here would oblige a conforming client to stay silent about the single reason the node
/// bonds nothing.
/// - **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.
Expand Down
Loading