Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6947cb0
build(deps): adopt control-interface 0.23.0 and the collateral math c…
MichaelTaylor3d Aug 28, 2026
bb4011a
feat(collateral): per-epoch record store, local safety margin, fundin…
MichaelTaylor3d Aug 28, 2026
8596b5e
feat(spend-audit): cursor paging and an explicit completeness flag
MichaelTaylor3d Aug 28, 2026
02bd05c
feat(control): serve spends.list and the three collateral methods
MichaelTaylor3d Aug 28, 2026
7c38016
feat(collateral): the recommended DIG buffer and the three funding st…
MichaelTaylor3d Aug 28, 2026
2c5f86c
docs(spec): SPEC section 24 -- the mirror-coin collateral contract
MichaelTaylor3d Aug 28, 2026
c9e16fd
fix(collateral): derive the epoch from the canonical clock, not a sto…
MichaelTaylor3d Aug 28, 2026
41aef04
docs(spec): SPEC 24.3 follows the epoch clock correction
MichaelTaylor3d Aug 28, 2026
b3399cc
docs(collateral): name the census-vs-local trap the buffer must not f…
MichaelTaylor3d Aug 28, 2026
4cc30fb
style(collateral): satisfy clippy on the buffer summary
MichaelTaylor3d Aug 28, 2026
428280a
test(collateral): pin the rendering half of the never-render-an-unkno…
MichaelTaylor3d Aug 28, 2026
4bc37c8
refactor(collateral)!: conform the buffer to the declared 0.24.0 cont…
MichaelTaylor3d Aug 28, 2026
fb90981
docs(spec): SPEC 24.5/24.6 follow the 0.24.0 buffer contract
MichaelTaylor3d Aug 28, 2026
06839f5
fix(collateral): resolve state paths internally, clearing 3 high Code…
MichaelTaylor3d Aug 28, 2026
fec861c
feat(collateral): adopt control-interface 0.24.0 and serve control.co…
MichaelTaylor3d Aug 28, 2026
4ceea79
feat(cli): dign collateral buffer asks the node, with operands as an …
MichaelTaylor3d Aug 28, 2026
9a2d26b
docs(spec): SPEC 24.5/24.6 follow the served 0.24.0 buffer method
MichaelTaylor3d Aug 28, 2026
8d19002
chore(cli): drop committed splice scratch files and restore spliced d…
MichaelTaylor3d Aug 28, 2026
4e7d6a6
fix(cli): refuse an undecodable collateral answer instead of renderin…
MichaelTaylor3d Aug 28, 2026
89c7267
fix(collateral): an unreadable state directory is not an uncensused e…
MichaelTaylor3d Aug 28, 2026
1befabb
docs(spec): SPEC 24.2/24.7 fix the undecodable-answer and operand-pro…
MichaelTaylor3d Aug 28, 2026
c12224d
fix(collateral): pin the margin and provenance guards, and stop the c…
MichaelTaylor3d Aug 28, 2026
29203a7
chore(release): 0.162.0 — 0.161.0 was taken by #393
MichaelTaylor3d Aug 28, 2026
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
42 changes: 32 additions & 10 deletions 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 @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.161.0"
version = "0.162.0"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
207 changes: 207 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -7364,3 +7364,210 @@ the node is stopped or wedged.
Every verb offers `--json` beside the human output, with stable field names (§6.2). The JSON listing is
`{ path, count, unreadable_lines, spends[] }`, and each spend carries its raw fields plus `status_token`
and `chain_reference`.

## 24. Mirror-coin collateral — the requirement, the local margin, and the funding advice (dig_ecosystem#3173)

An advertisement qualifies for an epoch only if it posts that epoch's **collateral**. This section is the
contract for what the node reports about it. It governs three control methods and three `dign` verbs.

**Every figure MUST come from `dig-mirror-collateral`.** The node MUST NOT restate the model's
arithmetic. `required_per_store` is the WHOLE answer: `equilibrium × multiplier − handicap` omits the
floor clamp, and a re-derivation that omitted it would understate what an advertisement must post.
`apply_safety_margin` rounds UP, and a re-derivation that rounded down would post a base unit short of
qualifying. A second implementation of either is a money-path drift bug.

**Units.** Every amount is **DIG base units**: `1 DIG = 1_000`, smallest amount `0.001 DIG`. They are
never mojos — a mojo is XCH's base unit, `1e-12 XCH`, nine orders of magnitude away. The two units MUST
NOT appear in one expression.

### 24.1. The requirement is CONSENSUS; the margin is LOCAL. They are never one value

