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.25.0"
version = "0.26.0"
edition = "2021"
rust-version = "1.75.0"
license = "Apache-2.0 OR MIT"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
88 changes: 88 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` |
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading