Skip to content
Draft
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 @@ -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.168.0"
version = "0.169.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
257 changes: 253 additions & 4 deletions SPEC.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ dig-mirror-coin = "0.7"
# to the line `chia-query` and `dig-mirror-coin` compiled against, so all three unify on one crate.
dig-chainsource-interface = "0.3"

# The chia types that reach `mirror::spends`' PUBLIC signatures: `dig_mirror_coin::create` takes
# `Vec<Cat>`, a `PublicKey`, `Coin` fee inputs and a `BigInt` epoch, and `build_create`/
# `build_reclaim` pass them straight through. They were dev-dependencies while the module was
# unwired; a public signature cannot be spelled in a dev-dependency.
#
# The whole chia set moves TOGETHER and every version here is the one `dig-mirror-coin` 0.7 and
# `dig-wallet` compile against. A crate split across two chia lines compiles until something
# crosses a public signature -- which is exactly what these four do -- and then the `Bytes32` the
# builder wants is a different type from the one the caller holds.
chia-bls = "0.36.1"
chia-protocol = "0.36.1"
chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] }
# The epoch term of a mirror hint is a `BigInt`, not a `u64`: the morph is arithmetic over
# 32-byte values and the crate's API says so. Same line as `dig-mirror-coin`'s own.
num-bigint = "0.4.6"

# 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)` /
# `/dev/urandom` on Unix and `BCryptGenRandom` on Windows, one code path on every
Expand Down
55 changes: 53 additions & 2 deletions crates/dig-node-service/src/collateral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,39 @@ pub struct CollateralConfig {
/// inventing a decision the operator never made.
#[serde(default)]
pub retention_epochs: Option<u64>,

/// Whether the mirror-coin lifecycle may CREATE bonds (`SPEC.md` §25.7).
///
/// **It gates creates only. Reclaims run regardless**, which is why turning this off releases
/// locked collateral instead of freezing it: OFF forces the desired bond set empty, and the
/// ordinary pass then reclaims every live coin. A switch that stopped reclaims would strand the
/// user's $DIG behind their own decision to stop, which inverts the meaning of revoking.
///
/// Default-on, and `default` rather than required, for the same reason the two fields above
/// are: a config written before this field existed expressed no preference, and a node that
/// silently stopped collateralising on upgrade would go undiscoverable without saying so. The
/// consent model that makes default-on honest is §25.7's — disclosed, bounded, fully audited,
/// one setting to turn off.
#[serde(default = "default_mirror_enabled")]
pub mirror_enabled: bool,
}

fn default_margin_bp() -> u64 {
SAFETY_MARGIN_BP_DEFAULT
}

fn default_mirror_enabled() -> bool {
true
}

impl Default for CollateralConfig {
fn default() -> Self {
CollateralConfig {
margin_bp: SAFETY_MARGIN_BP_DEFAULT,
// Keep everything. See the field's own documentation for why this is not a tuning
// choice.
retention_epochs: None,
mirror_enabled: default_mirror_enabled(),
}
}
}
Expand Down Expand Up @@ -1575,6 +1595,36 @@ mod tests {
}
}

/// A config written before `mirror_enabled` existed still collateralises.
///
/// The fixture is an EMPTY object rather than one naming the field, because that is what an
/// upgraded node actually has on disk, and it is the only input that can tell `#[serde(default)]`
/// (which would read `false`) from `default = "default_mirror_enabled"` (which reads `true`).
/// The false reading is silent: the node stops advertising every store it serves and reports no
/// error, because declining to collateralise is a legitimate state.
///
/// The second half is the control — an explicit `false` must still be honoured, or the field is
/// not a switch at all.
#[test]
fn a_config_predating_the_switch_still_collateralises_and_an_explicit_off_is_honoured() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(COLLATERAL_CONFIG_FILE), b"{}").expect("write");
assert!(
CollateralConfig::load_from(dir.path()).mirror_enabled,
"an upgraded node must not silently stop advertising what it serves"
);

std::fs::write(
dir.path().join(COLLATERAL_CONFIG_FILE),
br#"{"mirror_enabled":false}"#,
)
.expect("write");
assert!(
!CollateralConfig::load_from(dir.path()).mirror_enabled,
"and an operator who turned it off stays turned off across a restart"
);
}