`control.collateral.requirement` returns the **pre-margin** per-store requirement, which every node
derives identically. It MUST NOT include the local safety margin, and the margin MUST NOT be reachable
from it: returning the margined amount would present one operator's private preference as the network's
price. The margin is served separately by `control.collateral.margin.get` / `.set`.

The census inputs — `stores`, `owners`, `multiplier_micros`, `handicap_dig_base_units` — travel WITH the
figure. A client holding only the number can say the price moved; a client holding the inputs can say
why. `stores` counts qualifying `(owner, store, root)` **advertisements**, never nodes; `owners` counts
distinct owner puzzle hashes and a surface displaying it MUST say "collateralised owners".

`protocol_version` is the version that **computed** the epoch, read from the record — never the newest
version the build implements. The two differ exactly when a node has upgraded mid-schedule, which is the
one case where a client needs the difference.

### 24.2. UNKNOWN is a first-class answer, and it is never a zero

A node that cannot state the requirement MUST return `state: "unknown"` with a `reason`, and MUST NOT
return a zero, a stale epoch's figure presented as this epoch's, or an error a client would render as
"no collateral required". Under-posting costs the operator that epoch's rewards.

The reasons are distinct because their remedies differ:

| `reason` | meaning | remedy |
|---|---|---|
| `not_censused` | this node holds no record for the epoch | run the census |
| `behind_finality_depth` | the epoch's inputs are not final | wait for the chain to settle |
| `record_unreadable` | a record exists and could not be read | re-run the census for the epoch |
| `no_chain_source` | the node cannot see the chain | configure a chain source |

Collapsing them into one "unavailable" hands every client the same unactionable sentence.

**A record the node never wrote and one it wrote and cannot read are different answers**
(`not_censused` vs `record_unreadable`). This is decided by the record file itself, not only by its
contents: a file that is MISSING is `not_censused`, and a file that EXISTS and cannot be read is
`record_unreadable` even when no line was parsed. Reporting an unreadable state directory as
`not_censused` sends the operator to run a census that writes to the very file it cannot read.

**A client MUST NOT render a requirement it cannot decode as a figure.** `state` is an open tag and
the reason taxonomy is open with it, so a client will meet values it does not know — including from
a node newer than itself, since the CLI and the node are installed separately. A client that guards
positively on the states it knows and formats everything else from absent fields renders a real
epoch beside a zero requirement, which reads as authoritative rather than degraded. An answer this
build cannot decode MUST be reported as undecodable, and MUST NOT borrow the `unknown` rendering
either: `unknown` asserts that the node NAMED a missing fact, which an undecodable answer did not.

### 24.3. The epoch is DERIVED from the canonical clock, and never re-derived locally

The mirror-coin epoch schedule is a **wall-clock** one published by `dig-constants`: 7-day epochs
from a fixed genesis. The current epoch MUST be obtained from
`dig_constants::mirror_epoch_at_unix_ms` and MUST NOT be recomputed. The epoch number is an **input
to coin identity** — `dig_mirror_coin::mirror_hint` takes it — so a node computing a different epoch
than its peers does not display a wrong label, it derives different coins and orphans that epoch's
collateral.

Two properties a plausible reimplementation loses, and both are load-bearing:

* the epoch is **one-based** — the genesis instant is epoch 1, not 0;
* it uses **`div_euclid`**, so an instant one millisecond before genesis is epoch 0 rather than
colliding with epoch 1 as a truncating `/` would.

An instant before genesis yields a non-positive epoch, which is not an epoch. It MUST be reported as
`not_censused` rather than clamped to 1: a machine whose clock is wrong MUST NOT be handed epoch 1's
requirement as though it were current.

**Deriving the epoch is what makes a stale answer unrepresentable.** The requirement is looked up for
the epoch that is current NOW, so a node whose census has stopped running reports `not_censused` for
the present epoch rather than confidently serving a previous epoch's figure. A stored "current
epoch" marker would reintroduce exactly that hazard, because a marker left behind by a stopped census
names an epoch that is no longer current and nothing local can detect it.

### 24.4. The safety margin

Basis points, always — `100` is `+1%`. Never a percentage and never a float: a 1 bp margin (`0.01%`) is a
legal choice and any conversion to whole percent would erase it.

* A stored configuration that **predates** the field MUST load as `DEFAULT_SAFETY_MARGIN_BP` (`100`),
never `0`. A zero margin is a deliberate choice to post the requirement exactly; reporting it for a
configuration that never expressed one tells the operator they declined a cushion they were never
offered.
* `.set` MUST **persist** the value, so it survives a restart. A margin that lapsed to the default on
reboot would silently change what the node posts, so a failed write MUST NOT be reported as a success.
* A value above `MAX_SAFETY_MARGIN_BP` (`10_000`, i.e. `+100%`) is **REFUSED, never clamped**, and `.set`
returns what was actually stored. Clamping and returning the clamped value would leave the caller's
stored intent and the node's behaviour disagreeing on the money path.
* The node is the **authoritative home** for the setting: the flywheel is headless, so a machine with no
GUI MUST be able to set it. dig-app is a remote control for the same value.

### 24.5. The funding advice — how much to hold, and the states

**Collateral is RECLAIMED, not spent.** Each pass creates the coins for `(store, root, epoch n)` and
reclaims epoch `n-1`; reclaims run FIRST and are never gated on funds, so returned collateral funds
the creates behind it. **The steady state is roughly ONE epoch's lock, not one per epoch.** A
recommendation of "requirement x epochs of runway" overstates by the epoch count and tells an
operator to hold many times what they need.

The total is three named terms that sum without double-counting:

```
lock = pairs_served_by_this_node x apply_safety_margin(required_per_store, margin_bp)
overlap = the collateral still locked in the epoch being reclaimed
headroom = what the next `horizon_epochs` could add at the escalation ceiling
recommended = lock + overlap + headroom
```

The **overlap** is the real peak and the term nobody budgets for: epoch `n` exists before `n-1` is
reclaimed, and a reclaim can be delayed or fail.

**`pairs_served_by_this_node` is THIS NODE's own `(owner, store, root)` set.** It MUST NOT be taken
from `control.collateral.requirement`'s `stores` or `owners`, which are network census figures
(§24.1), and it MUST NOT be approximated from the hosted-store list, which is a different set that
merely resembles it. A resemblance is not an identity, and both produce a plausible number.

**Escalation MUST be obtained by stepping `dig_mirror_collateral::step_multiplier` in its high
band**, never from a hand-rolled closed form. A `(9/8)^n` loses two behaviours the controller has:
the step truncates each epoch (0.8x over four epochs reaches 1.281444, not 1.281445), and the result
is clamped at `MULT_CEILING_MICROS`, so a long horizon cannot manufacture headroom the controller
could never produce.

`horizon_epochs` and `escalation_ceiling_micros` are BOTH required alongside the figure. A buffer
without its horizon is a magic number, and a horizon without its ceiling cannot be reproduced. The
ceiling is a **worst case, not a forecast** — inside the dead band the multiplier does not move.

The states, of which only two leave an epoch uncovered:

| state | meaning | `is_shortfall()` |
|---|---|---|
| `short_now` | cannot cover the current epoch; roots are already uncollateralised | **yes** |
| `dangerously_low` | covers now; could not cover the next epoch at the escalation ceiling | **yes** |
| `below_recommended_buffer` | every epoch covered, no cushion | **NO — readout only** |
| `funded` | at or above the recommendation | no |

`below_recommended_buffer` MUST be excluded from `is_shortfall()` and MUST NOT raise a notification.
Every epoch it covers *is* covered; a healthy node sits there much of the time, and a recurring alert
an operator learns to dismiss teaches them to dismiss the two above it.

### 24.6. UNKNOWN must be UNREPRESENTABLE as a number

`control.collateral.buffer` is a **separate method** from `control.collateral.requirement`, not a
widening of it: the requirement is consensus-derived and identical on every node, while the buffer
depends on this node's own served set, an operator preference, and a horizon this node chose. The
funding state is **carried, not re-derived by clients** — two clients deriving it will disagree, and
the one that disagrees about a funding warning is the one an operator acts on.

The buffer answer is a **tagged variant**: the unknown case carries `state` and `reason` and **no
numeric field at all**. This is a shape requirement, not a convention — a struct with optional
numbers can hold a `0`, and a zero buffer reads as *no buffer needed*.

| `reason` | the missing fact |
|---|---|
| `requirement_unknown` | the node cannot state this epoch's per-store requirement |
| `served_set_unknown` | the node cannot enumerate the roots it serves |
| `reclaim_state_unknown` | the node cannot tell which of last epoch's coins are reclaimed |
| `balance_unknown` | the operator's spendable $DIG is not known to this node |

`requirement_unknown` is distinct from the rest and from §24.2's reasons on purpose: a missing
requirement is a NETWORK gap, while the other three are LOCAL. Reporting one as the other sends the
operator to fix the wrong thing.

**None of these has a counterpart in §24.2's census taxonomy**, which is the structural reason the
buffer is its own method rather than a widening of the requirement: collapsing `served_set_unknown`
into `not_censused` reports a missing LOCAL fact as a missing NETWORK one and sends the operator to
fix the wrong thing.