#[test]
fn a_config_that_expresses_no_retention_keeps_everything() {
let dir = tempfile::tempdir().expect("tempdir");
Expand All @@ -1587,8 +1637,9 @@ mod tests {
// Zero is not a retention of nothing. Honouring it would delete the epoch in force.
assert_eq!(
CollateralConfig {
retention_epochs: Some(0),
margin_bp: 0,
retention_epochs: Some(0)
..CollateralConfig::default()
}
.retention(),
RetentionPolicy::KeepEverything
Expand Down Expand Up @@ -1720,7 +1771,7 @@ mod tests {
// pass if the fixture used the default.
CollateralConfig {
margin_bp: 250,
retention_epochs: None,
..CollateralConfig::default()
}
.save_to(dir.path())
.expect("save");
Expand Down
5 changes: 5 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ pub mod logging;
/// `127.0.0.1` from the rest of the machine. See [`loopback`].
pub mod loopback;
pub mod meta;
/// The mirror-coin lifecycle (dig-node#377, `SPEC.md` §25): presence of a `.dig` on disk drives
/// creation of an on-chain mirror coin locking the epoch's required $DIG for that
/// `(store, root, epoch)`, and its disappearance drives reclaim of the collateral. The node signs
/// those spends itself with its own operator wallet, scoped by construction. See [`mirror`].
pub mod mirror;
/// `dig-node open <chia://… | urn:dig:chia:…>` (#389): the OS scheme-handler target the
/// installer registers for `chia://` + `urn:dig:chia:`. Strictly validates the untrusted
/// handler argument, then opens the user's default browser at the resolving URL. See [`open`].
Expand Down
77 changes: 77 additions & 0 deletions crates/dig-node-service/src/mirror/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! The mirror-coin lifecycle (dig-node#377) — presence of a `.dig` on disk, made true on chain.
//!
//! A **mirror coin** locks $DIG to advertise that this node serves one `(store, root)` for one
//! epoch. This module is what keeps that advertisement honest: a capsule this node holds and is
//! willing to serve gets a coin, and a coin whose capsule is gone gets reclaimed. `SPEC.md` §25 is
//! the normative contract; this module doc says why the shape is what it is.
//!
//! # Reclaim is loss avoidance, not cleanup
//!
//! A live coin advertising a capsule the node cannot serve is **penalised later**. So the reclaim
//! path is not tidying up after the interesting work — it is the half where money is at stake, and
//! it is held to a higher standard than the create path (§25.9):
//!
//! * **Reclaims run first in every pass.** A reclaim returns collateral, which may fund the creates
//! behind it, and a reclaim withheld because the wallet is short is the legacy defect where a
//! wallet at zero could neither advertise nor recover what it had already locked.
//! * **Reclaims are never gated on funds.** [`plan::split_by_funds`] never sees them.
//! * **Reclaims never wait on the collateral requirement.** A reclaim's amount is read from the coin
//! being reclaimed, so recovering money does not depend on a census answering (§25.3).
//! * **The start-up reconcile is the reliable path, not the file watcher.** A watcher's event is
//! exactly what a crash loses; a scan at start-up re-derives the whole answer from two
//! observations that survive anything.
//!
//! # The node signs these itself — with its OWN operating wallet, scoped by construction
//!
//! §908 says the node signs nothing **on the user's behalf**, and it still holds no user seed and no
//! user spend key. That is untouched here. What signs a mirror spend is the node's own **operator
//! wallet** (`SPEC.md` §16.4 autoseed, sealed under the device key) — machine custody, not user
//! custody — which §23 already permits to sign certain spends automatically, and which the shipped
//! auto-tipping path (§18.23) already uses for unattended $DIG spends.
//!
//! A *separately derived* mirror key was considered and rejected. The machine identity seed is the
//! node's **network** identity (peer_id/TLS), not money custody; and a second fundable address would
//! split the user's deposit across two wallets that no balance surface can see, or else require an
//! automated wallet-to-wallet transfer — strictly *more* unattended signing than it prevents.
//!
//! Scope is therefore held by a **type**, not by a runtime check or by key hygiene.
//! [`spends::MirrorSpends`] has no public constructor; its only producers wrap
//! `dig_mirror_coin::create` and `::reclaim`, and the signer's only entry point takes one. There is
//! no method anywhere on this path that accepts an arbitrary `CoinSpend`, so the reachable spend
//! shapes are mirror-coin create and mirror-coin reclaim **by construction**. The signer instance is
//! module-private and is never installed on the general `WalletBackend`, so wiring it does not
//! change what any other surface — including default-on auto-tipping — is able to sign.
//!
//! # Accountability is what pays for it
//!
//! Because the user cannot approve each spend, they are owed a complete account of every spend made
//! without asking. The signer takes a
//! [`RecordedSpend`](crate::spend_audit::RecordedSpend) (dig-node#376), whose only source is
//! [`SpendJournal::begin`](crate::spend_audit::SpendJournal::begin) — so recording is the SHAPE of
//! the call rather than a convention a later producer can forget.
//!
//! # Nothing here re-derives the epoch, the hint, or the amount
//!
//! The epoch comes from `dig_constants::mirror_epoch_at_unix_ms` and the hint from
//! `dig_mirror_coin::mirror_hint`. Both are canonical, and a locally computed version of either
//! would put coins under a value no verifier queries — collateral that is genuinely locked and
//! genuinely invisible.
//!
//! The **amount is derived per epoch** and is never a constant here: it is
//! `dig_mirror_collateral::margin::apply_safety_margin(required_per_store, margin_bp)` for the
//! current epoch, obtained through the requirement machinery `SPEC.md` §24 describes. `dig-constants`
//! carried a fixed `MIRROR_COIN_COLLATERAL_DIG = 20` until 0.13.0 removed it as a twentyfold error on
//! a real-money path — the schedule starts at **1.000 DIG** per `(store, root)`. Restating the
//! model's arithmetic is equally forbidden: `required_per_store` is the whole answer, and the formula
//! as usually written omits its floor clamp.
//!
//! All amounts in this module are **DIG base units** (1 DIG = 1_000), and every name says so. A
//! mirror amount is never "mojos" — a mojo is XCH's base unit, nine orders of magnitude away, and
//! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named
//! `*_mojos` and come from separate coins so a fee can never shave collateral.

pub mod pass;
pub mod plan;
pub mod presence;
pub mod signer;
pub mod spends;
Loading
Loading