This is a live hazard, not a theoretical one, because **an unknown and a genuine zero produce
identical arithmetic**: a served count read as zero yields a `0.000 DIG` recommendation that every
balance clears, so a node that could not tell how much it owes would report "funded". A surface MUST
also distinguish "nothing to collateralise" from "your funding is sufficient" for the same reason.

A malformed operator-supplied balance is REFUSED, never parsed as zero, which would report
`short_now` over a typo. Amounts are scaled by integer arithmetic: `0.001 DIG` steps are where an
`f64` starts rounding.

### 24.7. The `dign` verbs

* `collateral requirement` — §24.1/§24.2. Prints the reason and its remedy on `unknown`, never a zero.
* `collateral margin [set <tight|default|generous|BP>]` — §24.4. A preset resolves to
`dig-mirror-collateral`'s own constant, never to a number spelled out in the CLI; a second spelling is
how two surfaces post different amounts for one choice. An unrecognised word is REFUSED, never treated
as the default.
* `collateral buffer [--roots <N>] [--balance <DIG>]` — §24.5/§24.6. With no operands it asks the
node, which is the authority on its own served set, preference and balance. The operands are an
OVERRIDE, so a person can get a figure before the node can enumerate its served set; they are never
a fallback the node applies itself. Either way it states an AMOUNT to add, not an adjective, and
shows the working: roots served, per-store requirement, margin, the three terms, and the horizon
with its ceiling. When the root count came from `--roots`, the output MUST say so: the operand and
the node's own measurement otherwise render identically, which would make an operator's guess
indistinguishable from a measurement in every figure derived from it.

Every verb offers `--json` beside the human output, with stable field names (§6.2).
2 changes: 1 addition & 1 deletion crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "48a14ed
# consumer cannot edit. Collapsing to a single copy needs each of those to release against 0.9 —
# the release-first cascade on #2072. Do NOT pin this BACK to widen a window: the fix is upstream
# moving forward, never dig-node drifting backward.
dig-constants = "0.11.2"
dig-constants = "0.13.0"
# -- Portable profile body format (DPB) — epic #3008 W6 -----------------------------------------------
#
# `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/
Expand Down
16 changes: 13 additions & 3 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,17 @@ dig-node-core = { path = "../dig-node-core" }
# "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of --
# it checked nothing about the very method the change added. A caret range keeps the pin moving
# with the published catalog instead of silently narrowing what CI can see.
dig-node-control-interface = "0.21"
dig-node-control-interface = "0.24"

# The deterministic mirror-coin collateral model: the per-epoch requirement, the controller
# multiplier, the small-network handicap, the floor clamp, and the safety-margin arithmetic.
#
# Depended on rather than restated. `required_per_store` is the WHOLE answer -- writing
# `equilibrium x multiplier - handicap` at a call site omits the floor clamp, which is a
# money-path understatement, and a lane has already been refused for putting exactly that
# formula in a SPEC. Every figure this node reports for `control.collateral.requirement`
# comes out of this crate so that a second derivation cannot drift from the consensus one.
dig-mirror-collateral = "0.3"

# The OS CSPRNG for all authorization material — the control token, pairing ids/tokens
# (§7), and the relay loop-probe id (`control::fill_random`). Wraps `getrandom(2)` /
Expand Down Expand Up @@ -162,7 +172,7 @@ serde_json = "1"
# reusable `logs` CLI verbs + the bundle-time redaction engine. Every DIG service binary
# gets logging from HERE rather than hand-rolling a subscriber. Sourced from crates.io (no
# git/path dep) per the ecosystem crates.io policy (#681).
dig-logging = "0.1"
dig-logging = "0.2"
# The logging facade the shell emits through. dig-node-core already depends on `tracing`;
# the service shell now emits its own bring-up/lifecycle events through it, captured by the
# `dig-logging` subscriber installed at the serve entrypoints.
Expand Down Expand Up @@ -195,7 +205,7 @@ dig-urn-resolver = "0.5.3"
# for client→node connections; `config.rs`'s DEFAULT_PORT reads it rather than re-declaring the
# literal. Held in lockstep with dig-node-core's pin (#2072) — 0.11.2, on dig-node's chia 0.36.1
# line (dig_ecosystem#3161) — so the two node crates can never disagree about a canonical value.
dig-constants = "0.11.2"
dig-constants = "0.13.0"

# Windows Service Control Protocol. service-manager only REGISTERS the service in the
# SCM; the binary the SCM launches must itself speak the service protocol
Expand Down
Loading
Loading