From c1093be4b1cc7ad059e50b8d0645bce37e355ee0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 04:18:26 -0700 Subject: [PATCH 01/19] chore(mirror): open step 7 lane (observation + DHT pointer) --- .step7-wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 .step7-wip diff --git a/.step7-wip b/.step7-wip new file mode 100644 index 00000000..a5ff0e78 --- /dev/null +++ b/.step7-wip @@ -0,0 +1 @@ +step 7 lane: mirror bond observation + DHT pointer. WIP. From 2614d07c450e2cdec4f36212b1f326037a64b4cc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 04:29:09 -0700 Subject: [PATCH 02/19] build(deps): take the peer stack's ^0.21 tier now dig-gossip v0.32.0 clears dig-nat --- Cargo.lock | 114 +-- Cargo.toml | 52 +- crates/dig-node-core/Cargo.toml | 1175 ++++++++++++++++--------------- 3 files changed, 647 insertions(+), 694 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1daa60e9..5e867b9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1342,21 +1342,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "chia-sdk-utils" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7831343041ceb5f170ef4ff8d8c2b91c055ab75f8a8e075e8bef2c423a0c47c" -dependencies = [ - "bech32", - "chia-protocol 0.36.1", - "hex", - "indexmap", - "rand 0.9.5", - "rand_chacha 0.9.0", - "thiserror 2.0.20", -] - [[package]] name = "chia-sdk-utils" version = "0.36.0" @@ -1963,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2601,7 +2586,7 @@ dependencies = [ "chia-sha2 0.36.1", "chia-traits 0.36.1", "chia_streamable_macro 0.36.1", - "dig-message 0.7.0", + "dig-message", "sha2 0.10.9", ] @@ -2662,9 +2647,9 @@ dependencies = [ [[package]] name = "dig-dht" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c688e8e15124843953e52d75e8f9b114e18cb1c7351fb87e84fd9c2e4acea7a" +checksum = "74e4c62744a1051edd768adcae93c20e3723acde53f7ffe20a4cefcfbec466b3" dependencies = [ "async-trait", "dig-ip", @@ -2697,12 +2682,12 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be658f713ebd801a0fa7fe3e9756e34ffae8dd1239cd7adee029b59a86076876" +checksum = "7c187e30254a3a94448c76f6b58b384239e28bd002f3970cdb459cedde79c026" dependencies = [ "async-trait", - "dig-constants 0.10.1", + "dig-constants 0.11.2", "dig-dht", "dig-nat", "dig-peer", @@ -2729,8 +2714,8 @@ dependencies = [ [[package]] name = "dig-gossip" -version = "0.30.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=48a14ed2ed395a41c913d5c8f944e6ea0b747075#48a14ed2ed395a41c913d5c8f944e6ea0b747075" +version = "0.32.0" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee#1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee" dependencies = [ "bincode 1.3.3", "chia-bls 0.36.1", @@ -2763,21 +2748,6 @@ dependencies = [ "x509-parser", ] -[[package]] -name = "dig-identity" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa7453530fe41c51d40100b0a68f77bab0b523ddbeb0e6514e7efbb2360b5472" -dependencies = [ - "blst", - "chia-bls 0.36.1", - "chia-protocol 0.36.1", - "chia-sdk-utils 0.34.0", - "sha2 0.10.9", - "sparse-merkle-tree", - "thiserror 2.0.20", -] - [[package]] name = "dig-identity" version = "0.7.1" @@ -2888,26 +2858,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "dig-message" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b6907ad0a2fab0f3c3af42825a72732a10dd5e541e127b4d6c440e53dd9a072" -dependencies = [ - "chacha20poly1305", - "chia-bls 0.36.1", - "chia-protocol 0.36.1", - "chia-sha2 0.36.1", - "chia-traits 0.36.1", - "chia_streamable_macro 0.36.1", - "dig-identity 0.6.0", - "getrandom 0.2.17", - "hkdf", - "sha2 0.10.9", - "thiserror 1.0.69", - "zstd", -] - [[package]] name = "dig-message" version = "0.7.0" @@ -2920,7 +2870,7 @@ dependencies = [ "chia-sha2 0.36.1", "chia-traits 0.36.1", "chia_streamable_macro 0.36.1", - "dig-identity 0.7.1", + "dig-identity", "getrandom 0.2.17", "hkdf", "sha2 0.10.9", @@ -2963,15 +2913,15 @@ dependencies = [ [[package]] name = "dig-nat" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4788ac71fdb3cfb4deee26886ad21f0a2592016d2a383e9f68cf47ba4d94a6e9" +checksum = "f32d4a8ddbb282a8326dfa4a23d1af37f3d6fdd9d66bd67cba6b7e46658cb915" dependencies = [ "arc-swap", "async-trait", "base64", - "dig-constants 0.10.1", - "dig-identity 0.6.0", + "dig-constants 0.11.2", + "dig-identity", "dig-ip", "dig-tls", "futures", @@ -3033,12 +2983,12 @@ dependencies = [ "dig-dht", "dig-download", "dig-gossip", - "dig-identity 0.7.1", + "dig-identity", "dig-ip", "dig-ipc-protocol", "dig-keystore", "dig-keystore-hardware", - "dig-message 0.7.0", + "dig-message", "dig-nat", "dig-peer", "dig-peer-selector", @@ -3165,13 +3115,13 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788bbfa5f64c30f793799daaef4695f7f1ade876968e64e0e67add19981da1b6" +checksum = "80e4e58ca84bd44119c829273f6badc92f42ce1e8a3191a756cfdbef4523d9e4" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", - "dig-message 0.6.1", + "dig-message", "dig-nat", "dig-rpc-protocol", "dig-tls", @@ -3205,9 +3155,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1207adf6950888ed3e3fb1ac6e2c75bb56f9f29442363ca296a0740c6d5662" +checksum = "c21c975473af910434801a34f8437037f44cd5204ebaae1389e519524414eb3b" dependencies = [ "dig-dht", "dig-nat", @@ -3760,7 +3710,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3908,7 +3858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4535,7 +4485,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4766,7 +4716,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5100,7 +5050,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5711,7 +5661,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -5749,9 +5699,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6501,7 +6451,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7213,7 +7163,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8347,7 +8297,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f3e19147..0bdc5d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,35 +56,31 @@ overflow-checks = true # dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. # dig-constants is likewise a plain crates.io dep everywhere now. # -# The RESOLVED line today is dig-nat **0.18.0** (see `crates/dig-node-core/Cargo.toml`, which owns the -# declaration and the reasoning). Do not read a version out of this comment: an earlier revision of it -# asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", which was true of the #1280 cascade and -# was eleven minors stale by the time anyone read it. `tests/dependency_tree.rs` asserts the -# single-instance invariant against the resolved LOCK, which is the only claim that cannot go stale. +# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance +# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. +# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", +# which was eleven minors stale by the time anyone read it. # -# THE PEER STACK IS NOW ON THE 0.36.1 LINE (dig_ecosystem#3161, 2026-08-26). +# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). # -# The wall this comment used to describe was chia-bls: every published stack member required -# `dig-nat ^0.20` -> `dig-tls ^0.4`, which is the chia-bls 0.36.1 uplift, while dig-gossip was pinned -# at a rev reaching chia-protocol 0.26 through `dig-peer-protocol 0.6.0`. Adopting any of them split -# chia-bls across two lines inside one crate. +# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit +# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the +# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through +# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same +# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while +# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, +# which cleared it.** # -# The predicted unblock is exactly what happened, and it happened UPSTREAM: dig-gossip `main` -# (v0.30.0) moved to `dig-peer-protocol 0.7` and chia 0.36.1. Repointing the git rev in -# `crates/dig-node-core/Cargo.toml` is what let the stack move; nothing was shimmed, and nothing in -# this file needed to change to allow it. +# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, +# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to +# exactly the line count they had before the move. # -# The resolved stack is dig-nat 0.20, dig-dht 0.12.1, dig-download 0.20.4, dig-peer 0.12.0, -# dig-peer-selector 0.9.2 -- the ^0.20 TIER, deliberately not the newest. -# -# WHY THE OLDER TIER IS THE CORRECT CHOICE, and the distinction a future lane will get wrong: the -# step from dig-nat 0.20 to 0.21 carries ZERO chia delta (both take `dig-tls ^0.4`). It is still not -# takeable, for a reason one level up -- dig-gossip v0.30.0 declares `dig-nat ^0.20`, while -# dig-dht 0.13 / dig-peer 0.13 / dig-download 0.21 / dig-peer-selector 0.10 all require `^0.21`. -# Taking the newer tier resolves TWO dig-nat lines while cargo prints success, MEASURED here, and -# dig-nat values cross from this crate into dig-download/dig-gossip/dig-peer-selector. One line per -# family beats the highest version numbers (CLAUDE.md §2.4b). -# -# So "0.21 is chia-blocked" is FALSE and "dig-node cannot reach 0.21" is still TRUE. The remaining -# unblock is a dig-gossip release against `dig-nat ^0.21`, upstream, never an edit in this file. Do -# not bridge two lines with a shim -- that is the §4.1 byte-drift class. +# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has +# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of +# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. +# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht +# values cross from this crate into both of them. dig-dht 0.15 carries +# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a +# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an +# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and +# bridging two lines with a shim is the §4.1 byte-drift class. diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 21a5ecf4..00169914 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -1,584 +1,591 @@ -[package] -name = "dig-node-core" -# The engine library's OWN version, independent of the released binary's (`[workspace.package].version` -# in the root manifest, which only dig-node-service tracks). It moves when this crate's PUBLIC surface -# moves: 0.24.0 acknowledges that the surface now exposes dig-nat 0.14 / dig-dht 0.8 types, a -# compatibility-relevant change even though the crate is consumed in-workspace and is not published to -# crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for -# what the exported types are (dig_ecosystem#1785c). 0.26.0 does the same for the 0.15 line: the -# exported dig-nat / dig-dht types rotate identity again, and `DhtError` — reachable through this -# crate's dig-dht re-exports — now carries `SafeText` instead of `String` (dig_ecosystem#1674/#1675). -# 0.27.0 adds the pure `relevance` module (CacheTier / relevance() / evict_key / should_displace) — -# a new, backwards-compatible public export (dig_ecosystem#1986), hence a MINOR bump. -# 0.28.0 adds the pure `tier0_selector` module (Candidate / select_within_budget / tier0_budget_bytes / -# should_displace_tier0) built on 0.27's relevance module — a new, backwards-compatible public export -# (dig_ecosystem#1988), hence another MINOR bump. -# 0.31.0 adds cache observability to `cache.stats` (§7.10e) — `refetch_count` + per-tier `tiers` -# occupancy fields, and makes `InboundDemand::entry_count` a real (no-longer-`#[cfg(test)]`) public -# API — a new, backwards-compatible surface (dig_ecosystem#1991), hence a MINOR bump. -# 0.47.0 adopts dig-gossip 0.25.0 and splits the announce seams by ORIGIN: `ProfileTransport` gains a -# required `announce_root_local` and `MeltBroadcast` a required `broadcast_local`, so a locally -# originated announce takes dig-gossip's dedup-exempt path and a re-announce of an unchanged root is -# no longer suppressed forever (dig_ecosystem#3061). Adding a required trait method is BREAKING for an -# out-of-workspace implementor; this crate is consumed in-workspace only and is pre-1.0, so it is a -# MINOR bump under SemVer's 0.x rule -- recorded here rather than letting the number imply the trait -# surface held still. -# 0.53.0 adds the module-granularity capsule relay leg (`seams/dig_peer/module_relay.rs`, gated OFF -# by default) and makes the provider/candidate locator readers absence-safe: `NodeContent::find_providers` -# and `NatModuleTransport::discovered_candidates` now return `BestEffort` rather than a bare `Vec`, -# so a source that could not be consulted can no longer be read as a proven absence (dig_ecosystem#3128, -# dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; -# this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x -# rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.62.0" -edition = "2021" -license = "GPL-2.0-only" -description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." - -[lib] -name = "dig_node_core" -path = "src/lib.rs" - -[dependencies] -# Bounds the whole-module descriptor memo (`seams/dig_peer/module_serve.rs`, #1615/G2): a per-process -# cache of `dig.getModuleInfo` answers keyed by (store, root), capped so a long-running node cannot -# accumulate one entry per module it has EVER described (each entry is tens of KB — up to 512 -# chunk_hashes + chunk_lens). Already a transitive dependency (pulled in via the workspace's other -# crates); promoted to a direct one now that this crate uses it itself. -lru = "0.12" -# The `.dig` STORE-FORMAT LIBRARY crates. These live in the digstore repo (the store -# CLI + format/read libraries) and are consumed here as GIT dependencies: the node -# depends on the store libs, NEVER the reverse (digstore is only ever an RPC client of -# a node). Pinned to a single digstore rev so the whole store-format surface the node -# reads/verifies/decrypts against is one coherent version. Bump all seven together. -# -# digstore-host — instantiate a compiled module + `serve_blind` (ciphertext + proof). -# digstore-core — wire types (ContentResponse), codec, Bytes32/48, the PublicManifest -# type + `datasection::read_public_manifest` (#176 Phase C). -# digstore-remote — the §21 remote client + persistent identity signer (§21.9). -# digstore-chain — CHIP-0035 singleton lineage walk (chain-anchored root). -# digstore-stage — the shared stage→compile engine for the `dig.stage` RPC (turn a -# folder into a capsule in-process, #95 Pass C). Its build script -# embeds the guest wasm (BINDING contract D6) via the DIGSTORE_GUEST_WASM -# override this repo sets in .cargo/config.toml (see vendor/). -# digstore-crypto — read-path crypto primitives. -# digstore-compiler — `extract_data_section_blob` (#176 Phase C): pulls the embedded DIGS -# data-section blob out of a compiled `.dig` module's wasm binary so -# `dig.getManifest` can read section 13 (the PublicManifest) WITHOUT a -# wasmtime instantiation (the manifest is PUBLIC, unencrypted data — no -# `serve_blind` decrypt needed). Already a transitive dependency via -# digstore-stage; pinned directly at the SAME rev so its public API is -# usable here too. -# -# Bumped to a rev past digstore's "Add normalized public manifest to the .dig format" -# commit (08f5d39) so `PublicManifest`/`read_public_manifest`/`extract_data_section_blob` -# exist. -# -# Bumped again to 5872240f (digstore v0.19.2, fix #1473) to adopt the HARDENED, -# unforgeable `digstore_chain::singleton::verify_pinned_root`. The rev before that (d5e52fb) -# anchored store identity on the attacker-controllable curried `SingletonStruct.launcher_id`, -# so a forged singleton currying `launcher_id == store_id` (hinted to store_id) could pass as -# chain-verified. 5872240f anchors identity on the UNFORGEABLE launcher coin (`coin_id == -# store_id`) via a bounded backward `parent_coin_info` walk, making the read-root anti-rollback -# check (#747/#841/#852-node) SOUND. -# -# Bumped again to the CURRENT rev, 161c2a3108cb4bb6c8791e96a26588ec99afb029 (digs#59), which is a -# descendant of 5872240f — so the launcher-coin anchoring above still holds — and additionally -# carries digstore-chain onto `chia-wallet-sdk` 0.36. See the SDK note below for why that bump is -# a security fix rather than housekeeping. -# -# Both bumps leave the public signature unchanged (a pure rev bump). Bump all seven together. -# NC-7 exception: dig_ecosystem#901 (the crates.io repoint) leaves the seven `digstore-*` deps below -# git-pinned ON PURPOSE. Three independent facts each block the repoint on their own, and all three are -# upstream of this workspace: -# -# 1. THE SET IS ATOMIC, so `digstore-core` cannot move alone even though it IS published (0.28.0). -# Inside the digstore workspace every sibling declares `digstore-core = { path = "../digstore-core" }`, -# so a git-sourced `digstore-host` always carries the git `digstore-core`. Repointing only the -# published members would put TWO `digstore-core` in the graph — a crates.io one for this crate's -# direct imports and a git one inside its siblings — and `ContentResponse`/`Bytes32` would stop -# unifying across the serve seam. They move together or not at all. -# -# 2. MOST OF THE SET IS UNPUBLISHED, and the closure is wider than it looks. Absent from crates.io: -# `digstore-host`, `digstore-remote`, `digstore-stage`, `digstore-crypto`, `digstore-compiler` — -# plus `digstore-prover`, `digstore-store`, `digstore-chunker` and `digstore-cli`, which the five -# pull in transitively and which no checklist had named. Each declares its siblings as a BARE -# `path = "../x"` with no `version` key, which `cargo publish` refuses outright, so publishing them -# is real work in DIG-Network/digs rather than a release button. -# -# 3. (CLEARED 2026-08-26, dig_ecosystem#3161.) This used to read "the published members are on the -# OTHER chia line", because digstore declared the primitives at 0.36.1 while this workspace was -# held at 0.26 by dig-gossip -> dig-peer-protocol 0.6. Both halves moved: the dig-gossip rev now -# carries 0.36.1, and the digstore rev below is digstore's own ceiling fix. The primitives are -# one line across the boundary, so nothing is split. -# -# The SDK family has now collapsed too (2026-08-26, dig-node#374). This used to read that -# digstore's rev took `chia-wallet-sdk 0.34` against dig-wallet's 0.36.0, and that closing the -# gap needed a digstore release — upstream, not here. That release happened: DIG-Network/digs#59 -# moved `digstore-chain` onto `chia-wallet-sdk 0.36` (and `datalayer-driver` 5, which is what -# required the SDK bump), and the rev below is that commit. -# -# Repointing was a SECURITY fix, not tidiness. `chia-sdk-driver` 0.30 and 0.34 decode the oracle -# fee out of an attacker-authored CREATE_COIN memo as -# `BigInt::from_signed_bytes_be(&memo).to_u64_digits().1[0]`, and an empty or explicit-zero memo -# makes that index an empty vec — a panic reachable from any spend dig-node walks through -# `Datastore::from_spend` (the lineage walk, `verify_pinned_root`, `sync_datastore`). 0.36 guards -# the empty case and uses `try_into`, so both become `DriverError::InvalidMemo`; it also stops -# silently discarding a negative memo's sign and truncating an over-`u64` fee to its low digit. -# -# So this row is still downstream of publishing the digstore workspace. Do NOT repoint these -# piecemeal, and never let the pin regress to a floating git BRANCH: the single `rev` is what makes -# the whole store-format surface reproducible. -digstore-host = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-core = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-remote = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-chain = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-stage = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-crypto = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-compiler = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -chia-protocol = "0.36.1" -# The `Streamable` to_bytes/from_bytes trait, used to serialize the chat `ChatMessage` payload before -# the dig-message seal. Same 0.36.1 as chia-protocol so the byte layout agrees across the workspace. -chia-traits = "0.36.1" -# The ecosystem's canonical address-family / IPv6-first primitive (CLAUDE.md §5.2). The advertise -# path keys + orders candidate addresses via `dig_ip::Family` and aggregates them source-tagged + -# de-duplicated via `dig_ip::PeerCandidates` — never a hand-rolled family sort (epic #1020, #1032). -# A leaf crate (no transitive DIG deps), so the crates.io release is used directly. -dig-ip = "0.1.2" -# The canonical dig-app ⇄ dig-node IPC session/signing contract (engine role-half + wire types + -# domain builders + frame bounds). SSOT shared with dig-app so the two halves can never drift (#1080). -# Pinned EXACTLY: 0.3.0 is the first release on this line and the caret range would silently accept -# a future 0.3.x. The crate reaches this one through the IPC surface, so an unreviewed minor is a -# wire-shape change arriving without a PR (dig-node#369 gate). -dig-ipc-protocol = "=0.3.0" -# On-chain DID resolution (WU3, #778): the honest chain-authenticated read path the production -# DidSigningKeyResolver delegates to. -# -# NOW A CRATES.IO DEP (dig_ecosystem#901). It was git-pinned at `tag = "v0.4.0"` because the crate was -# unpublished; it is published now, so the git source is retired and NC-7 is satisfied for this dep. -# -# 0.4.2 IS THE CORRECT TARGET, AND IT IS NOT THE LATEST (0.7.1). Two separate constraints pick it, and -# both are "one line per family" (CLAUDE.md §2.4b), not staleness: -# -# 1. CHIA LINE. This crate re-exports `Bytes32` and `Coin` FROM dig_identity (`seams/dig_peer/ -# session.rs`), so dig-identity's chia-protocol MUST be this workspace's line. Published lines: -# 0.4.1 / 0.4.2 / 0.5.0 -> chia-protocol 0.26, chia-sdk-utils 0.30 <- this workspace -# 0.6.0 / 0.7.0 / 0.7.1 -> chia-protocol 0.36.1, chia-sdk-utils 0.34+ -# Taking 0.7.1 links a SECOND chia-protocol and stops every `Bytes32` crossing the resolver seam -# from unifying — the same split the dig-gossip and dig-constants notes below describe. The 0.6+ -# uplift is downstream of the chia-0.36 cascade (dig_ecosystem#2761), not an edit here. -# -# 2. DEDUPLICATION. A 0.x minor is a semver-INCOMPATIBLE boundary, so this pin must match whatever -# the rest of the graph already resolves or the tree carries TWO dig-identity copies — which for -# an identity/key crate is the whole point of pinning it at all. -# -# 0.7.1 is that value on the chia-0.36.1 ceiling (dig_ecosystem#3161): `dig-nat 0.20` and -# `dig-message 0.7` both declare `dig-identity ^0.7`, and 0.7.1 is the only line whose `chia-bls` is -# 0.36.1. The previous pin here was 0.4.2, chosen by the same rule against the then-current -# `dig-message 0.5` / `dig-nat 0.18`; it is the RULE that is stable, not the number. Re-derive it -# from `Cargo.lock` rather than trusting this comment. -dig-identity = "0.7.1" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "signal"] } -# Object-safe async trait for the injectable anchored-root resolver (the -# mandatory read-path chain pin, #127): production walks coinset, tests inject a -# deterministic resolver. -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -# The canonical node<->node JSON-RPC contract: the Method catalogue (wire names + -# per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its -# method names + the peer allowlist from HERE (never hand-rolled) so the contract -# cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.10.2" -# The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope -# the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 -# envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. -dig-message = "0.7" -# The chat message-TYPE layer: the five chat payloads on dig-message's dig-chat band + the registry -# wiring. A crypto-free, content-blind type layer — the DIGCHAT1 seal is carried as opaque bytes. -dig-chat-protocol = { path = "../dig-chat-protocol", version = "0.2" } -base64 = "0.22" -hex = "0.4" -# The whole-module pull's one content-addressing primitive (#1576): the module descriptor's per-chunk + -# whole-blob hashes on the SERVE side, and the pre-announce re-hash that proves the artifact about to be -# cached + announced is byte-identical to the one the anchor gate admitted. Production, not test-only — -# this hash is what stands between a promoted artifact and a promoted-but-unverified one. -sha2 = "0.10" -filetime = "0.2" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -fs4 = { version = "1.1.0", features = ["sync"] } -directories = "6.0.0" -# OS CSPRNG for the per-stage content-authoring BLS key (#95 Pass C dig.stage). -getrandom = "0.2" -# -- dig_ecosystem#2168: the node MACHINE key at rest -------------------------------------- -# The canonical DIG keystore. Consumed with `custody` OFF (it is off by default) because this -# crate seals only the node's OWN machine identity seed and must never be able to name the -# user-custody API -- the #908 boundary expressed in the dependency graph rather than in prose -# (dig-keystore SPEC.md 18.2, which names this engine as the intended opaque-only consumer). -# 0.10 rather than 0.9: 0.10 is a BREAKING release that moves the crate's re-exported chia-bls -# onto the ecosystem 0.36.1 line. That line is ALREADY in this lock (six chia-bls lines resolve -# here today), and the `opaque` surface this crate uses is bytes + Password with no BLS type on -# it at all, so nothing crosses between lines -- and adopting 0.10 now avoids routing the node's -# identity onto a type identity that was about to change under it. -# -# 0.13 is the FLOOR for taking hardware binding live, and the reason is not the providers. -# dig-keystore#18 made `create_with_rng` an atomic check-then-write in 0.13.0; 0.12 will happily -# overwrite an existing record. On a SOFTWARE tier an overwritten machine key is recoverable from -# a backup of two plaintext-sealed halves. Hardware wrapping binds the wrapping key to a -# non-exportable component, so the same overwrite becomes PERMANENT -- there is nothing to restore -# from. Sealing therefore upgrades that bug from recoverable to unrecoverable, and 0.12 is not a -# version this seam may be enabled on. -dig-keystore = "0.13" -# The platform trusted-component providers (Windows TPM 2.0 via CNG, Apple Secure Enclave, Linux -# TPM 2.0). A SEPARATE crate because `dig-keystore` forbids `unsafe` and every one of these is FFI; -# it is the workspace member dig_ecosystem#1693 asked for, and it landed in dig-keystore v0.12.0. -# Without it the node's hardware seam -- already fully composed at `machine_key.rs` -- passes a -# hardcoded `None` and can never report anything but `Software(NotRequested)`. -# 0.2 pairs with dig-keystore 0.13: it adds the macOS Secure Enclave and Linux TPM 2.0 providers, -# and reclassifies a TPM AUTHORIZATION refusal (owner password set, dictionary-attack lockout) as -# `Absent` rather than `Indeterminate`, so an ordinary locked-down Windows box degrades instead of -# refusing. Only a genuinely uninspectable host is indeterminate now. -dig-keystore-hardware = "0.2" -# The machine key lives in the SAME user-global identity dir digstore_remote used for the -# plaintext seed (`/dig`), so the #2168 migration finds it in place and the node -# keeps its peer_id. digstore keeps its own `identity_dir` private, so the path is reproduced. -dirs = "5" -# Keeps the unsealed seed off the heap after use, matching what dig-keystore hands back. -zeroize = "1" - -# -- L7 DIG Node peer network (PHASE-2b, #162) ------------------------------------------------------- -# The NAT-traversal + connected-peer-pool layer. The bespoke in-node relay client (formerly relay.rs) -# is RETIRED: the relay connection now lives inside dig-nat (the `connect()` ladder's last-resort tier -# + the persistent reservation) and dig-gossip (the introducer-backed connected peer pool). dig-node -# consumes both to auto-discover peers via relay.dig.net, maintain a pool, and serve/issue the L7 peer -# RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). -# -# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack -# (dig-gossip 0.15.0, dig-dht 0.5.1, dig-download 0.7.1, dig-peer-selector 0.5.2) now depends on dig-nat -# ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE -# graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the -# relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a -# `RelayTunnel`, and `RelayAcceptor` runs the mTLS SERVER half over it (#1536, Leg B) — on top of -# 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes -# the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. -# 0.11.2 is REQUIRED (#1586): before it, `RangeFrame::bytes` was read with `serde_bytes`, which over -# the JSON frame wire takes the served base64 string as its literal characters — so every -# `dig.fetchRange` window arrived as its own base64 text, the reassembler rejected the frame, and the -# read leg aborted before reading a byte. 0.11.2 reads/writes `bytes` as base64 (the canonical -# `dig_rpc_protocol::types::RangeFrame` wire the node's own serve path emits). -# dig-nat 0.13 is the release that introduced the FALLIBLE, payload-capped `RangeFrame::encode` plus -# the paged-prologue sender API (`with_identity` carrying `chunk_count`, `with_chunk_lens_page` / -# `chunk_lens_offset`, `with_chunk_index`, `RangeRequest::skip_layout`). Everything 0.14 adds is the -# RECEIVER (`ChunkLensAssembler`) plus the `split_chunk_lens_pages` helper, so 0.13 carries the whole -# serve-side fix for #1640/#1668. -# -# The dig-nat MAJOR must match the whole peer stack's, and it moves as ONE step (#1771). This crate -# builds its own `dig_nat` `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` -# values and passes them INTO dig-download, dig-gossip and dig-peer-selector, so a second dig-nat -# instance does not merely bloat the tree -- those calls stop typechecking (verified: E0308 `expected -# dig_nat::relay::RelayStatus, found dig_nat::RelayStatus`). So dig-nat cannot be bumped alone, and -# dig-gossip/dig-dht/dig-download/dig-peer/dig-peer-selector cannot be bumped past it: the dig-nat -# 0.18 line (dig-dht 0.11, dig-download 0.19, dig-peer 0.11, dig-peer-selector 0.9, dig-gossip on the -# pinned rev below) is a single atomic cascade, every member of which is published. -# `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so a -# partial cascade cannot quietly reintroduce the duplicate. -# -# This line carries the SafeText peer-text cascade (#1674/#1675): error text of remote origin is -# held as `dig_nat::SafeText` rather than `String`, so a hostile peer's control characters cannot forge -# a line in an operator's log. On a partial bump the failure is now LOUDER than the RelayStatus mismatch -# above -- `SafeText` crosses dig-dht's AND dig-peer's public error surfaces, so two dig-nat versions is -# an outright compile error on those seams rather than merely two mTLS stacks. See -# `seams/dig_peer/dht.rs::stage_failed` for this crate's side of the adoption. -dig-nat = "0.20" -# dig-gossip is the ONE peer-stack exception: not yet published to crates.io (its publish is guarded -# pending dig-peer-protocol, #681), so it stays a git dependency PINNED to a release commit — here -# v0.17.12 (rev 3e352d91). -# -# v0.17.12 is REQUIRED, not merely current (#1771): it carries all THREE duplicate-connection fixes, -# and until it landed here the node's pool refused connections that would have worked. Every pool-add -# path now supersedes a stale slot for an already-authenticated `peer_id` instead of refusing the newer -# session — inbound (#1691), `connect_to` (#1703), and `adopt_nat_connection` (#1762), the last being -# the path EVERY dig-nat connection is adopted through, where a dead relay circuit's leftover slot -# refused the direct dial while the peer reported zero connections. `DuplicateConnection` still exists -# as an error variant but is no longer raised on those paths. Consequence for this crate: `PeerAdded` is -# REPUBLISHED on a supersede, so every consumer of it must be keyed by `peer_id` and idempotent (the DHT -# routing feed, the selector registry, and the download-side connected pool all are — see -# `download.rs::on_pool_event`), and `GossipStats::total_connections` is a LIFETIME counter that a -# supersede increments, so it must never be read as a count of unique peers (this crate reads -# `connected_peers` / `pool_stats().connected`, which are the live keyed-map sizes). -# -# Also on 0.17: the outbound /16+AS diversity caps (#1710/#1716) apply to the adoption path, so a -# test fixture adopting two peers must place them in DIFFERENT /16 groups. -# Pulled rustls-only (no native-tls) -- note `--all-features` is NOT a runnable configuration for -# dig-gossip: with both `ring` and `aws-lc-rs` reachable rustls cannot pick a provider. Its -# dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the graph still -# unifies to ONE of each. -# -# There are no vendored chia-protocol/chia-sdk-client forks to patch any more: dig-gossip 0.23.0 -# deleted both (dig_ecosystem#2228) and the workspace `[patch.crates-io]` section is RETIRED (see the -# root manifest). The #1529 three-rev lockstep is retired with it. An earlier version of this comment -# still described those patches as live; it was wrong, and a false manifest comment is how the next -# lane reasons wrongly about the version line. -# -# THIS DEP WAS THE CHIA-0.36 MIGRATION BLOCKER, AND IS NO LONGER (dig_ecosystem#3161, 2026-08-26). -# -# The old block, recorded because a future lane will otherwise re-derive it: the pinned rev was -# v0.25.0, which took `dig-peer-protocol = "0.6"`, whose only published 0.6.x declares -# `chia-protocol 0.26`. That put 0.26 into the graph through a path no dig-node manifest could edit, -# so no assignment of versions HERE could remove it. -# -# What cleared it was upstream moving, exactly as that comment predicted: dig-gossip `main` (v0.30.0, -# the rev pinned below) is on `dig-peer-protocol 0.7` and declares chia-protocol/traits/sha2/bls at -# 0.36.1. Repointing the rev is what let this workspace take the ceiling; it was never a local edit. -# -# The consequence that mattered is now discharged rather than merely avoided: `Bytes32` reaches the -# store-melt authorization path in `seams/dig_peer/store_melted.rs` from dig_gossip and from -# `digstore-chain` as the SAME type, so that path is no longer split across two chia-protocol -# versions. No conversion shim was added, and none may be — bridging two lines is the byte-drift -# class this migration exists to remove. -# -# STILL PINNED HERE, and deliberately: this rev takes `dig-nat ^0.20`, while the published -# dig-dht/dig-peer/dig-download/dig-peer-selector tips have moved to `^0.21`. So the peer stack below -# is held at its ^0.20 tier to keep ONE dig-nat in the graph. Taking the newer tier resolves TWO -# dig-nat lines while cargo prints success. The fix is a dig-gossip release against `dig-nat ^0.21`, -# upstream, never a version edit here. -# NC-7 exception: dig-gossip is git-pinned PERMANENTLY, not pending a publish — dig_ecosystem#2647. -# It cannot be published while its `native-tls` [patch.crates-io] fork stands, because `cargo publish` -# STRIPS [patch.crates-io] from the published metadata. A published dig-gossip would therefore build -# against UPSTREAM native-tls, compile cleanly, and silently accept inbound peers presenting no client -# certificate at all — a mutual-TLS bypass that no consumer could see from its own manifest. Its -# publish.yml guard is unconditional and deliberate: do not weaken it, do not `--allow-dirty` past it. -# dig_ecosystem#900 ("publish dig-gossip") was closed as superseded by #2647. -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "48a14ed2ed395a41c913d5c8f944e6ea0b747075", default-features = false, features = ["rustls", "relay"] } -# The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. -# -# 0.9 is the TIP OF DIG-NODE'S CHIA LINE, and that — not 0.10 — is the correct target here (#2072). -# dig-constants 0.10.0 moved to chia-protocol 0.36.1 / chia-wallet-sdk 0.34, while this workspace -# builds against 0.26 / 0.30, which dig-gossip pins there via dig-peer-protocol 0.6 (see the -# dig-gossip entry above). Depending on 0.10 links a SECOND chia_protocol and every `Bytes32` this -# crate reads off `DIG_MAINNET` stops unifying with the rest of the workspace. Being current on -# dig-constants is therefore downstream of the chia 0.36 migration, not a version-string edit. -# -# Values are identical from 0.4.0 through 0.10.0 — the L2 genesis challenge and its six AGG_SIG -# domains were finalized in 0.4.0 and have not moved since — so this bump is value-neutral and buys -# the additive surface (DIG_ASSET_ID, the treasury hash/address, `dig.local`, `rpc.dig.net`). -# -# The graph still carries 0.4.0/0.5.1/0.8.0 because upstream crates hold them down through their -# PUBLISHED ranges (dig-gossip `>=0.2, <0.5`, dig-nat `>=0.4, <0.6`, dig-download `^0.8`), which a -# 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.13.0" -# -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- -# -# `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ -# chia-protocol 0.36.1, which is a DIFFERENT chia line from the 0.26 this workspace builds against, so -# the default surface would put a SECOND chia-protocol into the always-running service. Without the -# feature the crate is a pure BYTE CORE (`dig-smt + sha2 + thiserror`) exposing exactly what the node -# needs: the DPB container, `AnchoredRoot` and `VerifiedBody`. -# -# Consequence to keep in mind: `dig_social_profile::resolve` is NOT available here. The node resolves -# a store's on-chain root through its OWN `AnchoredRootResolver` (the coinset lineage walk), which is -# the same authority, reached by the node's existing seam. -dig-social-profile = { version = "0.7", default-features = false } -# -- L7 content-location DHT (PHASE-B, #163) --------------------------------------------------------- -# The Kademlia DHT that maps DIG content (store / capsule / root / resource) to the peer_ids holding -# it. The node LOCATES content it lacks via `find_providers`, and keeps its OWN held-inventory provider -# records CURRENT in the DHT (`announce_provider` / `withdraw_provider` / `republish`). It rides the -# SAME dig-nat mTLS transport as the rest of the peer network via a `DhtTransport` adapter (`dht.rs`). -# -# Sourcing: the crates.io release (NC-7: prefer the published version over bare-git now that dig-dht -# is on crates.io). dig-download + dig-peer-selector below ALSO resolve dig-dht from crates.io "^0.5", -# so cargo unifies to ONE dig-dht instance — dig-dht's `PeerId` / `Contact` / `DhtTransport` share the -# exact types `dig_nat::connect` returns. dig-dht 0.5 deps dig-nat "^0.10" (crates.io), the SAME -# dig-nat "0.10" this crate pins, so the whole graph shares ONE dig-nat 0.10 instance. -dig-dht = "0.12.1" -# -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- -# The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX -# logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), -# feeding discovered peers into the pool as dial candidates (hints, verified by dialing) and formalizing -# what the ad-hoc `dig.getPeers` polling did. dig-pex ships a transport-agnostic sans-IO `PexEngine` -# this crate embeds; `pex.rs` is the dig-nat mux I/O adapter (SPEC §10.1, Appendix A). -# -# Sourcing: bare git form (no `rev`, like dig-nat/dig-dht) so cargo unifies any shared transitive deps -# to ONE copy across the graph; resolved to the tracked rev via the lockfile. dig-pex keeps a minimal -# dependency surface (serde/serde_json/tokio/rand — it mirrors the address shape rather than importing -# the transport crates), so there is no cross-crate type-unification concern here. -dig-pex = "0.1.1" -# -- Multi-source content fetch (#164) + redirect-on-miss (#165) -------------------------------------- -# The node-side download orchestrator: locate holders via dig-dht `find_providers`, confirm with -# `dig.getAvailability`, fan byte ranges across MULTIPLE providers simultaneously (`dig.fetchRange` -# over dig-nat), verify each range + the whole resource against the chain-anchored root, and -# reassemble — with pause/resume + `.download.tmp` GC. dig-node wires the real seams per the crate's -# implementers' note (see `download.rs`): DhtProviderLocator over our DhtService, NatRangeTransport -# over our mTLS identity, MerkleVerifier bound to the digstore proof format, FileSink/FileStateStore -# under the node's cache dir. -# -# Sourcing: the crates.io release (NC-7), matching dig-dht above. dig-download 0.6 deps dig-dht "^0.5" + -# dig-nat "^0.10" + dig-tls "^0.3" from crates.io, so cargo unifies its dig-dht + dig-nat + dig-tls with -# ours — ONE crate instance each across the graph (its re-exported `ContentId`/`ProviderRecord` ARE our -# dig-dht types). 0.6 keeps the injectable `SourceSelector` seam (dig-node implements it over -# dig-peer-selector, see `seams/dig_peer/selector_adapter.rs`), records range outcomes internally, and the -# fetch leg composes the FULL NAT ladder from an injected shared `NatRuntime` (`NatRangeTransport::new_with_runtime`). -# -# 0.8.1 adds the `ModuleDownloader` — the whole-`.dig`-module peer pull that turns a node which READ a -# resource into a complete resharer of the capsule (#1576, the reshare leg of the MVP flywheel). Its two -# seams are supplied here: `seams/dig_peer/module_transport.rs` (`NatModuleTransport`) and -# `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is -# deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this -# crate's anchor gate is the reshare path's ONLY root of trust. -# -# HELD on the 0.19 line, resolving 0.19.1 (dig_ecosystem#3128). 0.19.1 is a deliberate BACKPORT of the -# capsule-checkpoint filename fix (dig-download#38): `FileStateStore` hex-encoded the download key into -# the filename, so a module key `module:<64hex>:<64hex>` (136 bytes) became a 277-character name and -# EVERY capsule checkpoint write on Linux failed with `File name too long (os error 36)`. The same fix -# also shipped as 0.20.1, but 0.20 requires dig-dht ^0.12 / dig-nat ^0.20 / dig-peer ^0.12 / -# dig-tls ^0.4 — the whole dig_ecosystem#3152 cascade — which this crate cannot take yet, so the -# backport keeps the 0.19-line pins intact. Regression-tested here through the node's OWN production -# checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), -# because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no -# filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.20.4" -# -- The shared peer client (#1283/#1576) ------------------------------------------------------------- -# `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed -# RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the -# `ModuleTransport` half of dig-download's module pull, which speaks `dig.getModuleInfo` / -# `dig.fetchModuleRange` — the two client methods dig-peer 0.5 added for exactly this leg. -# -# 0.5 is REQUIRED, not merely current: dig-peer 0.4 pulled dig-rpc-protocol 0.3, so a tree containing -# both it and dig-download's dig-rpc-protocol 0.5 carried TWO `ModuleInfo` types either side of the -# module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on -# dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by -# `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.12.0" -# -- Self-optimizing peer selection (#178) ------------------------------------------------------------ -# The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the -# providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation -# point + an adaptive relayed penalty from the REAL measured outcome of every range) and hands -# dig-download the ranked subset + per-peer concurrency, instead of dig-download picking sources -# blindly. Every completed/failed range streams back via `record_outcome` in real time, and a dropped -# source drives a `rebalance`. dig-node bridges dig-download's `SourceSelector` seam to this crate via -# `seams/dig_peer/selector_adapter.rs` (the @30↔@30 composition-root bridge — dig-download does NOT -# depend on dig-peer-selector; dig-node, one level up, wires them together). -# -# Sourcing: the crates.io release (NC-7), matching dig-dht/dig-download above. It deps dig-dht "^0.5" + -# dig-nat "^0.10" from crates.io, so cargo unifies the selector's `dig-nat` + `dig-dht` with ours — ONE -# crate instance each (its re-used `PeerId`/`ContentId`/`ProviderRecord`/`CandidateAddr` ARE our -# dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on -# dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local -# `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). -# 0.9.2 IS the chia-0.36 cascade landing (dig_ecosystem#3161). The old note here said "NOT bumped to -# 0.9.1: that release moves onto dig-tls 0.4, and this crate still resolves 0.3 through -# dig-nat/dig-gossip, so the graph would carry TWO `dig_tls::PeerId` types that do not unify". Both -# halves of that split are gone — dig-nat 0.20 and the dig-gossip rev both take `dig-tls ^0.4`, and -# the lock now resolves dig-tls ONCE at 0.4.0. -# -# NOT bumped to 0.10: that release requires `dig-nat ^0.21`, which dig-gossip v0.30.0 does not take -# (it declares `^0.20`). Taking it splits dig-nat in two while cargo prints success. See the peer-stack -# note in the ROOT manifest for why the ^0.20 tier is the correct choice rather than the newest. -dig-peer-selector = "0.9.2" -# The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity -# is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in -# the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the -# legacy self-signed `rcgen` cert as the node dials/serves over the dig-nat CA-signed mTLS model -# (#1280). `peer_id = SHA256(SPKI DER)` is stable across restarts. dig-tls re-exports chia-bls's -# `SecretKey`, unifying with this crate's chia-bls 0.36.1. 0.4 is the chia-bls 0.36.1 uplift and is -# now TAKEN (dig_ecosystem#3161): the old hold — "taking it here would split `dig_tls::PeerId` -# against the 0.3 that dig-nat/dig-gossip still resolve" — expired when dig-nat 0.20 and the -# dig-gossip rev both moved onto `dig-tls ^0.4`. The lock resolves dig-tls ONCE, at 0.4.0. -dig-tls = "0.4" -# -- The store-exchange DECISION layer (dig_ecosystem#3130) ------------------------------------------- -# `dig-sex` owns every cache/tier/relevance/selection DECISION this node used to make in-tree: what to -# keep, what to sacrifice first, and whether a fresh candidate is worth displacing an incumbent. It is -# PURE — no clock, no socket, no filesystem — so this crate keeps owning all of the I/O and hands it -# only the facts. The in-tree `relevance` module and `tier0_prefetch::effective_tier` moved there -# verbatim and are now consumed as `dig_sex::*`. -# -# It also re-exports the ONE `CapsuleIdentity` the ecosystem speaks, so this crate never declares its -# own and never pulls a second major of `dig-store` into the graph. -dig-sex = "0.5" -# The cache/eviction SEAM `dig_sex::TieredPolicy` implements. Declared DIRECTLY rather than reached -# through `dig-sex`, which re-exports `CapsuleIdentity` alone — naming `EvictionEntry`, -# `EvictionContext` and the `EvictionPolicy` trait (whose `select_evictions` must be in scope to call) -# requires the crate itself. The version is pinned to the one `dig-sex` resolves, so the `CapsuleIdentity` -# this crate builds and the one `TieredPolicy` matches on are the SAME type; a split would silently -# fail to unify. `dig_sex::TieredPolicy` is the first non-LRU implementation of the seam, and the -# module sweep here is its first consumer. -dig-store-cache = "0.1.1" -# The mTLS peer-RPC LISTENER (server side): present the node cert + REQUIRE a client cert (peer_id -# enforced). rustls/tokio-rustls with ring only (no OpenSSL/aws-lc for OUR usage), matching dig-nat. -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } -# IPv6-first, IPv4-fallback (ecosystem HARD RULE): bind the peer-RPC listener on the IPv6 unspecified -# address `[::]` as a DUAL-STACK socket (IPV6_V6ONLY cleared) so ONE socket serves both native IPv6 -# and IPv4-mapped peers. Mirrors dig-relay's `net.rs` / dig-gossip's own dual-stack bind. -socket2 = "0.5" -# -- Engine-side identity session (NODE-1, #910) ----------------------------------------------------- -# The engine is IDENTITY-AGNOSTIC: it holds NO user signing key. `control.session.attach` proves the -# attaching dig-app holds the profile's slot-0x0010 identity key by VERIFYING an Ed25519 signature over -# the byte-identical `DIGNET-SESSION-v1 ‖ nonce ‖ profile_did` challenge (session.rs). Ed25519 -# verification rides `ring` — ALREADY in the locked graph (the rustls/tokio-rustls transport uses it), -# so it adds no new crate. VERIFY-ONLY: the engine never holds a signing key. (dig-app signs with -# ed25519-dalek; ring verifies RFC-8032 signatures + is strict against malleable/small-order inputs.) -ring = "0.17" -tracing = "0.1" -# Best-effort CONCURRENT fan-out of the multi-source provider union (`UnionLocator`, #1443): query -# dig-dht + PEX + relay-introducer sources at once via `future::join_all` (order-preserving, so the -# dedup keeps DHT-first precedence). rustls/tokio already pull futures transitively; declaring it here -# makes it a first-class prod dep of the union seam. Minimal feature set (no executor). -futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } - -[dev-dependencies] -tempfile = "3" -# `dig_keystore::hardware::double::FakeDevice` -- the only way to exercise a SPECIFIC hardware -# outcome from a test. The real providers ship now (dig-keystore-hardware above), but which one a -# host answers with is a property of that host: no CI runner can be made to produce an -# indeterminate TPM probe on demand, and that is the outcome the refusal path turns on. -# Test-only: `testing` is never enabled for the library build. -dig-keystore = { version = "0.13", features = ["testing"] } -# Same git rev as the normal dependency above, with dig-gossip's `test-util` feature added. That -# feature gates `__connect_stub_peer_with_direction`, which registers a connected peer without real -# TLS — the only way to assert what a broadcast ACTUALLY put on the wire from a test. Declared here -# rather than on the normal dependency so the shipped binary never carries the test hook: cargo -# unifies the feature into test builds only, since dev-dependencies are not built for `cargo build`. -# NC-7 exception: same permanent git pin as the runtime dep above (dig_ecosystem#2647). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "48a14ed2ed395a41c913d5c8f944e6ea0b747075", default-features = false, features = ["rustls", "relay", "test-util"] } -# Generates the P-256 leaf key pairs the opcode-222 holdings tests sign with: `public_key_der()` IS the -# leaf `SubjectPublicKeyInfo` DER the wire carries as `provider_spki`, and whose SHA-256 is the -# announcing peer_id — so a test signer is a real §5.2 identity rather than a stand-in that could hide -# an attribution bug (#1429). -rcgen = "0.13" -# dig-download's in-memory harness (`MockRangeTransport`, `MockContent`, …), which the download-path -# tests drive the real orchestrator over. -# -# It is a DEV dependency and MUST STAY ONE (#1576). The `testkit` feature is also what makes -# `AcceptAnyModuleAnchor` — a fail-OPEN module anchor verifier — nameable at all; the crate compiles it -# out of a default consumer build precisely so a production wiring cannot reach it. The reshare path's -# anchor gate is its ONLY root of trust, so enabling this feature on the PRODUCTION `dig-download` entry -# above would put a bypass of that root of trust one `use` away. Dev-dependency features do not -# propagate to a consumer's build, so the node binaries never see it. -# -# Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which -# fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.20.4", features = ["testkit"] } -# Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the -# serve-observability tests (#1595) assert what an operator would actually see in the node log — -# and that no payload byte or proof ever reaches it. -tracing-subscriber = { version = "0.3", features = ["fmt"] } -# Builds canonical `did:chia:` strings for the on-chain DID-resolver tests (matches dig-identity's codec). -chia-sdk-utils = "0.36.0" -# The session attach tests sign challenges with the BLS12-381 G2 AugScheme (the app's slot-0x0010 -# identity key), matching dig-ipc-protocol's signing contract and dig-identity's published G1 key. -chia-bls = "0.36.1" -# axum is a TEST-ONLY dependency of the engine library: the node no longer serves its -# own HTTP (the `dig-node-service` shell owns the axum transport, `dig-runtime` owns the -# in-process FFI path — dispatch is `handle_rpc`, which is transport-agnostic). The -# integration/unit tests still spawn small in-process axum mock servers (the §21 remote -# + a module server) to exercise the fetch/cache path hermetically. -axum = "0.7" -# In-process loopback mTLS peer drives the peer-network tests with NO real network (the CA-signed -# `dig_tls::NodeCert` minted by `load_or_generate_node_cert` is the test client's mTLS identity). -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "test-util"] } -futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } -# The peer_network.rs integration test implements PeerRpcResponder (an async trait). -async-trait = "0.1" +[package] +name = "dig-node-core" +# The engine library's OWN version, independent of the released binary's (`[workspace.package].version` +# in the root manifest, which only dig-node-service tracks). It moves when this crate's PUBLIC surface +# moves: 0.24.0 acknowledges that the surface now exposes dig-nat 0.14 / dig-dht 0.8 types, a +# compatibility-relevant change even though the crate is consumed in-workspace and is not published to +# crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for +# what the exported types are (dig_ecosystem#1785c). 0.26.0 does the same for the 0.15 line: the +# exported dig-nat / dig-dht types rotate identity again, and `DhtError` — reachable through this +# crate's dig-dht re-exports — now carries `SafeText` instead of `String` (dig_ecosystem#1674/#1675). +# 0.27.0 adds the pure `relevance` module (CacheTier / relevance() / evict_key / should_displace) — +# a new, backwards-compatible public export (dig_ecosystem#1986), hence a MINOR bump. +# 0.28.0 adds the pure `tier0_selector` module (Candidate / select_within_budget / tier0_budget_bytes / +# should_displace_tier0) built on 0.27's relevance module — a new, backwards-compatible public export +# (dig_ecosystem#1988), hence another MINOR bump. +# 0.31.0 adds cache observability to `cache.stats` (§7.10e) — `refetch_count` + per-tier `tiers` +# occupancy fields, and makes `InboundDemand::entry_count` a real (no-longer-`#[cfg(test)]`) public +# API — a new, backwards-compatible surface (dig_ecosystem#1991), hence a MINOR bump. +# 0.47.0 adopts dig-gossip 0.25.0 and splits the announce seams by ORIGIN: `ProfileTransport` gains a +# required `announce_root_local` and `MeltBroadcast` a required `broadcast_local`, so a locally +# originated announce takes dig-gossip's dedup-exempt path and a re-announce of an unchanged root is +# no longer suppressed forever (dig_ecosystem#3061). Adding a required trait method is BREAKING for an +# out-of-workspace implementor; this crate is consumed in-workspace only and is pre-1.0, so it is a +# MINOR bump under SemVer's 0.x rule -- recorded here rather than letting the number imply the trait +# surface held still. +# 0.53.0 adds the module-granularity capsule relay leg (`seams/dig_peer/module_relay.rs`, gated OFF +# by default) and makes the provider/candidate locator readers absence-safe: `NodeContent::find_providers` +# and `NatModuleTransport::discovered_candidates` now return `BestEffort` rather than a bare `Vec`, +# so a source that could not be consulted can no longer be read as a proven absence (dig_ecosystem#3128, +# dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; +# this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x +# rule -- recorded here rather than letting the number imply the locator surface held still. +version = "0.62.0" +edition = "2021" +license = "GPL-2.0-only" +description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." + +[lib] +name = "dig_node_core" +path = "src/lib.rs" + +[dependencies] +# Bounds the whole-module descriptor memo (`seams/dig_peer/module_serve.rs`, #1615/G2): a per-process +# cache of `dig.getModuleInfo` answers keyed by (store, root), capped so a long-running node cannot +# accumulate one entry per module it has EVER described (each entry is tens of KB — up to 512 +# chunk_hashes + chunk_lens). Already a transitive dependency (pulled in via the workspace's other +# crates); promoted to a direct one now that this crate uses it itself. +lru = "0.12" +# The `.dig` STORE-FORMAT LIBRARY crates. These live in the digstore repo (the store +# CLI + format/read libraries) and are consumed here as GIT dependencies: the node +# depends on the store libs, NEVER the reverse (digstore is only ever an RPC client of +# a node). Pinned to a single digstore rev so the whole store-format surface the node +# reads/verifies/decrypts against is one coherent version. Bump all seven together. +# +# digstore-host — instantiate a compiled module + `serve_blind` (ciphertext + proof). +# digstore-core — wire types (ContentResponse), codec, Bytes32/48, the PublicManifest +# type + `datasection::read_public_manifest` (#176 Phase C). +# digstore-remote — the §21 remote client + persistent identity signer (§21.9). +# digstore-chain — CHIP-0035 singleton lineage walk (chain-anchored root). +# digstore-stage — the shared stage→compile engine for the `dig.stage` RPC (turn a +# folder into a capsule in-process, #95 Pass C). Its build script +# embeds the guest wasm (BINDING contract D6) via the DIGSTORE_GUEST_WASM +# override this repo sets in .cargo/config.toml (see vendor/). +# digstore-crypto — read-path crypto primitives. +# digstore-compiler — `extract_data_section_blob` (#176 Phase C): pulls the embedded DIGS +# data-section blob out of a compiled `.dig` module's wasm binary so +# `dig.getManifest` can read section 13 (the PublicManifest) WITHOUT a +# wasmtime instantiation (the manifest is PUBLIC, unencrypted data — no +# `serve_blind` decrypt needed). Already a transitive dependency via +# digstore-stage; pinned directly at the SAME rev so its public API is +# usable here too. +# +# Bumped to a rev past digstore's "Add normalized public manifest to the .dig format" +# commit (08f5d39) so `PublicManifest`/`read_public_manifest`/`extract_data_section_blob` +# exist. +# +# Bumped again to 5872240f (digstore v0.19.2, fix #1473) to adopt the HARDENED, +# unforgeable `digstore_chain::singleton::verify_pinned_root`. The rev before that (d5e52fb) +# anchored store identity on the attacker-controllable curried `SingletonStruct.launcher_id`, +# so a forged singleton currying `launcher_id == store_id` (hinted to store_id) could pass as +# chain-verified. 5872240f anchors identity on the UNFORGEABLE launcher coin (`coin_id == +# store_id`) via a bounded backward `parent_coin_info` walk, making the read-root anti-rollback +# check (#747/#841/#852-node) SOUND. +# +# Bumped again to the CURRENT rev, 161c2a3108cb4bb6c8791e96a26588ec99afb029 (digs#59), which is a +# descendant of 5872240f — so the launcher-coin anchoring above still holds — and additionally +# carries digstore-chain onto `chia-wallet-sdk` 0.36. See the SDK note below for why that bump is +# a security fix rather than housekeeping. +# +# Both bumps leave the public signature unchanged (a pure rev bump). Bump all seven together. +# NC-7 exception: dig_ecosystem#901 (the crates.io repoint) leaves the seven `digstore-*` deps below +# git-pinned ON PURPOSE. Three independent facts each block the repoint on their own, and all three are +# upstream of this workspace: +# +# 1. THE SET IS ATOMIC, so `digstore-core` cannot move alone even though it IS published (0.28.0). +# Inside the digstore workspace every sibling declares `digstore-core = { path = "../digstore-core" }`, +# so a git-sourced `digstore-host` always carries the git `digstore-core`. Repointing only the +# published members would put TWO `digstore-core` in the graph — a crates.io one for this crate's +# direct imports and a git one inside its siblings — and `ContentResponse`/`Bytes32` would stop +# unifying across the serve seam. They move together or not at all. +# +# 2. MOST OF THE SET IS UNPUBLISHED, and the closure is wider than it looks. Absent from crates.io: +# `digstore-host`, `digstore-remote`, `digstore-stage`, `digstore-crypto`, `digstore-compiler` — +# plus `digstore-prover`, `digstore-store`, `digstore-chunker` and `digstore-cli`, which the five +# pull in transitively and which no checklist had named. Each declares its siblings as a BARE +# `path = "../x"` with no `version` key, which `cargo publish` refuses outright, so publishing them +# is real work in DIG-Network/digs rather than a release button. +# +# 3. (CLEARED 2026-08-26, dig_ecosystem#3161.) This used to read "the published members are on the +# OTHER chia line", because digstore declared the primitives at 0.36.1 while this workspace was +# held at 0.26 by dig-gossip -> dig-peer-protocol 0.6. Both halves moved: the dig-gossip rev now +# carries 0.36.1, and the digstore rev below is digstore's own ceiling fix. The primitives are +# one line across the boundary, so nothing is split. +# +# The SDK family has now collapsed too (2026-08-26, dig-node#374). This used to read that +# digstore's rev took `chia-wallet-sdk 0.34` against dig-wallet's 0.36.0, and that closing the +# gap needed a digstore release — upstream, not here. That release happened: DIG-Network/digs#59 +# moved `digstore-chain` onto `chia-wallet-sdk 0.36` (and `datalayer-driver` 5, which is what +# required the SDK bump), and the rev below is that commit. +# +# Repointing was a SECURITY fix, not tidiness. `chia-sdk-driver` 0.30 and 0.34 decode the oracle +# fee out of an attacker-authored CREATE_COIN memo as +# `BigInt::from_signed_bytes_be(&memo).to_u64_digits().1[0]`, and an empty or explicit-zero memo +# makes that index an empty vec — a panic reachable from any spend dig-node walks through +# `Datastore::from_spend` (the lineage walk, `verify_pinned_root`, `sync_datastore`). 0.36 guards +# the empty case and uses `try_into`, so both become `DriverError::InvalidMemo`; it also stops +# silently discarding a negative memo's sign and truncating an over-`u64` fee to its low digit. +# +# So this row is still downstream of publishing the digstore workspace. Do NOT repoint these +# piecemeal, and never let the pin regress to a floating git BRANCH: the single `rev` is what makes +# the whole store-format surface reproducible. +digstore-host = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-core = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-remote = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-chain = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-stage = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-crypto = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-compiler = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +chia-protocol = "0.36.1" +# The `Streamable` to_bytes/from_bytes trait, used to serialize the chat `ChatMessage` payload before +# the dig-message seal. Same 0.36.1 as chia-protocol so the byte layout agrees across the workspace. +chia-traits = "0.36.1" +# The ecosystem's canonical address-family / IPv6-first primitive (CLAUDE.md §5.2). The advertise +# path keys + orders candidate addresses via `dig_ip::Family` and aggregates them source-tagged + +# de-duplicated via `dig_ip::PeerCandidates` — never a hand-rolled family sort (epic #1020, #1032). +# A leaf crate (no transitive DIG deps), so the crates.io release is used directly. +dig-ip = "0.1.2" +# The canonical dig-app ⇄ dig-node IPC session/signing contract (engine role-half + wire types + +# domain builders + frame bounds). SSOT shared with dig-app so the two halves can never drift (#1080). +# Pinned EXACTLY: 0.3.0 is the first release on this line and the caret range would silently accept +# a future 0.3.x. The crate reaches this one through the IPC surface, so an unreviewed minor is a +# wire-shape change arriving without a PR (dig-node#369 gate). +dig-ipc-protocol = "=0.3.0" +# On-chain DID resolution (WU3, #778): the honest chain-authenticated read path the production +# DidSigningKeyResolver delegates to. +# +# NOW A CRATES.IO DEP (dig_ecosystem#901). It was git-pinned at `tag = "v0.4.0"` because the crate was +# unpublished; it is published now, so the git source is retired and NC-7 is satisfied for this dep. +# +# 0.4.2 IS THE CORRECT TARGET, AND IT IS NOT THE LATEST (0.7.1). Two separate constraints pick it, and +# both are "one line per family" (CLAUDE.md §2.4b), not staleness: +# +# 1. CHIA LINE. This crate re-exports `Bytes32` and `Coin` FROM dig_identity (`seams/dig_peer/ +# session.rs`), so dig-identity's chia-protocol MUST be this workspace's line. Published lines: +# 0.4.1 / 0.4.2 / 0.5.0 -> chia-protocol 0.26, chia-sdk-utils 0.30 <- this workspace +# 0.6.0 / 0.7.0 / 0.7.1 -> chia-protocol 0.36.1, chia-sdk-utils 0.34+ +# Taking 0.7.1 links a SECOND chia-protocol and stops every `Bytes32` crossing the resolver seam +# from unifying — the same split the dig-gossip and dig-constants notes below describe. The 0.6+ +# uplift is downstream of the chia-0.36 cascade (dig_ecosystem#2761), not an edit here. +# +# 2. DEDUPLICATION. A 0.x minor is a semver-INCOMPATIBLE boundary, so this pin must match whatever +# the rest of the graph already resolves or the tree carries TWO dig-identity copies — which for +# an identity/key crate is the whole point of pinning it at all. +# +# 0.7.1 is that value on the chia-0.36.1 ceiling (dig_ecosystem#3161): `dig-nat 0.20` and +# `dig-message 0.7` both declare `dig-identity ^0.7`, and 0.7.1 is the only line whose `chia-bls` is +# 0.36.1. The previous pin here was 0.4.2, chosen by the same rule against the then-current +# `dig-message 0.5` / `dig-nat 0.18`; it is the RULE that is stable, not the number. Re-derive it +# from `Cargo.lock` rather than trusting this comment. +dig-identity = "0.7.1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "signal"] } +# Object-safe async trait for the injectable anchored-root resolver (the +# mandatory read-path chain pin, #127): production walks coinset, tests inject a +# deterministic resolver. +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# The canonical node<->node JSON-RPC contract: the Method catalogue (wire names + +# per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its +# method names + the peer allowlist from HERE (never hand-rolled) so the contract +# cannot drift from the other node implementation or the discovery document (#1075). +dig-rpc-protocol = "0.10.2" +# The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope +# the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 +# envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. +dig-message = "0.7" +# The chat message-TYPE layer: the five chat payloads on dig-message's dig-chat band + the registry +# wiring. A crypto-free, content-blind type layer — the DIGCHAT1 seal is carried as opaque bytes. +dig-chat-protocol = { path = "../dig-chat-protocol", version = "0.2" } +base64 = "0.22" +hex = "0.4" +# The whole-module pull's one content-addressing primitive (#1576): the module descriptor's per-chunk + +# whole-blob hashes on the SERVE side, and the pre-announce re-hash that proves the artifact about to be +# cached + announced is byte-identical to the one the anchor gate admitted. Production, not test-only — +# this hash is what stands between a promoted artifact and a promoted-but-unverified one. +sha2 = "0.10" +filetime = "0.2" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +fs4 = { version = "1.1.0", features = ["sync"] } +directories = "6.0.0" +# OS CSPRNG for the per-stage content-authoring BLS key (#95 Pass C dig.stage). +getrandom = "0.2" +# -- dig_ecosystem#2168: the node MACHINE key at rest -------------------------------------- +# The canonical DIG keystore. Consumed with `custody` OFF (it is off by default) because this +# crate seals only the node's OWN machine identity seed and must never be able to name the +# user-custody API -- the #908 boundary expressed in the dependency graph rather than in prose +# (dig-keystore SPEC.md 18.2, which names this engine as the intended opaque-only consumer). +# 0.10 rather than 0.9: 0.10 is a BREAKING release that moves the crate's re-exported chia-bls +# onto the ecosystem 0.36.1 line. That line is ALREADY in this lock (six chia-bls lines resolve +# here today), and the `opaque` surface this crate uses is bytes + Password with no BLS type on +# it at all, so nothing crosses between lines -- and adopting 0.10 now avoids routing the node's +# identity onto a type identity that was about to change under it. +# +# 0.13 is the FLOOR for taking hardware binding live, and the reason is not the providers. +# dig-keystore#18 made `create_with_rng` an atomic check-then-write in 0.13.0; 0.12 will happily +# overwrite an existing record. On a SOFTWARE tier an overwritten machine key is recoverable from +# a backup of two plaintext-sealed halves. Hardware wrapping binds the wrapping key to a +# non-exportable component, so the same overwrite becomes PERMANENT -- there is nothing to restore +# from. Sealing therefore upgrades that bug from recoverable to unrecoverable, and 0.12 is not a +# version this seam may be enabled on. +dig-keystore = "0.13" +# The platform trusted-component providers (Windows TPM 2.0 via CNG, Apple Secure Enclave, Linux +# TPM 2.0). A SEPARATE crate because `dig-keystore` forbids `unsafe` and every one of these is FFI; +# it is the workspace member dig_ecosystem#1693 asked for, and it landed in dig-keystore v0.12.0. +# Without it the node's hardware seam -- already fully composed at `machine_key.rs` -- passes a +# hardcoded `None` and can never report anything but `Software(NotRequested)`. +# 0.2 pairs with dig-keystore 0.13: it adds the macOS Secure Enclave and Linux TPM 2.0 providers, +# and reclassifies a TPM AUTHORIZATION refusal (owner password set, dictionary-attack lockout) as +# `Absent` rather than `Indeterminate`, so an ordinary locked-down Windows box degrades instead of +# refusing. Only a genuinely uninspectable host is indeterminate now. +dig-keystore-hardware = "0.2" +# The machine key lives in the SAME user-global identity dir digstore_remote used for the +# plaintext seed (`/dig`), so the #2168 migration finds it in place and the node +# keeps its peer_id. digstore keeps its own `identity_dir` private, so the path is reproduced. +dirs = "5" +# Keeps the unsealed seed off the heap after use, matching what dig-keystore hands back. +zeroize = "1" + +# -- L7 DIG Node peer network (PHASE-2b, #162) ------------------------------------------------------- +# The NAT-traversal + connected-peer-pool layer. The bespoke in-node relay client (formerly relay.rs) +# is RETIRED: the relay connection now lives inside dig-nat (the `connect()` ladder's last-resort tier +# + the persistent reservation) and dig-gossip (the introducer-backed connected peer pool). dig-node +# consumes both to auto-discover peers via relay.dig.net, maintain a pool, and serve/issue the L7 peer +# RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). +# +# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack +# (dig-gossip 0.15.0, dig-dht 0.5.1, dig-download 0.7.1, dig-peer-selector 0.5.2) now depends on dig-nat +# ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE +# graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the +# relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a +# `RelayTunnel`, and `RelayAcceptor` runs the mTLS SERVER half over it (#1536, Leg B) — on top of +# 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes +# the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. +# 0.11.2 is REQUIRED (#1586): before it, `RangeFrame::bytes` was read with `serde_bytes`, which over +# the JSON frame wire takes the served base64 string as its literal characters — so every +# `dig.fetchRange` window arrived as its own base64 text, the reassembler rejected the frame, and the +# read leg aborted before reading a byte. 0.11.2 reads/writes `bytes` as base64 (the canonical +# `dig_rpc_protocol::types::RangeFrame` wire the node's own serve path emits). +# dig-nat 0.13 is the release that introduced the FALLIBLE, payload-capped `RangeFrame::encode` plus +# the paged-prologue sender API (`with_identity` carrying `chunk_count`, `with_chunk_lens_page` / +# `chunk_lens_offset`, `with_chunk_index`, `RangeRequest::skip_layout`). Everything 0.14 adds is the +# RECEIVER (`ChunkLensAssembler`) plus the `split_chunk_lens_pages` helper, so 0.13 carries the whole +# serve-side fix for #1640/#1668. +# +# The dig-nat MAJOR must match the whole peer stack's, and it moves as ONE step (#1771). This crate +# builds its own `dig_nat` `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` +# values and passes them INTO dig-download, dig-gossip and dig-peer-selector, so a second dig-nat +# instance does not merely bloat the tree -- those calls stop typechecking (verified: E0308 `expected +# dig_nat::relay::RelayStatus, found dig_nat::RelayStatus`). So dig-nat cannot be bumped alone, and +# dig-gossip/dig-dht/dig-download/dig-peer/dig-peer-selector cannot be bumped past it: the dig-nat +# 0.18 line (dig-dht 0.11, dig-download 0.19, dig-peer 0.11, dig-peer-selector 0.9, dig-gossip on the +# pinned rev below) is a single atomic cascade, every member of which is published. +# `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so a +# partial cascade cannot quietly reintroduce the duplicate. +# +# This line carries the SafeText peer-text cascade (#1674/#1675): error text of remote origin is +# held as `dig_nat::SafeText` rather than `String`, so a hostile peer's control characters cannot forge +# a line in an operator's log. On a partial bump the failure is now LOUDER than the RelayStatus mismatch +# above -- `SafeText` crosses dig-dht's AND dig-peer's public error surfaces, so two dig-nat versions is +# an outright compile error on those seams rather than merely two mTLS stacks. See +# `seams/dig_peer/dht.rs::stage_failed` for this crate's side of the adoption. +dig-nat = "0.21" +# dig-gossip is the ONE peer-stack exception: not yet published to crates.io (its publish is guarded +# pending dig-peer-protocol, #681), so it stays a git dependency PINNED to a release commit — here +# v0.17.12 (rev 3e352d91). +# +# v0.17.12 is REQUIRED, not merely current (#1771): it carries all THREE duplicate-connection fixes, +# and until it landed here the node's pool refused connections that would have worked. Every pool-add +# path now supersedes a stale slot for an already-authenticated `peer_id` instead of refusing the newer +# session — inbound (#1691), `connect_to` (#1703), and `adopt_nat_connection` (#1762), the last being +# the path EVERY dig-nat connection is adopted through, where a dead relay circuit's leftover slot +# refused the direct dial while the peer reported zero connections. `DuplicateConnection` still exists +# as an error variant but is no longer raised on those paths. Consequence for this crate: `PeerAdded` is +# REPUBLISHED on a supersede, so every consumer of it must be keyed by `peer_id` and idempotent (the DHT +# routing feed, the selector registry, and the download-side connected pool all are — see +# `download.rs::on_pool_event`), and `GossipStats::total_connections` is a LIFETIME counter that a +# supersede increments, so it must never be read as a count of unique peers (this crate reads +# `connected_peers` / `pool_stats().connected`, which are the live keyed-map sizes). +# +# Also on 0.17: the outbound /16+AS diversity caps (#1710/#1716) apply to the adoption path, so a +# test fixture adopting two peers must place them in DIFFERENT /16 groups. +# Pulled rustls-only (no native-tls) -- note `--all-features` is NOT a runnable configuration for +# dig-gossip: with both `ring` and `aws-lc-rs` reachable rustls cannot pick a provider. Its +# dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the graph still +# unifies to ONE of each. +# +# There are no vendored chia-protocol/chia-sdk-client forks to patch any more: dig-gossip 0.23.0 +# deleted both (dig_ecosystem#2228) and the workspace `[patch.crates-io]` section is RETIRED (see the +# root manifest). The #1529 three-rev lockstep is retired with it. An earlier version of this comment +# still described those patches as live; it was wrong, and a false manifest comment is how the next +# lane reasons wrongly about the version line. +# +# THIS DEP WAS THE CHIA-0.36 MIGRATION BLOCKER, AND IS NO LONGER (dig_ecosystem#3161, 2026-08-26). +# +# The old block, recorded because a future lane will otherwise re-derive it: the pinned rev was +# v0.25.0, which took `dig-peer-protocol = "0.6"`, whose only published 0.6.x declares +# `chia-protocol 0.26`. That put 0.26 into the graph through a path no dig-node manifest could edit, +# so no assignment of versions HERE could remove it. +# +# What cleared it was upstream moving, exactly as that comment predicted: dig-gossip `main` (v0.30.0, +# the rev pinned below) is on `dig-peer-protocol 0.7` and declares chia-protocol/traits/sha2/bls at +# 0.36.1. Repointing the rev is what let this workspace take the ceiling; it was never a local edit. +# +# The consequence that mattered is now discharged rather than merely avoided: `Bytes32` reaches the +# store-melt authorization path in `seams/dig_peer/store_melted.rs` from dig_gossip and from +# `digstore-chain` as the SAME type, so that path is no longer split across two chia-protocol +# versions. No conversion shim was added, and none may be — bridging two lines is the byte-drift +# class this migration exists to remove. +# +# THE dig-nat WALL IS CLEARED (dig-node#412 step 7). The rev pinned below is dig-gossip v0.32.0, +# which declares `dig-nat = "0.21"`, so the peer stack takes its ^0.21 tier with ONE dig-nat in the +# graph. The prediction the old note made -- "the fix is a dig-gossip release against `dig-nat +# ^0.21`, upstream, never a version edit here" -- is what happened; measured from the resolved LOCK +# rather than from the caret, because a caret-compatible transitive is invisible to a plain +# re-resolve (CLAUDE.md §2.4b). +# NC-7 exception: dig-gossip is git-pinned PERMANENTLY, not pending a publish — dig_ecosystem#2647. +# It cannot be published while its `native-tls` [patch.crates-io] fork stands, because `cargo publish` +# STRIPS [patch.crates-io] from the published metadata. A published dig-gossip would therefore build +# against UPSTREAM native-tls, compile cleanly, and silently accept inbound peers presenting no client +# certificate at all — a mutual-TLS bypass that no consumer could see from its own manifest. Its +# publish.yml guard is unconditional and deliberate: do not weaken it, do not `--allow-dirty` past it. +# dig_ecosystem#900 ("publish dig-gossip") was closed as superseded by #2647. +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay"] } +# The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. +# +# 0.9 is the TIP OF DIG-NODE'S CHIA LINE, and that — not 0.10 — is the correct target here (#2072). +# dig-constants 0.10.0 moved to chia-protocol 0.36.1 / chia-wallet-sdk 0.34, while this workspace +# builds against 0.26 / 0.30, which dig-gossip pins there via dig-peer-protocol 0.6 (see the +# dig-gossip entry above). Depending on 0.10 links a SECOND chia_protocol and every `Bytes32` this +# crate reads off `DIG_MAINNET` stops unifying with the rest of the workspace. Being current on +# dig-constants is therefore downstream of the chia 0.36 migration, not a version-string edit. +# +# Values are identical from 0.4.0 through 0.10.0 — the L2 genesis challenge and its six AGG_SIG +# domains were finalized in 0.4.0 and have not moved since — so this bump is value-neutral and buys +# the additive surface (DIG_ASSET_ID, the treasury hash/address, `dig.local`, `rpc.dig.net`). +# +# The graph still carries 0.4.0/0.5.1/0.8.0 because upstream crates hold them down through their +# PUBLISHED ranges (dig-gossip `>=0.2, <0.5`, dig-nat `>=0.4, <0.6`, dig-download `^0.8`), which a +# 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.13.0" +# -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- +# +# `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ +# chia-protocol 0.36.1, which is a DIFFERENT chia line from the 0.26 this workspace builds against, so +# the default surface would put a SECOND chia-protocol into the always-running service. Without the +# feature the crate is a pure BYTE CORE (`dig-smt + sha2 + thiserror`) exposing exactly what the node +# needs: the DPB container, `AnchoredRoot` and `VerifiedBody`. +# +# Consequence to keep in mind: `dig_social_profile::resolve` is NOT available here. The node resolves +# a store's on-chain root through its OWN `AnchoredRootResolver` (the coinset lineage walk), which is +# the same authority, reached by the node's existing seam. +dig-social-profile = { version = "0.7", default-features = false } +# -- L7 content-location DHT (PHASE-B, #163) --------------------------------------------------------- +# The Kademlia DHT that maps DIG content (store / capsule / root / resource) to the peer_ids holding +# it. The node LOCATES content it lacks via `find_providers`, and keeps its OWN held-inventory provider +# records CURRENT in the DHT (`announce_provider` / `withdraw_provider` / `republish`). It rides the +# SAME dig-nat mTLS transport as the rest of the peer network via a `DhtTransport` adapter (`dht.rs`). +# +# Sourcing: the crates.io release (NC-7). dig-download and dig-peer-selector below resolve dig-dht +# from crates.io too, so cargo unifies to ONE dig-dht instance — its `PeerId` / `Contact` / +# `DhtTransport` are then the exact types `dig_nat::connect` returns, rather than look-alikes. +# +# NOT bumped to 0.15, and this is the tier's REMAINING blocker rather than an oversight: dig-download +# 0.21.0 and dig-peer-selector 0.10.0 — the LATEST published of each — both require `dig-dht ^0.13`, +# read from the crates.io index. Declaring "0.15" resolves TWO dig-dht lines while cargo prints +# success, and dig-dht values cross from this crate into both consumers. dig-dht 0.15 is where +# `ProviderRecord::unverified_mirror_coin_id` lives (dig-dht#23), so the §25 mirror-coin pointer is +# unreachable here until dig-download AND dig-peer-selector release against `dig-dht ^0.15` — +# upstream, never a version edit here. Bridging two lines with a shim is the §4.1 byte-drift class. +dig-dht = "0.13" +# -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- +# The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX +# logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), +# feeding discovered peers into the pool as dial candidates (hints, verified by dialing) and formalizing +# what the ad-hoc `dig.getPeers` polling did. dig-pex ships a transport-agnostic sans-IO `PexEngine` +# this crate embeds; `pex.rs` is the dig-nat mux I/O adapter (SPEC §10.1, Appendix A). +# +# Sourcing: bare git form (no `rev`, like dig-nat/dig-dht) so cargo unifies any shared transitive deps +# to ONE copy across the graph; resolved to the tracked rev via the lockfile. dig-pex keeps a minimal +# dependency surface (serde/serde_json/tokio/rand — it mirrors the address shape rather than importing +# the transport crates), so there is no cross-crate type-unification concern here. +dig-pex = "0.1.1" +# -- Multi-source content fetch (#164) + redirect-on-miss (#165) -------------------------------------- +# The node-side download orchestrator: locate holders via dig-dht `find_providers`, confirm with +# `dig.getAvailability`, fan byte ranges across MULTIPLE providers simultaneously (`dig.fetchRange` +# over dig-nat), verify each range + the whole resource against the chain-anchored root, and +# reassemble — with pause/resume + `.download.tmp` GC. dig-node wires the real seams per the crate's +# implementers' note (see `download.rs`): DhtProviderLocator over our DhtService, NatRangeTransport +# over our mTLS identity, MerkleVerifier bound to the digstore proof format, FileSink/FileStateStore +# under the node's cache dir. +# +# Sourcing: the crates.io release (NC-7), matching dig-dht above. dig-download 0.6 deps dig-dht "^0.5" + +# dig-nat "^0.10" + dig-tls "^0.3" from crates.io, so cargo unifies its dig-dht + dig-nat + dig-tls with +# ours — ONE crate instance each across the graph (its re-exported `ContentId`/`ProviderRecord` ARE our +# dig-dht types). 0.6 keeps the injectable `SourceSelector` seam (dig-node implements it over +# dig-peer-selector, see `seams/dig_peer/selector_adapter.rs`), records range outcomes internally, and the +# fetch leg composes the FULL NAT ladder from an injected shared `NatRuntime` (`NatRangeTransport::new_with_runtime`). +# +# 0.8.1 adds the `ModuleDownloader` — the whole-`.dig`-module peer pull that turns a node which READ a +# resource into a complete resharer of the capsule (#1576, the reshare leg of the MVP flywheel). Its two +# seams are supplied here: `seams/dig_peer/module_transport.rs` (`NatModuleTransport`) and +# `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is +# deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this +# crate's anchor gate is the reshare path's ONLY root of trust. +# +# HELD on the 0.19 line, resolving 0.19.1 (dig_ecosystem#3128). 0.19.1 is a deliberate BACKPORT of the +# capsule-checkpoint filename fix (dig-download#38): `FileStateStore` hex-encoded the download key into +# the filename, so a module key `module:<64hex>:<64hex>` (136 bytes) became a 277-character name and +# EVERY capsule checkpoint write on Linux failed with `File name too long (os error 36)`. The same fix +# also shipped as 0.20.1, but 0.20 requires dig-dht ^0.12 / dig-nat ^0.20 / dig-peer ^0.12 / +# dig-tls ^0.4 — the whole dig_ecosystem#3152 cascade — which this crate cannot take yet, so the +# backport keeps the 0.19-line pins intact. Regression-tested here through the node's OWN production +# checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), +# because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no +# filename at all) and the one `FileStateStore` test used a 3-character key. +dig-download = "0.21" +# -- The shared peer client (#1283/#1576) ------------------------------------------------------------- +# `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed +# RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the +# `ModuleTransport` half of dig-download's module pull, which speaks `dig.getModuleInfo` / +# `dig.fetchModuleRange` — the two client methods dig-peer 0.5 added for exactly this leg. +# +# 0.5 is REQUIRED, not merely current: dig-peer 0.4 pulled dig-rpc-protocol 0.3, so a tree containing +# both it and dig-download's dig-rpc-protocol 0.5 carried TWO `ModuleInfo` types either side of the +# module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on +# dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by +# `crates/dig-node-core/tests/dependency_tree.rs`). +dig-peer = "0.13" +# -- Self-optimizing peer selection (#178) ------------------------------------------------------------ +# The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the +# providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation +# point + an adaptive relayed penalty from the REAL measured outcome of every range) and hands +# dig-download the ranked subset + per-peer concurrency, instead of dig-download picking sources +# blindly. Every completed/failed range streams back via `record_outcome` in real time, and a dropped +# source drives a `rebalance`. dig-node bridges dig-download's `SourceSelector` seam to this crate via +# `seams/dig_peer/selector_adapter.rs` (the @30↔@30 composition-root bridge — dig-download does NOT +# depend on dig-peer-selector; dig-node, one level up, wires them together). +# +# Sourcing: the crates.io release (NC-7), matching dig-dht/dig-download above. It deps dig-dht "^0.5" + +# dig-nat "^0.10" from crates.io, so cargo unifies the selector's `dig-nat` + `dig-dht` with ours — ONE +# crate instance each (its re-used `PeerId`/`ContentId`/`ProviderRecord`/`CandidateAddr` ARE our +# dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on +# dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local +# `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). +# 0.9.2 IS the chia-0.36 cascade landing (dig_ecosystem#3161). The old note here said "NOT bumped to +# 0.9.1: that release moves onto dig-tls 0.4, and this crate still resolves 0.3 through +# dig-nat/dig-gossip, so the graph would carry TWO `dig_tls::PeerId` types that do not unify". Both +# halves of that split are gone — dig-nat 0.20 and the dig-gossip rev both take `dig-tls ^0.4`, and +# the lock now resolves dig-tls ONCE at 0.4.0. +# +# 0.10 IS takeable as of dig-gossip v0.32.0, which declares `dig-nat = "0.21"` — the release the old +# note here was waiting on. It carries `dig-dht ^0.13`, which is what holds dig-dht at 0.13; see the +# dig-dht entry above. +dig-peer-selector = "0.10" +# The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity +# is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in +# the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the +# legacy self-signed `rcgen` cert as the node dials/serves over the dig-nat CA-signed mTLS model +# (#1280). `peer_id = SHA256(SPKI DER)` is stable across restarts. dig-tls re-exports chia-bls's +# `SecretKey`, unifying with this crate's chia-bls 0.36.1. 0.4 is the chia-bls 0.36.1 uplift and is +# now TAKEN (dig_ecosystem#3161): the old hold — "taking it here would split `dig_tls::PeerId` +# against the 0.3 that dig-nat/dig-gossip still resolve" — expired when dig-nat 0.20 and the +# dig-gossip rev both moved onto `dig-tls ^0.4`. The lock resolves dig-tls ONCE, at 0.4.0. +dig-tls = "0.4" +# -- The store-exchange DECISION layer (dig_ecosystem#3130) ------------------------------------------- +# `dig-sex` owns every cache/tier/relevance/selection DECISION this node used to make in-tree: what to +# keep, what to sacrifice first, and whether a fresh candidate is worth displacing an incumbent. It is +# PURE — no clock, no socket, no filesystem — so this crate keeps owning all of the I/O and hands it +# only the facts. The in-tree `relevance` module and `tier0_prefetch::effective_tier` moved there +# verbatim and are now consumed as `dig_sex::*`. +# +# It also re-exports the ONE `CapsuleIdentity` the ecosystem speaks, so this crate never declares its +# own and never pulls a second major of `dig-store` into the graph. +dig-sex = "0.5" +# The cache/eviction SEAM `dig_sex::TieredPolicy` implements. Declared DIRECTLY rather than reached +# through `dig-sex`, which re-exports `CapsuleIdentity` alone — naming `EvictionEntry`, +# `EvictionContext` and the `EvictionPolicy` trait (whose `select_evictions` must be in scope to call) +# requires the crate itself. The version is pinned to the one `dig-sex` resolves, so the `CapsuleIdentity` +# this crate builds and the one `TieredPolicy` matches on are the SAME type; a split would silently +# fail to unify. `dig_sex::TieredPolicy` is the first non-LRU implementation of the seam, and the +# module sweep here is its first consumer. +dig-store-cache = "0.1.1" +# The mTLS peer-RPC LISTENER (server side): present the node cert + REQUIRE a client cert (peer_id +# enforced). rustls/tokio-rustls with ring only (no OpenSSL/aws-lc for OUR usage), matching dig-nat. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +# IPv6-first, IPv4-fallback (ecosystem HARD RULE): bind the peer-RPC listener on the IPv6 unspecified +# address `[::]` as a DUAL-STACK socket (IPV6_V6ONLY cleared) so ONE socket serves both native IPv6 +# and IPv4-mapped peers. Mirrors dig-relay's `net.rs` / dig-gossip's own dual-stack bind. +socket2 = "0.5" +# -- Engine-side identity session (NODE-1, #910) ----------------------------------------------------- +# The engine is IDENTITY-AGNOSTIC: it holds NO user signing key. `control.session.attach` proves the +# attaching dig-app holds the profile's slot-0x0010 identity key by VERIFYING an Ed25519 signature over +# the byte-identical `DIGNET-SESSION-v1 ‖ nonce ‖ profile_did` challenge (session.rs). Ed25519 +# verification rides `ring` — ALREADY in the locked graph (the rustls/tokio-rustls transport uses it), +# so it adds no new crate. VERIFY-ONLY: the engine never holds a signing key. (dig-app signs with +# ed25519-dalek; ring verifies RFC-8032 signatures + is strict against malleable/small-order inputs.) +ring = "0.17" +tracing = "0.1" +# Best-effort CONCURRENT fan-out of the multi-source provider union (`UnionLocator`, #1443): query +# dig-dht + PEX + relay-introducer sources at once via `future::join_all` (order-preserving, so the +# dedup keeps DHT-first precedence). rustls/tokio already pull futures transitively; declaring it here +# makes it a first-class prod dep of the union seam. Minimal feature set (no executor). +futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } + +[dev-dependencies] +tempfile = "3" +# `dig_keystore::hardware::double::FakeDevice` -- the only way to exercise a SPECIFIC hardware +# outcome from a test. The real providers ship now (dig-keystore-hardware above), but which one a +# host answers with is a property of that host: no CI runner can be made to produce an +# indeterminate TPM probe on demand, and that is the outcome the refusal path turns on. +# Test-only: `testing` is never enabled for the library build. +dig-keystore = { version = "0.13", features = ["testing"] } +# Same git rev as the normal dependency above, with dig-gossip's `test-util` feature added. That +# feature gates `__connect_stub_peer_with_direction`, which registers a connected peer without real +# TLS — the only way to assert what a broadcast ACTUALLY put on the wire from a test. Declared here +# rather than on the normal dependency so the shipped binary never carries the test hook: cargo +# unifies the feature into test builds only, since dev-dependencies are not built for `cargo build`. +# NC-7 exception: same permanent git pin as the runtime dep above (dig_ecosystem#2647). +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay", "test-util"] } +# Generates the P-256 leaf key pairs the opcode-222 holdings tests sign with: `public_key_der()` IS the +# leaf `SubjectPublicKeyInfo` DER the wire carries as `provider_spki`, and whose SHA-256 is the +# announcing peer_id — so a test signer is a real §5.2 identity rather than a stand-in that could hide +# an attribution bug (#1429). +rcgen = "0.13" +# dig-download's in-memory harness (`MockRangeTransport`, `MockContent`, …), which the download-path +# tests drive the real orchestrator over. +# +# It is a DEV dependency and MUST STAY ONE (#1576). The `testkit` feature is also what makes +# `AcceptAnyModuleAnchor` — a fail-OPEN module anchor verifier — nameable at all; the crate compiles it +# out of a default consumer build precisely so a production wiring cannot reach it. The reshare path's +# anchor gate is its ONLY root of trust, so enabling this feature on the PRODUCTION `dig-download` entry +# above would put a bypass of that root of trust one `use` away. Dev-dependency features do not +# propagate to a consumer's build, so the node binaries never see it. +# +# Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which +# fails if `testkit` ever appears on the production entry. +dig-download = { version = "0.21", features = ["testkit"] } +# Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the +# serve-observability tests (#1595) assert what an operator would actually see in the node log — +# and that no payload byte or proof ever reaches it. +tracing-subscriber = { version = "0.3", features = ["fmt"] } +# Builds canonical `did:chia:` strings for the on-chain DID-resolver tests (matches dig-identity's codec). +chia-sdk-utils = "0.36.0" +# The session attach tests sign challenges with the BLS12-381 G2 AugScheme (the app's slot-0x0010 +# identity key), matching dig-ipc-protocol's signing contract and dig-identity's published G1 key. +chia-bls = "0.36.1" +# axum is a TEST-ONLY dependency of the engine library: the node no longer serves its +# own HTTP (the `dig-node-service` shell owns the axum transport, `dig-runtime` owns the +# in-process FFI path — dispatch is `handle_rpc`, which is transport-agnostic). The +# integration/unit tests still spawn small in-process axum mock servers (the §21 remote +# + a module server) to exercise the fetch/cache path hermetically. +axum = "0.7" +# In-process loopback mTLS peer drives the peer-network tests with NO real network (the CA-signed +# `dig_tls::NodeCert` minted by `load_or_generate_node_cert` is the test client's mTLS identity). +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "test-util"] } +futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } +# The peer_network.rs integration test implements PeerRpcResponder (an async trait). +async-trait = "0.1" From 71317f39874c6d7741cde0510a9d3075d51212db Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 05:18:46 -0700 Subject: [PATCH 03/19] feat(mirror): observe bond states from four readings (#412 step 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `mirror::observe` — a pure function over values that turns the capsules on disk, the mirror coins on chain, this node's open creates, and its spendable $DIG into the `BondObservation` the §25.8 surface pages. It takes no `MirrorEffects`, so it holds no `create` and no `reclaim`: the read surface cannot reach a spend because no spend capability is constructed on its path. States come from `pass::decide`, the same pure decision a real pass takes, rather than a second derivation that would drift. Supporting reads, both keyless: - `dig_wallet::operator_puzzle_hash` derives this node's public puzzle hash without ever producing a `WalletSigner`. - `WalletBackend::dig_balance_base_units` reads spendable $DIG at that hash. `None` is UNKNOWN, never zero — the two render as opposite claims. Constructing or scheduling a pass, and wiring a `MirrorSigner`, are step 8. Refs #412 --- crates/dig-node-service/src/mirror/mod.rs | 1 + crates/dig-node-service/src/mirror/observe.rs | 322 ++++++++++++++++++ crates/dig-node-service/src/mirror/runner.rs | 2 +- crates/dig-wallet/src/operator_wallet.rs | 31 ++ crates/dig-wallet/src/sage/rpc.rs | 27 ++ 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 crates/dig-node-service/src/mirror/observe.rs diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index abe0ac77..e8afa63f 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,7 @@ //! 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 observe; pub mod pass; pub mod plan; pub mod presence; diff --git a/crates/dig-node-service/src/mirror/observe.rs b/crates/dig-node-service/src/mirror/observe.rs new file mode 100644 index 00000000..1a77891f --- /dev/null +++ b/crates/dig-node-service/src/mirror/observe.rs @@ -0,0 +1,322 @@ +//! §25.8's OBSERVATION: what this node's bonds are, without doing anything about them +//! (dig-node#412 step 7). +//! +//! [`super::states`] renders an answer and [`super::runner`] acts on one. This is the step between: +//! it turns four already-gathered readings — the capsules on disk, the mirror coins on chain, this +//! node's own open creates, and the $DIG it can spend — into the +//! [`BondObservation`](super::states::BondObservation) the surface pages. +//! +//! # It cannot spend, and that is a property of the module rather than a rule about it +//! +//! [`super::runner::MirrorEffects`] holds both halves of the lifecycle: three reads and two spends. +//! A read surface built over it would hold a `create` and a `reclaim` it merely promised never to +//! call, and §25.8 is reachable from the token-gated control plane. So nothing here takes that +//! trait. [`observe`] is a **pure function over values**: it has no wallet, no signer, no chain +//! handle and no `&self`, so there is no unattended spend for a later edit to reach by accident. +//! +//! The same reasoning decides where the I/O lives. The caller performs the four reads and hands the +//! results in, which is what lets every property below be a fixture and a literal rather than a run +//! against a chain. +//! +//! # Nothing here recomputes what the decision already knows +//! +//! The states come from [`super::pass::decide`] — the same pure decision a real pass takes — rather +//! than from a second derivation written for the read path. A second derivation is a second answer +//! to "is this bond covered", and the two would drift in the direction nobody tests: the surface +//! would say `bonded` about a pair the pass was about to create a coin for, or `unfunded` about one +//! it had already covered. `decide`'s plan half is discarded here precisely because taking it is +//! step 8's act, not this one's. +//! +//! # The locked total is summed over the WHOLE chain observation +//! +//! Over every owned coin, before the plan splits it into keeps and reclaims, and including coins +//! being reclaimed — a broadcast reclaim has not confirmed, and the collateral is locked until it +//! does. It is read from each coin's own amount rather than from this epoch's requirement, because a +//! coin created under a previous requirement locks the previous amount. Summing the plan instead +//! would omit every coin the plan leaves alone, which is most of them on a healthy node, and would +//! report locked money as free — the one direction a money figure must never be wrong in. +//! +//! # The disk set is NOT presence-debounced, deliberately +//! +//! [`super::presence`]'s settling window exists to stop the node SPENDING on a capsule that may +//! vanish (§25.5). It is a spend suppressor, and a read surface is not a spend. Applying it here +//! would make a freshly-arrived capsule absent from its own node's bond list, which reads as a +//! missing capsule rather than as a deliberate wait — and a fresh tracker, which is all a stateless +//! read can build, suppresses everything it has ever seen exactly once. So this reports what is on +//! disk now. The cost is bounded and states itself: a capsule that arrived moments ago is reported +//! with the state it has right now, which for an uncovered pair is `unfunded` or `deferred` until +//! the next pass covers it. + +use dig_mirror_coin::MirrorInventory; +use num_bigint::BigInt; + +use dig_node_control_interface::results::CollateralRequirementResult; + +use super::pass::{self, PassInputs}; +use super::plan::{Bond, HeldMirror}; +use super::runner::ObservedCapsule; +use super::states::BondObservation; + +/// What an observation consults that it does not read for itself. +/// +/// The epoch, the requirement, the margin and the switch are parameters for the same reason +/// [`pass::decide`] takes them: one observation must see ONE epoch and ONE requirement throughout, +/// and a value re-read partway through could change underneath it — producing a page whose rows +/// were priced against two different requirements while claiming to describe one moment. +#[derive(Debug, Clone)] +pub struct ObserveContext { + /// The epoch in force. + pub current_epoch: i64, + /// This epoch's requirement, or the named reason it is unknown (§24.2). + pub requirement: CollateralRequirementResult, + /// The local safety margin, in basis points. + pub margin_bp: u64, + /// §25.7's switch. Reported because a node with creates OFF describes its bonds differently. + pub creates_enabled: bool, +} + +/// The whole §25.8 answer before paging, from four readings and a context. +/// +/// `dig_balance_base_units` is `Option` and `None` is **not zero**: a wallet that could not be read +/// is UNKNOWN, and reading it as zero reports every uncovered bond as `unfunded` — an out-of-funds +/// alarm about a wallet nobody read, which is the dig-app#300 conflation this surface exists to +/// remove. `None` yields `deferred{balance_unreadable}` on the affected rows and leaves every +/// `bonded`, `withheld` and `reclaiming` row untouched, because none of those three depends on the +/// balance. +pub fn observe( + observed: &[ObservedCapsule], + on_chain: &[HeldMirror], + in_flight: &[Bond], + dig_balance_base_units: Option, + ctx: &ObserveContext, +) -> BondObservation { + let (held, relayed) = super::runner::split_by_provenance(observed); + + // Over the WHOLE observation, not over the plan: see the module doc. `saturating_add` rather + // than a wrapping sum, because a total that wrapped would report a large locked figure as a + // small one. + let locked_dig_base_units = on_chain + .iter() + .map(|coin| coin.collateral_dig_base_units) + .fold(0u64, u64::saturating_add); + + let decision = pass::decide(&PassInputs { + held: &held, + relayed: &relayed, + on_chain, + in_flight, + current_epoch: ctx.current_epoch, + requirement: &ctx.requirement, + margin_bp: ctx.margin_bp, + dig_balance_base_units, + creates_enabled: ctx.creates_enabled, + }); + + BondObservation { + states: decision.states, + locked_dig_base_units, + epoch: ctx.current_epoch, + } +} + +/// The mirror coins an inventory says this node owns, in the planner's vocabulary. +/// +/// A coin whose declared epoch does not fit an `i64` is DROPPED rather than clamped. The epoch is a +/// `BigInt` on the wire because the hint morph is arithmetic over unbounded integers, so a stranger +/// can place a coin declaring any epoch at all for the price of a dust coin. Clamping such a value +/// would make that coin claim to bond the current epoch — a stranger choosing what this node reports +/// about its own bonds. Dropping it costs nothing: `dig_mirror_coin::list` already authenticates +/// ownership from the lineage proof, so a coin here is one this node controls, and one it controls +/// with an unrepresentable epoch is one no pass could ever act on anyway. +/// +/// [`MirrorInventory::skipped`] and [`MirrorInventory::complete`] are the caller's to inspect; this +/// maps only the coins the scan resolved. +pub fn held_mirrors(inventory: &MirrorInventory) -> Vec { + inventory + .coins() + .iter() + .filter_map(|coin| { + Some(HeldMirror { + coin_id: hex::encode(coin.coin().coin_id()), + store_id: hex::encode(coin.store_launcher_id()), + root: hex::encode(coin.root_hash()), + epoch: epoch_as_i64(coin.epoch())?, + collateral_dig_base_units: coin.collateral(), + }) + }) + .collect() +} + +/// A declared epoch as an `i64`, or `None` when it does not fit. +/// +/// Separated so the drop-rather-than-clamp rule above is a named, testable step rather than a +/// `try_into` buried in a closure. +fn epoch_as_i64(epoch: &BigInt) -> Option { + i64::try_from(epoch).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_node_core::CapsuleProvenance; + + use super::super::pass::BondState; + + /// A store/root pair spelled out to 64 hex, so the canonicalisation downstream has real input. + fn bond(store: &str, root: &str) -> Bond { + Bond::new(store.repeat(32), root.repeat(32)) + } + + fn capsule(store: &str, root: &str, provenance: CapsuleProvenance) -> ObservedCapsule { + ObservedCapsule { + bond: bond(store, root), + provenance, + } + } + + fn coin(store: &str, root: &str, epoch: i64, collateral: u64) -> HeldMirror { + HeldMirror { + coin_id: "cc".repeat(32), + store_id: store.repeat(32), + root: root.repeat(32), + epoch, + collateral_dig_base_units: collateral, + } + } + + const REQUIRED: u64 = 1_000; + + fn ctx() -> ObserveContext { + ObserveContext { + current_epoch: 7, + requirement: CollateralRequirementResult::Known { + epoch: 7, + required_per_store_dig_base_units: REQUIRED, + stores: 10, + owners: 3, + census_height: 100, + }, + margin_bp: 0, + creates_enabled: true, + } + } + + /// The locked total spans EVERY owned coin, including one the plan will reclaim. + /// + /// The fixture is deliberately not "all coins are keepers": the second coin bonds a capsule that + /// is NOT on disk, so the plan reclaims it, and a total summed from the plan's keep list — or + /// from the page — would report `600` rather than `1000`. Money reported as free while it is on + /// chain is the one direction this figure must never be wrong in, and a fixture where every coin + /// is kept cannot tell the two implementations apart. + #[test] + fn the_locked_total_includes_a_coin_the_plan_is_about_to_reclaim() { + let observed = [capsule("aa", "11", CapsuleProvenance::Held)]; + let on_chain = [coin("aa", "11", 7, 600), coin("bb", "22", 7, 400)]; + + let observation = observe(&observed, &on_chain, &[], Some(10_000), &ctx()); + + assert_eq!( + observation.locked_dig_base_units, 1_000, + "both owned coins are locked; the reclaimed one has not confirmed" + ); + assert!( + observation + .states + .iter() + .any(|(b, s)| *b == bond("aa", "11") && *s == BondState::Bonded), + "the covered capsule is bonded" + ); + } + + /// A `Relayed` capsule is reported as `Withheld` rather than omitted. + /// + /// Two capsules with DIFFERENT provenance and neither on chain, so the assertion distinguishes + /// "the relayed half reached the states list" from "everything reached it". A fixture with only + /// a relayed capsule would pass against an implementation that reported every capsule as + /// withheld, which is the nearest wrong thing this split can do. + #[test] + fn a_relayed_capsule_is_withheld_and_a_held_one_is_not() { + let observed = [ + capsule("aa", "11", CapsuleProvenance::Held), + capsule("bb", "22", CapsuleProvenance::Relayed), + ]; + + let observation = observe(&observed, &[], &[], Some(10_000), &ctx()); + + let state_of = |b: Bond| { + observation + .states + .iter() + .find(|(k, _)| *k == b) + .map(|(_, s)| s.clone()) + }; + assert_eq!(state_of(bond("bb", "22")), Some(BondState::Withheld)); + assert_ne!( + state_of(bond("aa", "11")), + Some(BondState::Withheld), + "the held capsule is this node's own bond, not a withheld one" + ); + } + + /// An unreadable balance defers the rows it prices and leaves the rest alone. + /// + /// The fixture carries one COVERED bond beside one uncovered bond, so a `None` balance that + /// wrongly blanked the whole answer — or wrongly reported the uncovered row as `unfunded` — is + /// visible. An all-uncovered fixture could not tell `FundsUnknown` from a call-level failure. + #[test] + fn an_unreadable_balance_defers_only_the_rows_it_prices() { + let observed = [ + capsule("aa", "11", CapsuleProvenance::Held), + capsule("bb", "22", CapsuleProvenance::Held), + ]; + let on_chain = [coin("aa", "11", 7, REQUIRED)]; + + let observation = observe(&observed, &on_chain, &[], None, &ctx()); + + let state_of = |b: Bond| { + observation + .states + .iter() + .find(|(k, _)| *k == b) + .map(|(_, s)| s.clone()) + }; + assert_eq!( + state_of(bond("aa", "11")), + Some(BondState::Bonded), + "a covered bond does not depend on the balance" + ); + assert_eq!( + state_of(bond("bb", "22")), + Some(BondState::FundsUnknown), + "an uncovered bond is UNKNOWN, never a fabricated shortfall" + ); + } + + /// The epoch reported is the context's, not one derived from the coins. + /// + /// A node whose coins are all from a previous epoch still describes the epoch in force; deriving + /// it from the inventory would make a node with no coins have no epoch, and a node holding stale + /// coins report a past one as current. + #[test] + fn the_reported_epoch_is_the_one_in_force_not_the_coins() { + let on_chain = [coin("aa", "11", 3, 500)]; + + let observation = observe(&[], &on_chain, &[], Some(0), &ctx()); + + assert_eq!(observation.epoch, 7); + } + + /// An epoch that does not fit an `i64` is DROPPED, never clamped. + /// + /// Asserted on the conversion directly, because the value cannot be built through `HeldMirror`: + /// its field is already an `i64`, so the only place the decision is observable is here. Both + /// bounds are checked from BOTH sides — `i64::MAX` must convert and `i64::MAX + 1` must not — + /// since a conversion tested only from below would confirm itself. + #[test] + fn an_out_of_range_declared_epoch_does_not_become_a_valid_one() { + assert_eq!(epoch_as_i64(&BigInt::from(i64::MAX)), Some(i64::MAX)); + assert_eq!(epoch_as_i64(&BigInt::from(i64::MIN)), Some(i64::MIN)); + assert_eq!(epoch_as_i64(&(BigInt::from(i64::MAX) + 1)), None); + assert_eq!(epoch_as_i64(&(BigInt::from(i64::MIN) - 1)), None); + } +} diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 210ef771..6199e747 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -336,7 +336,7 @@ impl PassRunner { /// than a step inside a [`MirrorEffects`] implementation, so that there is exactly one of it: a /// second implementation of this rule is a second answer to "what may this node spend its own money /// advertising", and the two would not stay equal. -fn split_by_provenance(observed: &[ObservedCapsule]) -> (Vec, Vec) { +pub(super) fn split_by_provenance(observed: &[ObservedCapsule]) -> (Vec, Vec) { let mut held = Vec::new(); let mut relayed = Vec::new(); for capsule in observed { diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index 8659798a..a230bd78 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -101,6 +101,37 @@ impl OperatorWallet { } } +/// This node's operator PUZZLE HASH, derived without ever producing a signer. +/// +/// `None` for exactly the cases [`OperatorWallet::open`] returns `None` for: no seed, no device key, +/// a seal that will not open, or a phrase that does not derive. A caller MUST report the capability +/// as unavailable rather than substitute any other address — a balance or a coin list read for the +/// wrong puzzle hash is a confident number about somebody else's money. +/// +/// # Why this exists beside [`OperatorWallet::open`] rather than being a call to it +/// +/// The puzzle hash is a **public** value: this node's receive address, the address the dig-app +/// deposit flow funds, and the address reclaims return to. A [`WalletSigner`] is not. §25.8's bond +/// surface (dig-node#412) is a token-gated READ that needs the first and has no business +/// materialising the second, so this function's return type is a [`Bytes32`] and there is no signer +/// value anywhere on its path. +/// +/// That distinction is held by the **type**, not by a convention: a control-plane read built on this +/// cannot reach a signing capability, because no such capability is ever constructed for it to +/// reach. `OperatorWallet::open` remains the only way to obtain one, and the mirror lifecycle +/// remains its only caller. +/// +/// The secret key derived along the way is dropped at the end of this function and never leaves it; +/// the phrase lives in a zeroizing wrapper for the length of the derivation, exactly as in +/// [`OperatorWallet::open`], and nothing here is logged. +/// +/// §908 is untouched. This is the §16.4 machine-custody wallet — the node's own money — and no user +/// seed reaches this process. +pub fn operator_puzzle_hash(paths: &WalletPaths) -> Option { + let phrase = autoseed::open_operator_phrase(paths)?; + Some(digstore_chain::keys::derive_wallet_keys(&phrase).ok()?.owner_puzzle_hash) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index cbf1dcd1..9eb7b963 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -1213,6 +1213,33 @@ impl WalletBackend { Self::SUPPORTED_METHODS.contains(&method) } + /// The spendable **$DIG** at this node's own operator puzzle hash, in DIG CAT base units. + /// + /// `None` when the balance could not be read — an unreachable chain source, an unsynced replica + /// that cannot answer for this address, a figure too large for a `u64`. `None` is **not zero**: + /// §25's bond surface reports an uncovered bond as `deferred{balance_unreadable}` on `None` and + /// as `unfunded` on `Some(0)`, and those are opposite claims — the first says the node does not + /// know, the second raises an out-of-funds alarm. Substituting zero for an unreadable balance is + /// the dig-app#300 conflation. + /// + /// Takes the puzzle hash rather than an address so no caller has to spell an address, pick a + /// prefix, or name the asset: the encoding and the canonical `$DIG` asset id both stay inside + /// this crate, where the one definition of each already lives. A caller that assembled its own + /// address string could read the right amount of the wrong asset at the wrong network's prefix, + /// and every one of those returns a confident number. + /// + /// This is a READ. It confers no custody and touches no key: the puzzle hash is a public value. + pub async fn dig_balance_base_units(&self, owner_puzzle_hash: Bytes32) -> Option { + let address = self.address_of(&hex::encode(owner_puzzle_hash)); + let read = self + .balance_for_address(&address, BalanceAsset::DIG) + .await + .ok()?; + // Narrowed rather than saturated. A saturating cast would report a balance above `u64::MAX` + // as exactly `u64::MAX` — the largest possible confident wrong number on a funding decision. + u64::try_from(read.balance).ok() + } + // ---- address helpers -------------------------------------------------- fn address_of(&self, puzzle_hash_hex: &str) -> String { From 5a1f3d4d30fad82b6c4c5150ad7492ff81f24e2b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 05:22:49 -0700 Subject: [PATCH 04/19] fix(deps): restore LF on dig-node-core's manifest and make the section lookup fail loudly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build(deps)` 2614d07c saved crates/dig-node-core/Cargo.toml with CRLF line endings. Not one declaration changed — 1147 of its 1175 changed lines were line-ending churn — but `dependency_tree.rs` reads that manifest through `include_str!` and searches it for "\n[dependencies]\n", which CRLF bytes never match. The lookup's `unwrap_or(0)` then turned a failed search into a silent fallback to offset 0, so the guard searched `[package]` instead, found no `dig-download` there, and panicked claiming it was absent from the production tree. `dig-download = "0.21"` had never moved: it is on line 448 of the `[dependencies]` section, exactly where it was before. Two changes, both at the cause: - the manifest is normalised to LF before any search, so these assertions stay about DECLARATIONS rather than about how a file happened to be saved; - the lookup panics naming the section it could not read. A lookup that FAILED is not evidence about any dependency, and offset 0 makes every "X is a production dependency" assertion report absence with total confidence. The assertion itself is unchanged and no expect was loosened. Also bumps the workspace to 0.173.0: main took 0.172.0 while this branch was open, and step 7 adds a capability. Refs #412 --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/dig-node-core/Cargo.toml | 1182 ++++++++--------- crates/dig-node-core/tests/dependency_tree.rs | 26 +- 4 files changed, 615 insertions(+), 597 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e867b9e..24e835fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.172.0" +version = "0.173.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0bdc5d42..45668d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.172.0" +version = "0.173.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 00169914..e9266f91 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -1,591 +1,591 @@ -[package] -name = "dig-node-core" -# The engine library's OWN version, independent of the released binary's (`[workspace.package].version` -# in the root manifest, which only dig-node-service tracks). It moves when this crate's PUBLIC surface -# moves: 0.24.0 acknowledges that the surface now exposes dig-nat 0.14 / dig-dht 0.8 types, a -# compatibility-relevant change even though the crate is consumed in-workspace and is not published to -# crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for -# what the exported types are (dig_ecosystem#1785c). 0.26.0 does the same for the 0.15 line: the -# exported dig-nat / dig-dht types rotate identity again, and `DhtError` — reachable through this -# crate's dig-dht re-exports — now carries `SafeText` instead of `String` (dig_ecosystem#1674/#1675). -# 0.27.0 adds the pure `relevance` module (CacheTier / relevance() / evict_key / should_displace) — -# a new, backwards-compatible public export (dig_ecosystem#1986), hence a MINOR bump. -# 0.28.0 adds the pure `tier0_selector` module (Candidate / select_within_budget / tier0_budget_bytes / -# should_displace_tier0) built on 0.27's relevance module — a new, backwards-compatible public export -# (dig_ecosystem#1988), hence another MINOR bump. -# 0.31.0 adds cache observability to `cache.stats` (§7.10e) — `refetch_count` + per-tier `tiers` -# occupancy fields, and makes `InboundDemand::entry_count` a real (no-longer-`#[cfg(test)]`) public -# API — a new, backwards-compatible surface (dig_ecosystem#1991), hence a MINOR bump. -# 0.47.0 adopts dig-gossip 0.25.0 and splits the announce seams by ORIGIN: `ProfileTransport` gains a -# required `announce_root_local` and `MeltBroadcast` a required `broadcast_local`, so a locally -# originated announce takes dig-gossip's dedup-exempt path and a re-announce of an unchanged root is -# no longer suppressed forever (dig_ecosystem#3061). Adding a required trait method is BREAKING for an -# out-of-workspace implementor; this crate is consumed in-workspace only and is pre-1.0, so it is a -# MINOR bump under SemVer's 0.x rule -- recorded here rather than letting the number imply the trait -# surface held still. -# 0.53.0 adds the module-granularity capsule relay leg (`seams/dig_peer/module_relay.rs`, gated OFF -# by default) and makes the provider/candidate locator readers absence-safe: `NodeContent::find_providers` -# and `NatModuleTransport::discovered_candidates` now return `BestEffort` rather than a bare `Vec`, -# so a source that could not be consulted can no longer be read as a proven absence (dig_ecosystem#3128, -# dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; -# this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x -# rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.62.0" -edition = "2021" -license = "GPL-2.0-only" -description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." - -[lib] -name = "dig_node_core" -path = "src/lib.rs" - -[dependencies] -# Bounds the whole-module descriptor memo (`seams/dig_peer/module_serve.rs`, #1615/G2): a per-process -# cache of `dig.getModuleInfo` answers keyed by (store, root), capped so a long-running node cannot -# accumulate one entry per module it has EVER described (each entry is tens of KB — up to 512 -# chunk_hashes + chunk_lens). Already a transitive dependency (pulled in via the workspace's other -# crates); promoted to a direct one now that this crate uses it itself. -lru = "0.12" -# The `.dig` STORE-FORMAT LIBRARY crates. These live in the digstore repo (the store -# CLI + format/read libraries) and are consumed here as GIT dependencies: the node -# depends on the store libs, NEVER the reverse (digstore is only ever an RPC client of -# a node). Pinned to a single digstore rev so the whole store-format surface the node -# reads/verifies/decrypts against is one coherent version. Bump all seven together. -# -# digstore-host — instantiate a compiled module + `serve_blind` (ciphertext + proof). -# digstore-core — wire types (ContentResponse), codec, Bytes32/48, the PublicManifest -# type + `datasection::read_public_manifest` (#176 Phase C). -# digstore-remote — the §21 remote client + persistent identity signer (§21.9). -# digstore-chain — CHIP-0035 singleton lineage walk (chain-anchored root). -# digstore-stage — the shared stage→compile engine for the `dig.stage` RPC (turn a -# folder into a capsule in-process, #95 Pass C). Its build script -# embeds the guest wasm (BINDING contract D6) via the DIGSTORE_GUEST_WASM -# override this repo sets in .cargo/config.toml (see vendor/). -# digstore-crypto — read-path crypto primitives. -# digstore-compiler — `extract_data_section_blob` (#176 Phase C): pulls the embedded DIGS -# data-section blob out of a compiled `.dig` module's wasm binary so -# `dig.getManifest` can read section 13 (the PublicManifest) WITHOUT a -# wasmtime instantiation (the manifest is PUBLIC, unencrypted data — no -# `serve_blind` decrypt needed). Already a transitive dependency via -# digstore-stage; pinned directly at the SAME rev so its public API is -# usable here too. -# -# Bumped to a rev past digstore's "Add normalized public manifest to the .dig format" -# commit (08f5d39) so `PublicManifest`/`read_public_manifest`/`extract_data_section_blob` -# exist. -# -# Bumped again to 5872240f (digstore v0.19.2, fix #1473) to adopt the HARDENED, -# unforgeable `digstore_chain::singleton::verify_pinned_root`. The rev before that (d5e52fb) -# anchored store identity on the attacker-controllable curried `SingletonStruct.launcher_id`, -# so a forged singleton currying `launcher_id == store_id` (hinted to store_id) could pass as -# chain-verified. 5872240f anchors identity on the UNFORGEABLE launcher coin (`coin_id == -# store_id`) via a bounded backward `parent_coin_info` walk, making the read-root anti-rollback -# check (#747/#841/#852-node) SOUND. -# -# Bumped again to the CURRENT rev, 161c2a3108cb4bb6c8791e96a26588ec99afb029 (digs#59), which is a -# descendant of 5872240f — so the launcher-coin anchoring above still holds — and additionally -# carries digstore-chain onto `chia-wallet-sdk` 0.36. See the SDK note below for why that bump is -# a security fix rather than housekeeping. -# -# Both bumps leave the public signature unchanged (a pure rev bump). Bump all seven together. -# NC-7 exception: dig_ecosystem#901 (the crates.io repoint) leaves the seven `digstore-*` deps below -# git-pinned ON PURPOSE. Three independent facts each block the repoint on their own, and all three are -# upstream of this workspace: -# -# 1. THE SET IS ATOMIC, so `digstore-core` cannot move alone even though it IS published (0.28.0). -# Inside the digstore workspace every sibling declares `digstore-core = { path = "../digstore-core" }`, -# so a git-sourced `digstore-host` always carries the git `digstore-core`. Repointing only the -# published members would put TWO `digstore-core` in the graph — a crates.io one for this crate's -# direct imports and a git one inside its siblings — and `ContentResponse`/`Bytes32` would stop -# unifying across the serve seam. They move together or not at all. -# -# 2. MOST OF THE SET IS UNPUBLISHED, and the closure is wider than it looks. Absent from crates.io: -# `digstore-host`, `digstore-remote`, `digstore-stage`, `digstore-crypto`, `digstore-compiler` — -# plus `digstore-prover`, `digstore-store`, `digstore-chunker` and `digstore-cli`, which the five -# pull in transitively and which no checklist had named. Each declares its siblings as a BARE -# `path = "../x"` with no `version` key, which `cargo publish` refuses outright, so publishing them -# is real work in DIG-Network/digs rather than a release button. -# -# 3. (CLEARED 2026-08-26, dig_ecosystem#3161.) This used to read "the published members are on the -# OTHER chia line", because digstore declared the primitives at 0.36.1 while this workspace was -# held at 0.26 by dig-gossip -> dig-peer-protocol 0.6. Both halves moved: the dig-gossip rev now -# carries 0.36.1, and the digstore rev below is digstore's own ceiling fix. The primitives are -# one line across the boundary, so nothing is split. -# -# The SDK family has now collapsed too (2026-08-26, dig-node#374). This used to read that -# digstore's rev took `chia-wallet-sdk 0.34` against dig-wallet's 0.36.0, and that closing the -# gap needed a digstore release — upstream, not here. That release happened: DIG-Network/digs#59 -# moved `digstore-chain` onto `chia-wallet-sdk 0.36` (and `datalayer-driver` 5, which is what -# required the SDK bump), and the rev below is that commit. -# -# Repointing was a SECURITY fix, not tidiness. `chia-sdk-driver` 0.30 and 0.34 decode the oracle -# fee out of an attacker-authored CREATE_COIN memo as -# `BigInt::from_signed_bytes_be(&memo).to_u64_digits().1[0]`, and an empty or explicit-zero memo -# makes that index an empty vec — a panic reachable from any spend dig-node walks through -# `Datastore::from_spend` (the lineage walk, `verify_pinned_root`, `sync_datastore`). 0.36 guards -# the empty case and uses `try_into`, so both become `DriverError::InvalidMemo`; it also stops -# silently discarding a negative memo's sign and truncating an over-`u64` fee to its low digit. -# -# So this row is still downstream of publishing the digstore workspace. Do NOT repoint these -# piecemeal, and never let the pin regress to a floating git BRANCH: the single `rev` is what makes -# the whole store-format surface reproducible. -digstore-host = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-core = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-remote = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-chain = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-stage = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-crypto = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -digstore-compiler = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } -chia-protocol = "0.36.1" -# The `Streamable` to_bytes/from_bytes trait, used to serialize the chat `ChatMessage` payload before -# the dig-message seal. Same 0.36.1 as chia-protocol so the byte layout agrees across the workspace. -chia-traits = "0.36.1" -# The ecosystem's canonical address-family / IPv6-first primitive (CLAUDE.md §5.2). The advertise -# path keys + orders candidate addresses via `dig_ip::Family` and aggregates them source-tagged + -# de-duplicated via `dig_ip::PeerCandidates` — never a hand-rolled family sort (epic #1020, #1032). -# A leaf crate (no transitive DIG deps), so the crates.io release is used directly. -dig-ip = "0.1.2" -# The canonical dig-app ⇄ dig-node IPC session/signing contract (engine role-half + wire types + -# domain builders + frame bounds). SSOT shared with dig-app so the two halves can never drift (#1080). -# Pinned EXACTLY: 0.3.0 is the first release on this line and the caret range would silently accept -# a future 0.3.x. The crate reaches this one through the IPC surface, so an unreviewed minor is a -# wire-shape change arriving without a PR (dig-node#369 gate). -dig-ipc-protocol = "=0.3.0" -# On-chain DID resolution (WU3, #778): the honest chain-authenticated read path the production -# DidSigningKeyResolver delegates to. -# -# NOW A CRATES.IO DEP (dig_ecosystem#901). It was git-pinned at `tag = "v0.4.0"` because the crate was -# unpublished; it is published now, so the git source is retired and NC-7 is satisfied for this dep. -# -# 0.4.2 IS THE CORRECT TARGET, AND IT IS NOT THE LATEST (0.7.1). Two separate constraints pick it, and -# both are "one line per family" (CLAUDE.md §2.4b), not staleness: -# -# 1. CHIA LINE. This crate re-exports `Bytes32` and `Coin` FROM dig_identity (`seams/dig_peer/ -# session.rs`), so dig-identity's chia-protocol MUST be this workspace's line. Published lines: -# 0.4.1 / 0.4.2 / 0.5.0 -> chia-protocol 0.26, chia-sdk-utils 0.30 <- this workspace -# 0.6.0 / 0.7.0 / 0.7.1 -> chia-protocol 0.36.1, chia-sdk-utils 0.34+ -# Taking 0.7.1 links a SECOND chia-protocol and stops every `Bytes32` crossing the resolver seam -# from unifying — the same split the dig-gossip and dig-constants notes below describe. The 0.6+ -# uplift is downstream of the chia-0.36 cascade (dig_ecosystem#2761), not an edit here. -# -# 2. DEDUPLICATION. A 0.x minor is a semver-INCOMPATIBLE boundary, so this pin must match whatever -# the rest of the graph already resolves or the tree carries TWO dig-identity copies — which for -# an identity/key crate is the whole point of pinning it at all. -# -# 0.7.1 is that value on the chia-0.36.1 ceiling (dig_ecosystem#3161): `dig-nat 0.20` and -# `dig-message 0.7` both declare `dig-identity ^0.7`, and 0.7.1 is the only line whose `chia-bls` is -# 0.36.1. The previous pin here was 0.4.2, chosen by the same rule against the then-current -# `dig-message 0.5` / `dig-nat 0.18`; it is the RULE that is stable, not the number. Re-derive it -# from `Cargo.lock` rather than trusting this comment. -dig-identity = "0.7.1" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "signal"] } -# Object-safe async trait for the injectable anchored-root resolver (the -# mandatory read-path chain pin, #127): production walks coinset, tests inject a -# deterministic resolver. -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -# The canonical node<->node JSON-RPC contract: the Method catalogue (wire names + -# per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its -# method names + the peer allowlist from HERE (never hand-rolled) so the contract -# cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.10.2" -# The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope -# the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 -# envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. -dig-message = "0.7" -# The chat message-TYPE layer: the five chat payloads on dig-message's dig-chat band + the registry -# wiring. A crypto-free, content-blind type layer — the DIGCHAT1 seal is carried as opaque bytes. -dig-chat-protocol = { path = "../dig-chat-protocol", version = "0.2" } -base64 = "0.22" -hex = "0.4" -# The whole-module pull's one content-addressing primitive (#1576): the module descriptor's per-chunk + -# whole-blob hashes on the SERVE side, and the pre-announce re-hash that proves the artifact about to be -# cached + announced is byte-identical to the one the anchor gate admitted. Production, not test-only — -# this hash is what stands between a promoted artifact and a promoted-but-unverified one. -sha2 = "0.10" -filetime = "0.2" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -fs4 = { version = "1.1.0", features = ["sync"] } -directories = "6.0.0" -# OS CSPRNG for the per-stage content-authoring BLS key (#95 Pass C dig.stage). -getrandom = "0.2" -# -- dig_ecosystem#2168: the node MACHINE key at rest -------------------------------------- -# The canonical DIG keystore. Consumed with `custody` OFF (it is off by default) because this -# crate seals only the node's OWN machine identity seed and must never be able to name the -# user-custody API -- the #908 boundary expressed in the dependency graph rather than in prose -# (dig-keystore SPEC.md 18.2, which names this engine as the intended opaque-only consumer). -# 0.10 rather than 0.9: 0.10 is a BREAKING release that moves the crate's re-exported chia-bls -# onto the ecosystem 0.36.1 line. That line is ALREADY in this lock (six chia-bls lines resolve -# here today), and the `opaque` surface this crate uses is bytes + Password with no BLS type on -# it at all, so nothing crosses between lines -- and adopting 0.10 now avoids routing the node's -# identity onto a type identity that was about to change under it. -# -# 0.13 is the FLOOR for taking hardware binding live, and the reason is not the providers. -# dig-keystore#18 made `create_with_rng` an atomic check-then-write in 0.13.0; 0.12 will happily -# overwrite an existing record. On a SOFTWARE tier an overwritten machine key is recoverable from -# a backup of two plaintext-sealed halves. Hardware wrapping binds the wrapping key to a -# non-exportable component, so the same overwrite becomes PERMANENT -- there is nothing to restore -# from. Sealing therefore upgrades that bug from recoverable to unrecoverable, and 0.12 is not a -# version this seam may be enabled on. -dig-keystore = "0.13" -# The platform trusted-component providers (Windows TPM 2.0 via CNG, Apple Secure Enclave, Linux -# TPM 2.0). A SEPARATE crate because `dig-keystore` forbids `unsafe` and every one of these is FFI; -# it is the workspace member dig_ecosystem#1693 asked for, and it landed in dig-keystore v0.12.0. -# Without it the node's hardware seam -- already fully composed at `machine_key.rs` -- passes a -# hardcoded `None` and can never report anything but `Software(NotRequested)`. -# 0.2 pairs with dig-keystore 0.13: it adds the macOS Secure Enclave and Linux TPM 2.0 providers, -# and reclassifies a TPM AUTHORIZATION refusal (owner password set, dictionary-attack lockout) as -# `Absent` rather than `Indeterminate`, so an ordinary locked-down Windows box degrades instead of -# refusing. Only a genuinely uninspectable host is indeterminate now. -dig-keystore-hardware = "0.2" -# The machine key lives in the SAME user-global identity dir digstore_remote used for the -# plaintext seed (`/dig`), so the #2168 migration finds it in place and the node -# keeps its peer_id. digstore keeps its own `identity_dir` private, so the path is reproduced. -dirs = "5" -# Keeps the unsealed seed off the heap after use, matching what dig-keystore hands back. -zeroize = "1" - -# -- L7 DIG Node peer network (PHASE-2b, #162) ------------------------------------------------------- -# The NAT-traversal + connected-peer-pool layer. The bespoke in-node relay client (formerly relay.rs) -# is RETIRED: the relay connection now lives inside dig-nat (the `connect()` ladder's last-resort tier -# + the persistent reservation) and dig-gossip (the introducer-backed connected peer pool). dig-node -# consumes both to auto-discover peers via relay.dig.net, maintain a pool, and serve/issue the L7 peer -# RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). -# -# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack -# (dig-gossip 0.15.0, dig-dht 0.5.1, dig-download 0.7.1, dig-peer-selector 0.5.2) now depends on dig-nat -# ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE -# graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the -# relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a -# `RelayTunnel`, and `RelayAcceptor` runs the mTLS SERVER half over it (#1536, Leg B) — on top of -# 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes -# the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. -# 0.11.2 is REQUIRED (#1586): before it, `RangeFrame::bytes` was read with `serde_bytes`, which over -# the JSON frame wire takes the served base64 string as its literal characters — so every -# `dig.fetchRange` window arrived as its own base64 text, the reassembler rejected the frame, and the -# read leg aborted before reading a byte. 0.11.2 reads/writes `bytes` as base64 (the canonical -# `dig_rpc_protocol::types::RangeFrame` wire the node's own serve path emits). -# dig-nat 0.13 is the release that introduced the FALLIBLE, payload-capped `RangeFrame::encode` plus -# the paged-prologue sender API (`with_identity` carrying `chunk_count`, `with_chunk_lens_page` / -# `chunk_lens_offset`, `with_chunk_index`, `RangeRequest::skip_layout`). Everything 0.14 adds is the -# RECEIVER (`ChunkLensAssembler`) plus the `split_chunk_lens_pages` helper, so 0.13 carries the whole -# serve-side fix for #1640/#1668. -# -# The dig-nat MAJOR must match the whole peer stack's, and it moves as ONE step (#1771). This crate -# builds its own `dig_nat` `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` -# values and passes them INTO dig-download, dig-gossip and dig-peer-selector, so a second dig-nat -# instance does not merely bloat the tree -- those calls stop typechecking (verified: E0308 `expected -# dig_nat::relay::RelayStatus, found dig_nat::RelayStatus`). So dig-nat cannot be bumped alone, and -# dig-gossip/dig-dht/dig-download/dig-peer/dig-peer-selector cannot be bumped past it: the dig-nat -# 0.18 line (dig-dht 0.11, dig-download 0.19, dig-peer 0.11, dig-peer-selector 0.9, dig-gossip on the -# pinned rev below) is a single atomic cascade, every member of which is published. -# `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so a -# partial cascade cannot quietly reintroduce the duplicate. -# -# This line carries the SafeText peer-text cascade (#1674/#1675): error text of remote origin is -# held as `dig_nat::SafeText` rather than `String`, so a hostile peer's control characters cannot forge -# a line in an operator's log. On a partial bump the failure is now LOUDER than the RelayStatus mismatch -# above -- `SafeText` crosses dig-dht's AND dig-peer's public error surfaces, so two dig-nat versions is -# an outright compile error on those seams rather than merely two mTLS stacks. See -# `seams/dig_peer/dht.rs::stage_failed` for this crate's side of the adoption. -dig-nat = "0.21" -# dig-gossip is the ONE peer-stack exception: not yet published to crates.io (its publish is guarded -# pending dig-peer-protocol, #681), so it stays a git dependency PINNED to a release commit — here -# v0.17.12 (rev 3e352d91). -# -# v0.17.12 is REQUIRED, not merely current (#1771): it carries all THREE duplicate-connection fixes, -# and until it landed here the node's pool refused connections that would have worked. Every pool-add -# path now supersedes a stale slot for an already-authenticated `peer_id` instead of refusing the newer -# session — inbound (#1691), `connect_to` (#1703), and `adopt_nat_connection` (#1762), the last being -# the path EVERY dig-nat connection is adopted through, where a dead relay circuit's leftover slot -# refused the direct dial while the peer reported zero connections. `DuplicateConnection` still exists -# as an error variant but is no longer raised on those paths. Consequence for this crate: `PeerAdded` is -# REPUBLISHED on a supersede, so every consumer of it must be keyed by `peer_id` and idempotent (the DHT -# routing feed, the selector registry, and the download-side connected pool all are — see -# `download.rs::on_pool_event`), and `GossipStats::total_connections` is a LIFETIME counter that a -# supersede increments, so it must never be read as a count of unique peers (this crate reads -# `connected_peers` / `pool_stats().connected`, which are the live keyed-map sizes). -# -# Also on 0.17: the outbound /16+AS diversity caps (#1710/#1716) apply to the adoption path, so a -# test fixture adopting two peers must place them in DIFFERENT /16 groups. -# Pulled rustls-only (no native-tls) -- note `--all-features` is NOT a runnable configuration for -# dig-gossip: with both `ring` and `aws-lc-rs` reachable rustls cannot pick a provider. Its -# dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the graph still -# unifies to ONE of each. -# -# There are no vendored chia-protocol/chia-sdk-client forks to patch any more: dig-gossip 0.23.0 -# deleted both (dig_ecosystem#2228) and the workspace `[patch.crates-io]` section is RETIRED (see the -# root manifest). The #1529 three-rev lockstep is retired with it. An earlier version of this comment -# still described those patches as live; it was wrong, and a false manifest comment is how the next -# lane reasons wrongly about the version line. -# -# THIS DEP WAS THE CHIA-0.36 MIGRATION BLOCKER, AND IS NO LONGER (dig_ecosystem#3161, 2026-08-26). -# -# The old block, recorded because a future lane will otherwise re-derive it: the pinned rev was -# v0.25.0, which took `dig-peer-protocol = "0.6"`, whose only published 0.6.x declares -# `chia-protocol 0.26`. That put 0.26 into the graph through a path no dig-node manifest could edit, -# so no assignment of versions HERE could remove it. -# -# What cleared it was upstream moving, exactly as that comment predicted: dig-gossip `main` (v0.30.0, -# the rev pinned below) is on `dig-peer-protocol 0.7` and declares chia-protocol/traits/sha2/bls at -# 0.36.1. Repointing the rev is what let this workspace take the ceiling; it was never a local edit. -# -# The consequence that mattered is now discharged rather than merely avoided: `Bytes32` reaches the -# store-melt authorization path in `seams/dig_peer/store_melted.rs` from dig_gossip and from -# `digstore-chain` as the SAME type, so that path is no longer split across two chia-protocol -# versions. No conversion shim was added, and none may be — bridging two lines is the byte-drift -# class this migration exists to remove. -# -# THE dig-nat WALL IS CLEARED (dig-node#412 step 7). The rev pinned below is dig-gossip v0.32.0, -# which declares `dig-nat = "0.21"`, so the peer stack takes its ^0.21 tier with ONE dig-nat in the -# graph. The prediction the old note made -- "the fix is a dig-gossip release against `dig-nat -# ^0.21`, upstream, never a version edit here" -- is what happened; measured from the resolved LOCK -# rather than from the caret, because a caret-compatible transitive is invisible to a plain -# re-resolve (CLAUDE.md §2.4b). -# NC-7 exception: dig-gossip is git-pinned PERMANENTLY, not pending a publish — dig_ecosystem#2647. -# It cannot be published while its `native-tls` [patch.crates-io] fork stands, because `cargo publish` -# STRIPS [patch.crates-io] from the published metadata. A published dig-gossip would therefore build -# against UPSTREAM native-tls, compile cleanly, and silently accept inbound peers presenting no client -# certificate at all — a mutual-TLS bypass that no consumer could see from its own manifest. Its -# publish.yml guard is unconditional and deliberate: do not weaken it, do not `--allow-dirty` past it. -# dig_ecosystem#900 ("publish dig-gossip") was closed as superseded by #2647. -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay"] } -# The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. -# -# 0.9 is the TIP OF DIG-NODE'S CHIA LINE, and that — not 0.10 — is the correct target here (#2072). -# dig-constants 0.10.0 moved to chia-protocol 0.36.1 / chia-wallet-sdk 0.34, while this workspace -# builds against 0.26 / 0.30, which dig-gossip pins there via dig-peer-protocol 0.6 (see the -# dig-gossip entry above). Depending on 0.10 links a SECOND chia_protocol and every `Bytes32` this -# crate reads off `DIG_MAINNET` stops unifying with the rest of the workspace. Being current on -# dig-constants is therefore downstream of the chia 0.36 migration, not a version-string edit. -# -# Values are identical from 0.4.0 through 0.10.0 — the L2 genesis challenge and its six AGG_SIG -# domains were finalized in 0.4.0 and have not moved since — so this bump is value-neutral and buys -# the additive surface (DIG_ASSET_ID, the treasury hash/address, `dig.local`, `rpc.dig.net`). -# -# The graph still carries 0.4.0/0.5.1/0.8.0 because upstream crates hold them down through their -# PUBLISHED ranges (dig-gossip `>=0.2, <0.5`, dig-nat `>=0.4, <0.6`, dig-download `^0.8`), which a -# 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.13.0" -# -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- -# -# `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ -# chia-protocol 0.36.1, which is a DIFFERENT chia line from the 0.26 this workspace builds against, so -# the default surface would put a SECOND chia-protocol into the always-running service. Without the -# feature the crate is a pure BYTE CORE (`dig-smt + sha2 + thiserror`) exposing exactly what the node -# needs: the DPB container, `AnchoredRoot` and `VerifiedBody`. -# -# Consequence to keep in mind: `dig_social_profile::resolve` is NOT available here. The node resolves -# a store's on-chain root through its OWN `AnchoredRootResolver` (the coinset lineage walk), which is -# the same authority, reached by the node's existing seam. -dig-social-profile = { version = "0.7", default-features = false } -# -- L7 content-location DHT (PHASE-B, #163) --------------------------------------------------------- -# The Kademlia DHT that maps DIG content (store / capsule / root / resource) to the peer_ids holding -# it. The node LOCATES content it lacks via `find_providers`, and keeps its OWN held-inventory provider -# records CURRENT in the DHT (`announce_provider` / `withdraw_provider` / `republish`). It rides the -# SAME dig-nat mTLS transport as the rest of the peer network via a `DhtTransport` adapter (`dht.rs`). -# -# Sourcing: the crates.io release (NC-7). dig-download and dig-peer-selector below resolve dig-dht -# from crates.io too, so cargo unifies to ONE dig-dht instance — its `PeerId` / `Contact` / -# `DhtTransport` are then the exact types `dig_nat::connect` returns, rather than look-alikes. -# -# NOT bumped to 0.15, and this is the tier's REMAINING blocker rather than an oversight: dig-download -# 0.21.0 and dig-peer-selector 0.10.0 — the LATEST published of each — both require `dig-dht ^0.13`, -# read from the crates.io index. Declaring "0.15" resolves TWO dig-dht lines while cargo prints -# success, and dig-dht values cross from this crate into both consumers. dig-dht 0.15 is where -# `ProviderRecord::unverified_mirror_coin_id` lives (dig-dht#23), so the §25 mirror-coin pointer is -# unreachable here until dig-download AND dig-peer-selector release against `dig-dht ^0.15` — -# upstream, never a version edit here. Bridging two lines with a shim is the §4.1 byte-drift class. -dig-dht = "0.13" -# -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- -# The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX -# logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), -# feeding discovered peers into the pool as dial candidates (hints, verified by dialing) and formalizing -# what the ad-hoc `dig.getPeers` polling did. dig-pex ships a transport-agnostic sans-IO `PexEngine` -# this crate embeds; `pex.rs` is the dig-nat mux I/O adapter (SPEC §10.1, Appendix A). -# -# Sourcing: bare git form (no `rev`, like dig-nat/dig-dht) so cargo unifies any shared transitive deps -# to ONE copy across the graph; resolved to the tracked rev via the lockfile. dig-pex keeps a minimal -# dependency surface (serde/serde_json/tokio/rand — it mirrors the address shape rather than importing -# the transport crates), so there is no cross-crate type-unification concern here. -dig-pex = "0.1.1" -# -- Multi-source content fetch (#164) + redirect-on-miss (#165) -------------------------------------- -# The node-side download orchestrator: locate holders via dig-dht `find_providers`, confirm with -# `dig.getAvailability`, fan byte ranges across MULTIPLE providers simultaneously (`dig.fetchRange` -# over dig-nat), verify each range + the whole resource against the chain-anchored root, and -# reassemble — with pause/resume + `.download.tmp` GC. dig-node wires the real seams per the crate's -# implementers' note (see `download.rs`): DhtProviderLocator over our DhtService, NatRangeTransport -# over our mTLS identity, MerkleVerifier bound to the digstore proof format, FileSink/FileStateStore -# under the node's cache dir. -# -# Sourcing: the crates.io release (NC-7), matching dig-dht above. dig-download 0.6 deps dig-dht "^0.5" + -# dig-nat "^0.10" + dig-tls "^0.3" from crates.io, so cargo unifies its dig-dht + dig-nat + dig-tls with -# ours — ONE crate instance each across the graph (its re-exported `ContentId`/`ProviderRecord` ARE our -# dig-dht types). 0.6 keeps the injectable `SourceSelector` seam (dig-node implements it over -# dig-peer-selector, see `seams/dig_peer/selector_adapter.rs`), records range outcomes internally, and the -# fetch leg composes the FULL NAT ladder from an injected shared `NatRuntime` (`NatRangeTransport::new_with_runtime`). -# -# 0.8.1 adds the `ModuleDownloader` — the whole-`.dig`-module peer pull that turns a node which READ a -# resource into a complete resharer of the capsule (#1576, the reshare leg of the MVP flywheel). Its two -# seams are supplied here: `seams/dig_peer/module_transport.rs` (`NatModuleTransport`) and -# `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is -# deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this -# crate's anchor gate is the reshare path's ONLY root of trust. -# -# HELD on the 0.19 line, resolving 0.19.1 (dig_ecosystem#3128). 0.19.1 is a deliberate BACKPORT of the -# capsule-checkpoint filename fix (dig-download#38): `FileStateStore` hex-encoded the download key into -# the filename, so a module key `module:<64hex>:<64hex>` (136 bytes) became a 277-character name and -# EVERY capsule checkpoint write on Linux failed with `File name too long (os error 36)`. The same fix -# also shipped as 0.20.1, but 0.20 requires dig-dht ^0.12 / dig-nat ^0.20 / dig-peer ^0.12 / -# dig-tls ^0.4 — the whole dig_ecosystem#3152 cascade — which this crate cannot take yet, so the -# backport keeps the 0.19-line pins intact. Regression-tested here through the node's OWN production -# checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), -# because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no -# filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.21" -# -- The shared peer client (#1283/#1576) ------------------------------------------------------------- -# `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed -# RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the -# `ModuleTransport` half of dig-download's module pull, which speaks `dig.getModuleInfo` / -# `dig.fetchModuleRange` — the two client methods dig-peer 0.5 added for exactly this leg. -# -# 0.5 is REQUIRED, not merely current: dig-peer 0.4 pulled dig-rpc-protocol 0.3, so a tree containing -# both it and dig-download's dig-rpc-protocol 0.5 carried TWO `ModuleInfo` types either side of the -# module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on -# dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by -# `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.13" -# -- Self-optimizing peer selection (#178) ------------------------------------------------------------ -# The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the -# providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation -# point + an adaptive relayed penalty from the REAL measured outcome of every range) and hands -# dig-download the ranked subset + per-peer concurrency, instead of dig-download picking sources -# blindly. Every completed/failed range streams back via `record_outcome` in real time, and a dropped -# source drives a `rebalance`. dig-node bridges dig-download's `SourceSelector` seam to this crate via -# `seams/dig_peer/selector_adapter.rs` (the @30↔@30 composition-root bridge — dig-download does NOT -# depend on dig-peer-selector; dig-node, one level up, wires them together). -# -# Sourcing: the crates.io release (NC-7), matching dig-dht/dig-download above. It deps dig-dht "^0.5" + -# dig-nat "^0.10" from crates.io, so cargo unifies the selector's `dig-nat` + `dig-dht` with ours — ONE -# crate instance each (its re-used `PeerId`/`ContentId`/`ProviderRecord`/`CandidateAddr` ARE our -# dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on -# dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local -# `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). -# 0.9.2 IS the chia-0.36 cascade landing (dig_ecosystem#3161). The old note here said "NOT bumped to -# 0.9.1: that release moves onto dig-tls 0.4, and this crate still resolves 0.3 through -# dig-nat/dig-gossip, so the graph would carry TWO `dig_tls::PeerId` types that do not unify". Both -# halves of that split are gone — dig-nat 0.20 and the dig-gossip rev both take `dig-tls ^0.4`, and -# the lock now resolves dig-tls ONCE at 0.4.0. -# -# 0.10 IS takeable as of dig-gossip v0.32.0, which declares `dig-nat = "0.21"` — the release the old -# note here was waiting on. It carries `dig-dht ^0.13`, which is what holds dig-dht at 0.13; see the -# dig-dht entry above. -dig-peer-selector = "0.10" -# The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity -# is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in -# the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the -# legacy self-signed `rcgen` cert as the node dials/serves over the dig-nat CA-signed mTLS model -# (#1280). `peer_id = SHA256(SPKI DER)` is stable across restarts. dig-tls re-exports chia-bls's -# `SecretKey`, unifying with this crate's chia-bls 0.36.1. 0.4 is the chia-bls 0.36.1 uplift and is -# now TAKEN (dig_ecosystem#3161): the old hold — "taking it here would split `dig_tls::PeerId` -# against the 0.3 that dig-nat/dig-gossip still resolve" — expired when dig-nat 0.20 and the -# dig-gossip rev both moved onto `dig-tls ^0.4`. The lock resolves dig-tls ONCE, at 0.4.0. -dig-tls = "0.4" -# -- The store-exchange DECISION layer (dig_ecosystem#3130) ------------------------------------------- -# `dig-sex` owns every cache/tier/relevance/selection DECISION this node used to make in-tree: what to -# keep, what to sacrifice first, and whether a fresh candidate is worth displacing an incumbent. It is -# PURE — no clock, no socket, no filesystem — so this crate keeps owning all of the I/O and hands it -# only the facts. The in-tree `relevance` module and `tier0_prefetch::effective_tier` moved there -# verbatim and are now consumed as `dig_sex::*`. -# -# It also re-exports the ONE `CapsuleIdentity` the ecosystem speaks, so this crate never declares its -# own and never pulls a second major of `dig-store` into the graph. -dig-sex = "0.5" -# The cache/eviction SEAM `dig_sex::TieredPolicy` implements. Declared DIRECTLY rather than reached -# through `dig-sex`, which re-exports `CapsuleIdentity` alone — naming `EvictionEntry`, -# `EvictionContext` and the `EvictionPolicy` trait (whose `select_evictions` must be in scope to call) -# requires the crate itself. The version is pinned to the one `dig-sex` resolves, so the `CapsuleIdentity` -# this crate builds and the one `TieredPolicy` matches on are the SAME type; a split would silently -# fail to unify. `dig_sex::TieredPolicy` is the first non-LRU implementation of the seam, and the -# module sweep here is its first consumer. -dig-store-cache = "0.1.1" -# The mTLS peer-RPC LISTENER (server side): present the node cert + REQUIRE a client cert (peer_id -# enforced). rustls/tokio-rustls with ring only (no OpenSSL/aws-lc for OUR usage), matching dig-nat. -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } -# IPv6-first, IPv4-fallback (ecosystem HARD RULE): bind the peer-RPC listener on the IPv6 unspecified -# address `[::]` as a DUAL-STACK socket (IPV6_V6ONLY cleared) so ONE socket serves both native IPv6 -# and IPv4-mapped peers. Mirrors dig-relay's `net.rs` / dig-gossip's own dual-stack bind. -socket2 = "0.5" -# -- Engine-side identity session (NODE-1, #910) ----------------------------------------------------- -# The engine is IDENTITY-AGNOSTIC: it holds NO user signing key. `control.session.attach` proves the -# attaching dig-app holds the profile's slot-0x0010 identity key by VERIFYING an Ed25519 signature over -# the byte-identical `DIGNET-SESSION-v1 ‖ nonce ‖ profile_did` challenge (session.rs). Ed25519 -# verification rides `ring` — ALREADY in the locked graph (the rustls/tokio-rustls transport uses it), -# so it adds no new crate. VERIFY-ONLY: the engine never holds a signing key. (dig-app signs with -# ed25519-dalek; ring verifies RFC-8032 signatures + is strict against malleable/small-order inputs.) -ring = "0.17" -tracing = "0.1" -# Best-effort CONCURRENT fan-out of the multi-source provider union (`UnionLocator`, #1443): query -# dig-dht + PEX + relay-introducer sources at once via `future::join_all` (order-preserving, so the -# dedup keeps DHT-first precedence). rustls/tokio already pull futures transitively; declaring it here -# makes it a first-class prod dep of the union seam. Minimal feature set (no executor). -futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } - -[dev-dependencies] -tempfile = "3" -# `dig_keystore::hardware::double::FakeDevice` -- the only way to exercise a SPECIFIC hardware -# outcome from a test. The real providers ship now (dig-keystore-hardware above), but which one a -# host answers with is a property of that host: no CI runner can be made to produce an -# indeterminate TPM probe on demand, and that is the outcome the refusal path turns on. -# Test-only: `testing` is never enabled for the library build. -dig-keystore = { version = "0.13", features = ["testing"] } -# Same git rev as the normal dependency above, with dig-gossip's `test-util` feature added. That -# feature gates `__connect_stub_peer_with_direction`, which registers a connected peer without real -# TLS — the only way to assert what a broadcast ACTUALLY put on the wire from a test. Declared here -# rather than on the normal dependency so the shipped binary never carries the test hook: cargo -# unifies the feature into test builds only, since dev-dependencies are not built for `cargo build`. -# NC-7 exception: same permanent git pin as the runtime dep above (dig_ecosystem#2647). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay", "test-util"] } -# Generates the P-256 leaf key pairs the opcode-222 holdings tests sign with: `public_key_der()` IS the -# leaf `SubjectPublicKeyInfo` DER the wire carries as `provider_spki`, and whose SHA-256 is the -# announcing peer_id — so a test signer is a real §5.2 identity rather than a stand-in that could hide -# an attribution bug (#1429). -rcgen = "0.13" -# dig-download's in-memory harness (`MockRangeTransport`, `MockContent`, …), which the download-path -# tests drive the real orchestrator over. -# -# It is a DEV dependency and MUST STAY ONE (#1576). The `testkit` feature is also what makes -# `AcceptAnyModuleAnchor` — a fail-OPEN module anchor verifier — nameable at all; the crate compiles it -# out of a default consumer build precisely so a production wiring cannot reach it. The reshare path's -# anchor gate is its ONLY root of trust, so enabling this feature on the PRODUCTION `dig-download` entry -# above would put a bypass of that root of trust one `use` away. Dev-dependency features do not -# propagate to a consumer's build, so the node binaries never see it. -# -# Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which -# fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.21", features = ["testkit"] } -# Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the -# serve-observability tests (#1595) assert what an operator would actually see in the node log — -# and that no payload byte or proof ever reaches it. -tracing-subscriber = { version = "0.3", features = ["fmt"] } -# Builds canonical `did:chia:` strings for the on-chain DID-resolver tests (matches dig-identity's codec). -chia-sdk-utils = "0.36.0" -# The session attach tests sign challenges with the BLS12-381 G2 AugScheme (the app's slot-0x0010 -# identity key), matching dig-ipc-protocol's signing contract and dig-identity's published G1 key. -chia-bls = "0.36.1" -# axum is a TEST-ONLY dependency of the engine library: the node no longer serves its -# own HTTP (the `dig-node-service` shell owns the axum transport, `dig-runtime` owns the -# in-process FFI path — dispatch is `handle_rpc`, which is transport-agnostic). The -# integration/unit tests still spawn small in-process axum mock servers (the §21 remote -# + a module server) to exercise the fetch/cache path hermetically. -axum = "0.7" -# In-process loopback mTLS peer drives the peer-network tests with NO real network (the CA-signed -# `dig_tls::NodeCert` minted by `load_or_generate_node_cert` is the test client's mTLS identity). -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "test-util"] } -futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } -# The peer_network.rs integration test implements PeerRpcResponder (an async trait). -async-trait = "0.1" +[package] +name = "dig-node-core" +# The engine library's OWN version, independent of the released binary's (`[workspace.package].version` +# in the root manifest, which only dig-node-service tracks). It moves when this crate's PUBLIC surface +# moves: 0.24.0 acknowledges that the surface now exposes dig-nat 0.14 / dig-dht 0.8 types, a +# compatibility-relevant change even though the crate is consumed in-workspace and is not published to +# crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for +# what the exported types are (dig_ecosystem#1785c). 0.26.0 does the same for the 0.15 line: the +# exported dig-nat / dig-dht types rotate identity again, and `DhtError` — reachable through this +# crate's dig-dht re-exports — now carries `SafeText` instead of `String` (dig_ecosystem#1674/#1675). +# 0.27.0 adds the pure `relevance` module (CacheTier / relevance() / evict_key / should_displace) — +# a new, backwards-compatible public export (dig_ecosystem#1986), hence a MINOR bump. +# 0.28.0 adds the pure `tier0_selector` module (Candidate / select_within_budget / tier0_budget_bytes / +# should_displace_tier0) built on 0.27's relevance module — a new, backwards-compatible public export +# (dig_ecosystem#1988), hence another MINOR bump. +# 0.31.0 adds cache observability to `cache.stats` (§7.10e) — `refetch_count` + per-tier `tiers` +# occupancy fields, and makes `InboundDemand::entry_count` a real (no-longer-`#[cfg(test)]`) public +# API — a new, backwards-compatible surface (dig_ecosystem#1991), hence a MINOR bump. +# 0.47.0 adopts dig-gossip 0.25.0 and splits the announce seams by ORIGIN: `ProfileTransport` gains a +# required `announce_root_local` and `MeltBroadcast` a required `broadcast_local`, so a locally +# originated announce takes dig-gossip's dedup-exempt path and a re-announce of an unchanged root is +# no longer suppressed forever (dig_ecosystem#3061). Adding a required trait method is BREAKING for an +# out-of-workspace implementor; this crate is consumed in-workspace only and is pre-1.0, so it is a +# MINOR bump under SemVer's 0.x rule -- recorded here rather than letting the number imply the trait +# surface held still. +# 0.53.0 adds the module-granularity capsule relay leg (`seams/dig_peer/module_relay.rs`, gated OFF +# by default) and makes the provider/candidate locator readers absence-safe: `NodeContent::find_providers` +# and `NatModuleTransport::discovered_candidates` now return `BestEffort` rather than a bare `Vec`, +# so a source that could not be consulted can no longer be read as a proven absence (dig_ecosystem#3128, +# dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; +# this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x +# rule -- recorded here rather than letting the number imply the locator surface held still. +version = "0.62.0" +edition = "2021" +license = "GPL-2.0-only" +description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." + +[lib] +name = "dig_node_core" +path = "src/lib.rs" + +[dependencies] +# Bounds the whole-module descriptor memo (`seams/dig_peer/module_serve.rs`, #1615/G2): a per-process +# cache of `dig.getModuleInfo` answers keyed by (store, root), capped so a long-running node cannot +# accumulate one entry per module it has EVER described (each entry is tens of KB — up to 512 +# chunk_hashes + chunk_lens). Already a transitive dependency (pulled in via the workspace's other +# crates); promoted to a direct one now that this crate uses it itself. +lru = "0.12" +# The `.dig` STORE-FORMAT LIBRARY crates. These live in the digstore repo (the store +# CLI + format/read libraries) and are consumed here as GIT dependencies: the node +# depends on the store libs, NEVER the reverse (digstore is only ever an RPC client of +# a node). Pinned to a single digstore rev so the whole store-format surface the node +# reads/verifies/decrypts against is one coherent version. Bump all seven together. +# +# digstore-host — instantiate a compiled module + `serve_blind` (ciphertext + proof). +# digstore-core — wire types (ContentResponse), codec, Bytes32/48, the PublicManifest +# type + `datasection::read_public_manifest` (#176 Phase C). +# digstore-remote — the §21 remote client + persistent identity signer (§21.9). +# digstore-chain — CHIP-0035 singleton lineage walk (chain-anchored root). +# digstore-stage — the shared stage→compile engine for the `dig.stage` RPC (turn a +# folder into a capsule in-process, #95 Pass C). Its build script +# embeds the guest wasm (BINDING contract D6) via the DIGSTORE_GUEST_WASM +# override this repo sets in .cargo/config.toml (see vendor/). +# digstore-crypto — read-path crypto primitives. +# digstore-compiler — `extract_data_section_blob` (#176 Phase C): pulls the embedded DIGS +# data-section blob out of a compiled `.dig` module's wasm binary so +# `dig.getManifest` can read section 13 (the PublicManifest) WITHOUT a +# wasmtime instantiation (the manifest is PUBLIC, unencrypted data — no +# `serve_blind` decrypt needed). Already a transitive dependency via +# digstore-stage; pinned directly at the SAME rev so its public API is +# usable here too. +# +# Bumped to a rev past digstore's "Add normalized public manifest to the .dig format" +# commit (08f5d39) so `PublicManifest`/`read_public_manifest`/`extract_data_section_blob` +# exist. +# +# Bumped again to 5872240f (digstore v0.19.2, fix #1473) to adopt the HARDENED, +# unforgeable `digstore_chain::singleton::verify_pinned_root`. The rev before that (d5e52fb) +# anchored store identity on the attacker-controllable curried `SingletonStruct.launcher_id`, +# so a forged singleton currying `launcher_id == store_id` (hinted to store_id) could pass as +# chain-verified. 5872240f anchors identity on the UNFORGEABLE launcher coin (`coin_id == +# store_id`) via a bounded backward `parent_coin_info` walk, making the read-root anti-rollback +# check (#747/#841/#852-node) SOUND. +# +# Bumped again to the CURRENT rev, 161c2a3108cb4bb6c8791e96a26588ec99afb029 (digs#59), which is a +# descendant of 5872240f — so the launcher-coin anchoring above still holds — and additionally +# carries digstore-chain onto `chia-wallet-sdk` 0.36. See the SDK note below for why that bump is +# a security fix rather than housekeeping. +# +# Both bumps leave the public signature unchanged (a pure rev bump). Bump all seven together. +# NC-7 exception: dig_ecosystem#901 (the crates.io repoint) leaves the seven `digstore-*` deps below +# git-pinned ON PURPOSE. Three independent facts each block the repoint on their own, and all three are +# upstream of this workspace: +# +# 1. THE SET IS ATOMIC, so `digstore-core` cannot move alone even though it IS published (0.28.0). +# Inside the digstore workspace every sibling declares `digstore-core = { path = "../digstore-core" }`, +# so a git-sourced `digstore-host` always carries the git `digstore-core`. Repointing only the +# published members would put TWO `digstore-core` in the graph — a crates.io one for this crate's +# direct imports and a git one inside its siblings — and `ContentResponse`/`Bytes32` would stop +# unifying across the serve seam. They move together or not at all. +# +# 2. MOST OF THE SET IS UNPUBLISHED, and the closure is wider than it looks. Absent from crates.io: +# `digstore-host`, `digstore-remote`, `digstore-stage`, `digstore-crypto`, `digstore-compiler` — +# plus `digstore-prover`, `digstore-store`, `digstore-chunker` and `digstore-cli`, which the five +# pull in transitively and which no checklist had named. Each declares its siblings as a BARE +# `path = "../x"` with no `version` key, which `cargo publish` refuses outright, so publishing them +# is real work in DIG-Network/digs rather than a release button. +# +# 3. (CLEARED 2026-08-26, dig_ecosystem#3161.) This used to read "the published members are on the +# OTHER chia line", because digstore declared the primitives at 0.36.1 while this workspace was +# held at 0.26 by dig-gossip -> dig-peer-protocol 0.6. Both halves moved: the dig-gossip rev now +# carries 0.36.1, and the digstore rev below is digstore's own ceiling fix. The primitives are +# one line across the boundary, so nothing is split. +# +# The SDK family has now collapsed too (2026-08-26, dig-node#374). This used to read that +# digstore's rev took `chia-wallet-sdk 0.34` against dig-wallet's 0.36.0, and that closing the +# gap needed a digstore release — upstream, not here. That release happened: DIG-Network/digs#59 +# moved `digstore-chain` onto `chia-wallet-sdk 0.36` (and `datalayer-driver` 5, which is what +# required the SDK bump), and the rev below is that commit. +# +# Repointing was a SECURITY fix, not tidiness. `chia-sdk-driver` 0.30 and 0.34 decode the oracle +# fee out of an attacker-authored CREATE_COIN memo as +# `BigInt::from_signed_bytes_be(&memo).to_u64_digits().1[0]`, and an empty or explicit-zero memo +# makes that index an empty vec — a panic reachable from any spend dig-node walks through +# `Datastore::from_spend` (the lineage walk, `verify_pinned_root`, `sync_datastore`). 0.36 guards +# the empty case and uses `try_into`, so both become `DriverError::InvalidMemo`; it also stops +# silently discarding a negative memo's sign and truncating an over-`u64` fee to its low digit. +# +# So this row is still downstream of publishing the digstore workspace. Do NOT repoint these +# piecemeal, and never let the pin regress to a floating git BRANCH: the single `rev` is what makes +# the whole store-format surface reproducible. +digstore-host = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-core = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-remote = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-chain = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-stage = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-crypto = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +digstore-compiler = { git = "https://github.com/DIG-Network/digstore.git", rev = "161c2a3108cb4bb6c8791e96a26588ec99afb029" } +chia-protocol = "0.36.1" +# The `Streamable` to_bytes/from_bytes trait, used to serialize the chat `ChatMessage` payload before +# the dig-message seal. Same 0.36.1 as chia-protocol so the byte layout agrees across the workspace. +chia-traits = "0.36.1" +# The ecosystem's canonical address-family / IPv6-first primitive (CLAUDE.md §5.2). The advertise +# path keys + orders candidate addresses via `dig_ip::Family` and aggregates them source-tagged + +# de-duplicated via `dig_ip::PeerCandidates` — never a hand-rolled family sort (epic #1020, #1032). +# A leaf crate (no transitive DIG deps), so the crates.io release is used directly. +dig-ip = "0.1.2" +# The canonical dig-app ⇄ dig-node IPC session/signing contract (engine role-half + wire types + +# domain builders + frame bounds). SSOT shared with dig-app so the two halves can never drift (#1080). +# Pinned EXACTLY: 0.3.0 is the first release on this line and the caret range would silently accept +# a future 0.3.x. The crate reaches this one through the IPC surface, so an unreviewed minor is a +# wire-shape change arriving without a PR (dig-node#369 gate). +dig-ipc-protocol = "=0.3.0" +# On-chain DID resolution (WU3, #778): the honest chain-authenticated read path the production +# DidSigningKeyResolver delegates to. +# +# NOW A CRATES.IO DEP (dig_ecosystem#901). It was git-pinned at `tag = "v0.4.0"` because the crate was +# unpublished; it is published now, so the git source is retired and NC-7 is satisfied for this dep. +# +# 0.4.2 IS THE CORRECT TARGET, AND IT IS NOT THE LATEST (0.7.1). Two separate constraints pick it, and +# both are "one line per family" (CLAUDE.md §2.4b), not staleness: +# +# 1. CHIA LINE. This crate re-exports `Bytes32` and `Coin` FROM dig_identity (`seams/dig_peer/ +# session.rs`), so dig-identity's chia-protocol MUST be this workspace's line. Published lines: +# 0.4.1 / 0.4.2 / 0.5.0 -> chia-protocol 0.26, chia-sdk-utils 0.30 <- this workspace +# 0.6.0 / 0.7.0 / 0.7.1 -> chia-protocol 0.36.1, chia-sdk-utils 0.34+ +# Taking 0.7.1 links a SECOND chia-protocol and stops every `Bytes32` crossing the resolver seam +# from unifying — the same split the dig-gossip and dig-constants notes below describe. The 0.6+ +# uplift is downstream of the chia-0.36 cascade (dig_ecosystem#2761), not an edit here. +# +# 2. DEDUPLICATION. A 0.x minor is a semver-INCOMPATIBLE boundary, so this pin must match whatever +# the rest of the graph already resolves or the tree carries TWO dig-identity copies — which for +# an identity/key crate is the whole point of pinning it at all. +# +# 0.7.1 is that value on the chia-0.36.1 ceiling (dig_ecosystem#3161): `dig-nat 0.20` and +# `dig-message 0.7` both declare `dig-identity ^0.7`, and 0.7.1 is the only line whose `chia-bls` is +# 0.36.1. The previous pin here was 0.4.2, chosen by the same rule against the then-current +# `dig-message 0.5` / `dig-nat 0.18`; it is the RULE that is stable, not the number. Re-derive it +# from `Cargo.lock` rather than trusting this comment. +dig-identity = "0.7.1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "signal"] } +# Object-safe async trait for the injectable anchored-root resolver (the +# mandatory read-path chain pin, #127): production walks coinset, tests inject a +# deterministic resolver. +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# The canonical node<->node JSON-RPC contract: the Method catalogue (wire names + +# per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its +# method names + the peer allowlist from HERE (never hand-rolled) so the contract +# cannot drift from the other node implementation or the discovery document (#1075). +dig-rpc-protocol = "0.10.2" +# The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope +# the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 +# envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. +dig-message = "0.7" +# The chat message-TYPE layer: the five chat payloads on dig-message's dig-chat band + the registry +# wiring. A crypto-free, content-blind type layer — the DIGCHAT1 seal is carried as opaque bytes. +dig-chat-protocol = { path = "../dig-chat-protocol", version = "0.2" } +base64 = "0.22" +hex = "0.4" +# The whole-module pull's one content-addressing primitive (#1576): the module descriptor's per-chunk + +# whole-blob hashes on the SERVE side, and the pre-announce re-hash that proves the artifact about to be +# cached + announced is byte-identical to the one the anchor gate admitted. Production, not test-only — +# this hash is what stands between a promoted artifact and a promoted-but-unverified one. +sha2 = "0.10" +filetime = "0.2" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +fs4 = { version = "1.1.0", features = ["sync"] } +directories = "6.0.0" +# OS CSPRNG for the per-stage content-authoring BLS key (#95 Pass C dig.stage). +getrandom = "0.2" +# -- dig_ecosystem#2168: the node MACHINE key at rest -------------------------------------- +# The canonical DIG keystore. Consumed with `custody` OFF (it is off by default) because this +# crate seals only the node's OWN machine identity seed and must never be able to name the +# user-custody API -- the #908 boundary expressed in the dependency graph rather than in prose +# (dig-keystore SPEC.md 18.2, which names this engine as the intended opaque-only consumer). +# 0.10 rather than 0.9: 0.10 is a BREAKING release that moves the crate's re-exported chia-bls +# onto the ecosystem 0.36.1 line. That line is ALREADY in this lock (six chia-bls lines resolve +# here today), and the `opaque` surface this crate uses is bytes + Password with no BLS type on +# it at all, so nothing crosses between lines -- and adopting 0.10 now avoids routing the node's +# identity onto a type identity that was about to change under it. +# +# 0.13 is the FLOOR for taking hardware binding live, and the reason is not the providers. +# dig-keystore#18 made `create_with_rng` an atomic check-then-write in 0.13.0; 0.12 will happily +# overwrite an existing record. On a SOFTWARE tier an overwritten machine key is recoverable from +# a backup of two plaintext-sealed halves. Hardware wrapping binds the wrapping key to a +# non-exportable component, so the same overwrite becomes PERMANENT -- there is nothing to restore +# from. Sealing therefore upgrades that bug from recoverable to unrecoverable, and 0.12 is not a +# version this seam may be enabled on. +dig-keystore = "0.13" +# The platform trusted-component providers (Windows TPM 2.0 via CNG, Apple Secure Enclave, Linux +# TPM 2.0). A SEPARATE crate because `dig-keystore` forbids `unsafe` and every one of these is FFI; +# it is the workspace member dig_ecosystem#1693 asked for, and it landed in dig-keystore v0.12.0. +# Without it the node's hardware seam -- already fully composed at `machine_key.rs` -- passes a +# hardcoded `None` and can never report anything but `Software(NotRequested)`. +# 0.2 pairs with dig-keystore 0.13: it adds the macOS Secure Enclave and Linux TPM 2.0 providers, +# and reclassifies a TPM AUTHORIZATION refusal (owner password set, dictionary-attack lockout) as +# `Absent` rather than `Indeterminate`, so an ordinary locked-down Windows box degrades instead of +# refusing. Only a genuinely uninspectable host is indeterminate now. +dig-keystore-hardware = "0.2" +# The machine key lives in the SAME user-global identity dir digstore_remote used for the +# plaintext seed (`/dig`), so the #2168 migration finds it in place and the node +# keeps its peer_id. digstore keeps its own `identity_dir` private, so the path is reproduced. +dirs = "5" +# Keeps the unsealed seed off the heap after use, matching what dig-keystore hands back. +zeroize = "1" + +# -- L7 DIG Node peer network (PHASE-2b, #162) ------------------------------------------------------- +# The NAT-traversal + connected-peer-pool layer. The bespoke in-node relay client (formerly relay.rs) +# is RETIRED: the relay connection now lives inside dig-nat (the `connect()` ladder's last-resort tier +# + the persistent reservation) and dig-gossip (the introducer-backed connected peer pool). dig-node +# consumes both to auto-discover peers via relay.dig.net, maintain a pool, and serve/issue the L7 peer +# RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). +# +# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack +# (dig-gossip 0.15.0, dig-dht 0.5.1, dig-download 0.7.1, dig-peer-selector 0.5.2) now depends on dig-nat +# ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE +# graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the +# relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a +# `RelayTunnel`, and `RelayAcceptor` runs the mTLS SERVER half over it (#1536, Leg B) — on top of +# 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes +# the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. +# 0.11.2 is REQUIRED (#1586): before it, `RangeFrame::bytes` was read with `serde_bytes`, which over +# the JSON frame wire takes the served base64 string as its literal characters — so every +# `dig.fetchRange` window arrived as its own base64 text, the reassembler rejected the frame, and the +# read leg aborted before reading a byte. 0.11.2 reads/writes `bytes` as base64 (the canonical +# `dig_rpc_protocol::types::RangeFrame` wire the node's own serve path emits). +# dig-nat 0.13 is the release that introduced the FALLIBLE, payload-capped `RangeFrame::encode` plus +# the paged-prologue sender API (`with_identity` carrying `chunk_count`, `with_chunk_lens_page` / +# `chunk_lens_offset`, `with_chunk_index`, `RangeRequest::skip_layout`). Everything 0.14 adds is the +# RECEIVER (`ChunkLensAssembler`) plus the `split_chunk_lens_pages` helper, so 0.13 carries the whole +# serve-side fix for #1640/#1668. +# +# The dig-nat MAJOR must match the whole peer stack's, and it moves as ONE step (#1771). This crate +# builds its own `dig_nat` `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` +# values and passes them INTO dig-download, dig-gossip and dig-peer-selector, so a second dig-nat +# instance does not merely bloat the tree -- those calls stop typechecking (verified: E0308 `expected +# dig_nat::relay::RelayStatus, found dig_nat::RelayStatus`). So dig-nat cannot be bumped alone, and +# dig-gossip/dig-dht/dig-download/dig-peer/dig-peer-selector cannot be bumped past it: the dig-nat +# 0.18 line (dig-dht 0.11, dig-download 0.19, dig-peer 0.11, dig-peer-selector 0.9, dig-gossip on the +# pinned rev below) is a single atomic cascade, every member of which is published. +# `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so a +# partial cascade cannot quietly reintroduce the duplicate. +# +# This line carries the SafeText peer-text cascade (#1674/#1675): error text of remote origin is +# held as `dig_nat::SafeText` rather than `String`, so a hostile peer's control characters cannot forge +# a line in an operator's log. On a partial bump the failure is now LOUDER than the RelayStatus mismatch +# above -- `SafeText` crosses dig-dht's AND dig-peer's public error surfaces, so two dig-nat versions is +# an outright compile error on those seams rather than merely two mTLS stacks. See +# `seams/dig_peer/dht.rs::stage_failed` for this crate's side of the adoption. +dig-nat = "0.21" +# dig-gossip is the ONE peer-stack exception: not yet published to crates.io (its publish is guarded +# pending dig-peer-protocol, #681), so it stays a git dependency PINNED to a release commit — here +# v0.17.12 (rev 3e352d91). +# +# v0.17.12 is REQUIRED, not merely current (#1771): it carries all THREE duplicate-connection fixes, +# and until it landed here the node's pool refused connections that would have worked. Every pool-add +# path now supersedes a stale slot for an already-authenticated `peer_id` instead of refusing the newer +# session — inbound (#1691), `connect_to` (#1703), and `adopt_nat_connection` (#1762), the last being +# the path EVERY dig-nat connection is adopted through, where a dead relay circuit's leftover slot +# refused the direct dial while the peer reported zero connections. `DuplicateConnection` still exists +# as an error variant but is no longer raised on those paths. Consequence for this crate: `PeerAdded` is +# REPUBLISHED on a supersede, so every consumer of it must be keyed by `peer_id` and idempotent (the DHT +# routing feed, the selector registry, and the download-side connected pool all are — see +# `download.rs::on_pool_event`), and `GossipStats::total_connections` is a LIFETIME counter that a +# supersede increments, so it must never be read as a count of unique peers (this crate reads +# `connected_peers` / `pool_stats().connected`, which are the live keyed-map sizes). +# +# Also on 0.17: the outbound /16+AS diversity caps (#1710/#1716) apply to the adoption path, so a +# test fixture adopting two peers must place them in DIFFERENT /16 groups. +# Pulled rustls-only (no native-tls) -- note `--all-features` is NOT a runnable configuration for +# dig-gossip: with both `ring` and `aws-lc-rs` reachable rustls cannot pick a provider. Its +# dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the graph still +# unifies to ONE of each. +# +# There are no vendored chia-protocol/chia-sdk-client forks to patch any more: dig-gossip 0.23.0 +# deleted both (dig_ecosystem#2228) and the workspace `[patch.crates-io]` section is RETIRED (see the +# root manifest). The #1529 three-rev lockstep is retired with it. An earlier version of this comment +# still described those patches as live; it was wrong, and a false manifest comment is how the next +# lane reasons wrongly about the version line. +# +# THIS DEP WAS THE CHIA-0.36 MIGRATION BLOCKER, AND IS NO LONGER (dig_ecosystem#3161, 2026-08-26). +# +# The old block, recorded because a future lane will otherwise re-derive it: the pinned rev was +# v0.25.0, which took `dig-peer-protocol = "0.6"`, whose only published 0.6.x declares +# `chia-protocol 0.26`. That put 0.26 into the graph through a path no dig-node manifest could edit, +# so no assignment of versions HERE could remove it. +# +# What cleared it was upstream moving, exactly as that comment predicted: dig-gossip `main` (v0.30.0, +# the rev pinned below) is on `dig-peer-protocol 0.7` and declares chia-protocol/traits/sha2/bls at +# 0.36.1. Repointing the rev is what let this workspace take the ceiling; it was never a local edit. +# +# The consequence that mattered is now discharged rather than merely avoided: `Bytes32` reaches the +# store-melt authorization path in `seams/dig_peer/store_melted.rs` from dig_gossip and from +# `digstore-chain` as the SAME type, so that path is no longer split across two chia-protocol +# versions. No conversion shim was added, and none may be — bridging two lines is the byte-drift +# class this migration exists to remove. +# +# THE dig-nat WALL IS CLEARED (dig-node#412 step 7). The rev pinned below is dig-gossip v0.32.0, +# which declares `dig-nat = "0.21"`, so the peer stack takes its ^0.21 tier with ONE dig-nat in the +# graph. The prediction the old note made -- "the fix is a dig-gossip release against `dig-nat +# ^0.21`, upstream, never a version edit here" -- is what happened; measured from the resolved LOCK +# rather than from the caret, because a caret-compatible transitive is invisible to a plain +# re-resolve (CLAUDE.md §2.4b). +# NC-7 exception: dig-gossip is git-pinned PERMANENTLY, not pending a publish — dig_ecosystem#2647. +# It cannot be published while its `native-tls` [patch.crates-io] fork stands, because `cargo publish` +# STRIPS [patch.crates-io] from the published metadata. A published dig-gossip would therefore build +# against UPSTREAM native-tls, compile cleanly, and silently accept inbound peers presenting no client +# certificate at all — a mutual-TLS bypass that no consumer could see from its own manifest. Its +# publish.yml guard is unconditional and deliberate: do not weaken it, do not `--allow-dirty` past it. +# dig_ecosystem#900 ("publish dig-gossip") was closed as superseded by #2647. +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay"] } +# The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. +# +# 0.9 is the TIP OF DIG-NODE'S CHIA LINE, and that — not 0.10 — is the correct target here (#2072). +# dig-constants 0.10.0 moved to chia-protocol 0.36.1 / chia-wallet-sdk 0.34, while this workspace +# builds against 0.26 / 0.30, which dig-gossip pins there via dig-peer-protocol 0.6 (see the +# dig-gossip entry above). Depending on 0.10 links a SECOND chia_protocol and every `Bytes32` this +# crate reads off `DIG_MAINNET` stops unifying with the rest of the workspace. Being current on +# dig-constants is therefore downstream of the chia 0.36 migration, not a version-string edit. +# +# Values are identical from 0.4.0 through 0.10.0 — the L2 genesis challenge and its six AGG_SIG +# domains were finalized in 0.4.0 and have not moved since — so this bump is value-neutral and buys +# the additive surface (DIG_ASSET_ID, the treasury hash/address, `dig.local`, `rpc.dig.net`). +# +# The graph still carries 0.4.0/0.5.1/0.8.0 because upstream crates hold them down through their +# PUBLISHED ranges (dig-gossip `>=0.2, <0.5`, dig-nat `>=0.4, <0.6`, dig-download `^0.8`), which a +# 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.13.0" +# -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- +# +# `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ +# chia-protocol 0.36.1, which is a DIFFERENT chia line from the 0.26 this workspace builds against, so +# the default surface would put a SECOND chia-protocol into the always-running service. Without the +# feature the crate is a pure BYTE CORE (`dig-smt + sha2 + thiserror`) exposing exactly what the node +# needs: the DPB container, `AnchoredRoot` and `VerifiedBody`. +# +# Consequence to keep in mind: `dig_social_profile::resolve` is NOT available here. The node resolves +# a store's on-chain root through its OWN `AnchoredRootResolver` (the coinset lineage walk), which is +# the same authority, reached by the node's existing seam. +dig-social-profile = { version = "0.7", default-features = false } +# -- L7 content-location DHT (PHASE-B, #163) --------------------------------------------------------- +# The Kademlia DHT that maps DIG content (store / capsule / root / resource) to the peer_ids holding +# it. The node LOCATES content it lacks via `find_providers`, and keeps its OWN held-inventory provider +# records CURRENT in the DHT (`announce_provider` / `withdraw_provider` / `republish`). It rides the +# SAME dig-nat mTLS transport as the rest of the peer network via a `DhtTransport` adapter (`dht.rs`). +# +# Sourcing: the crates.io release (NC-7). dig-download and dig-peer-selector below resolve dig-dht +# from crates.io too, so cargo unifies to ONE dig-dht instance — its `PeerId` / `Contact` / +# `DhtTransport` are then the exact types `dig_nat::connect` returns, rather than look-alikes. +# +# NOT bumped to 0.15, and this is the tier's REMAINING blocker rather than an oversight: dig-download +# 0.21.0 and dig-peer-selector 0.10.0 — the LATEST published of each — both require `dig-dht ^0.13`, +# read from the crates.io index. Declaring "0.15" resolves TWO dig-dht lines while cargo prints +# success, and dig-dht values cross from this crate into both consumers. dig-dht 0.15 is where +# `ProviderRecord::unverified_mirror_coin_id` lives (dig-dht#23), so the §25 mirror-coin pointer is +# unreachable here until dig-download AND dig-peer-selector release against `dig-dht ^0.15` — +# upstream, never a version edit here. Bridging two lines with a shim is the §4.1 byte-drift class. +dig-dht = "0.13" +# -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- +# The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX +# logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), +# feeding discovered peers into the pool as dial candidates (hints, verified by dialing) and formalizing +# what the ad-hoc `dig.getPeers` polling did. dig-pex ships a transport-agnostic sans-IO `PexEngine` +# this crate embeds; `pex.rs` is the dig-nat mux I/O adapter (SPEC §10.1, Appendix A). +# +# Sourcing: bare git form (no `rev`, like dig-nat/dig-dht) so cargo unifies any shared transitive deps +# to ONE copy across the graph; resolved to the tracked rev via the lockfile. dig-pex keeps a minimal +# dependency surface (serde/serde_json/tokio/rand — it mirrors the address shape rather than importing +# the transport crates), so there is no cross-crate type-unification concern here. +dig-pex = "0.1.1" +# -- Multi-source content fetch (#164) + redirect-on-miss (#165) -------------------------------------- +# The node-side download orchestrator: locate holders via dig-dht `find_providers`, confirm with +# `dig.getAvailability`, fan byte ranges across MULTIPLE providers simultaneously (`dig.fetchRange` +# over dig-nat), verify each range + the whole resource against the chain-anchored root, and +# reassemble — with pause/resume + `.download.tmp` GC. dig-node wires the real seams per the crate's +# implementers' note (see `download.rs`): DhtProviderLocator over our DhtService, NatRangeTransport +# over our mTLS identity, MerkleVerifier bound to the digstore proof format, FileSink/FileStateStore +# under the node's cache dir. +# +# Sourcing: the crates.io release (NC-7), matching dig-dht above. dig-download 0.6 deps dig-dht "^0.5" + +# dig-nat "^0.10" + dig-tls "^0.3" from crates.io, so cargo unifies its dig-dht + dig-nat + dig-tls with +# ours — ONE crate instance each across the graph (its re-exported `ContentId`/`ProviderRecord` ARE our +# dig-dht types). 0.6 keeps the injectable `SourceSelector` seam (dig-node implements it over +# dig-peer-selector, see `seams/dig_peer/selector_adapter.rs`), records range outcomes internally, and the +# fetch leg composes the FULL NAT ladder from an injected shared `NatRuntime` (`NatRangeTransport::new_with_runtime`). +# +# 0.8.1 adds the `ModuleDownloader` — the whole-`.dig`-module peer pull that turns a node which READ a +# resource into a complete resharer of the capsule (#1576, the reshare leg of the MVP flywheel). Its two +# seams are supplied here: `seams/dig_peer/module_transport.rs` (`NatModuleTransport`) and +# `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is +# deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this +# crate's anchor gate is the reshare path's ONLY root of trust. +# +# HELD on the 0.19 line, resolving 0.19.1 (dig_ecosystem#3128). 0.19.1 is a deliberate BACKPORT of the +# capsule-checkpoint filename fix (dig-download#38): `FileStateStore` hex-encoded the download key into +# the filename, so a module key `module:<64hex>:<64hex>` (136 bytes) became a 277-character name and +# EVERY capsule checkpoint write on Linux failed with `File name too long (os error 36)`. The same fix +# also shipped as 0.20.1, but 0.20 requires dig-dht ^0.12 / dig-nat ^0.20 / dig-peer ^0.12 / +# dig-tls ^0.4 — the whole dig_ecosystem#3152 cascade — which this crate cannot take yet, so the +# backport keeps the 0.19-line pins intact. Regression-tested here through the node's OWN production +# checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), +# because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no +# filename at all) and the one `FileStateStore` test used a 3-character key. +dig-download = "0.21" +# -- The shared peer client (#1283/#1576) ------------------------------------------------------------- +# `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed +# RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the +# `ModuleTransport` half of dig-download's module pull, which speaks `dig.getModuleInfo` / +# `dig.fetchModuleRange` — the two client methods dig-peer 0.5 added for exactly this leg. +# +# 0.5 is REQUIRED, not merely current: dig-peer 0.4 pulled dig-rpc-protocol 0.3, so a tree containing +# both it and dig-download's dig-rpc-protocol 0.5 carried TWO `ModuleInfo` types either side of the +# module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on +# dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by +# `crates/dig-node-core/tests/dependency_tree.rs`). +dig-peer = "0.13" +# -- Self-optimizing peer selection (#178) ------------------------------------------------------------ +# The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the +# providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation +# point + an adaptive relayed penalty from the REAL measured outcome of every range) and hands +# dig-download the ranked subset + per-peer concurrency, instead of dig-download picking sources +# blindly. Every completed/failed range streams back via `record_outcome` in real time, and a dropped +# source drives a `rebalance`. dig-node bridges dig-download's `SourceSelector` seam to this crate via +# `seams/dig_peer/selector_adapter.rs` (the @30↔@30 composition-root bridge — dig-download does NOT +# depend on dig-peer-selector; dig-node, one level up, wires them together). +# +# Sourcing: the crates.io release (NC-7), matching dig-dht/dig-download above. It deps dig-dht "^0.5" + +# dig-nat "^0.10" from crates.io, so cargo unifies the selector's `dig-nat` + `dig-dht` with ours — ONE +# crate instance each (its re-used `PeerId`/`ContentId`/`ProviderRecord`/`CandidateAddr` ARE our +# dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on +# dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local +# `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). +# 0.9.2 IS the chia-0.36 cascade landing (dig_ecosystem#3161). The old note here said "NOT bumped to +# 0.9.1: that release moves onto dig-tls 0.4, and this crate still resolves 0.3 through +# dig-nat/dig-gossip, so the graph would carry TWO `dig_tls::PeerId` types that do not unify". Both +# halves of that split are gone — dig-nat 0.20 and the dig-gossip rev both take `dig-tls ^0.4`, and +# the lock now resolves dig-tls ONCE at 0.4.0. +# +# 0.10 IS takeable as of dig-gossip v0.32.0, which declares `dig-nat = "0.21"` — the release the old +# note here was waiting on. It carries `dig-dht ^0.13`, which is what holds dig-dht at 0.13; see the +# dig-dht entry above. +dig-peer-selector = "0.10" +# The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity +# is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in +# the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the +# legacy self-signed `rcgen` cert as the node dials/serves over the dig-nat CA-signed mTLS model +# (#1280). `peer_id = SHA256(SPKI DER)` is stable across restarts. dig-tls re-exports chia-bls's +# `SecretKey`, unifying with this crate's chia-bls 0.36.1. 0.4 is the chia-bls 0.36.1 uplift and is +# now TAKEN (dig_ecosystem#3161): the old hold — "taking it here would split `dig_tls::PeerId` +# against the 0.3 that dig-nat/dig-gossip still resolve" — expired when dig-nat 0.20 and the +# dig-gossip rev both moved onto `dig-tls ^0.4`. The lock resolves dig-tls ONCE, at 0.4.0. +dig-tls = "0.4" +# -- The store-exchange DECISION layer (dig_ecosystem#3130) ------------------------------------------- +# `dig-sex` owns every cache/tier/relevance/selection DECISION this node used to make in-tree: what to +# keep, what to sacrifice first, and whether a fresh candidate is worth displacing an incumbent. It is +# PURE — no clock, no socket, no filesystem — so this crate keeps owning all of the I/O and hands it +# only the facts. The in-tree `relevance` module and `tier0_prefetch::effective_tier` moved there +# verbatim and are now consumed as `dig_sex::*`. +# +# It also re-exports the ONE `CapsuleIdentity` the ecosystem speaks, so this crate never declares its +# own and never pulls a second major of `dig-store` into the graph. +dig-sex = "0.5" +# The cache/eviction SEAM `dig_sex::TieredPolicy` implements. Declared DIRECTLY rather than reached +# through `dig-sex`, which re-exports `CapsuleIdentity` alone — naming `EvictionEntry`, +# `EvictionContext` and the `EvictionPolicy` trait (whose `select_evictions` must be in scope to call) +# requires the crate itself. The version is pinned to the one `dig-sex` resolves, so the `CapsuleIdentity` +# this crate builds and the one `TieredPolicy` matches on are the SAME type; a split would silently +# fail to unify. `dig_sex::TieredPolicy` is the first non-LRU implementation of the seam, and the +# module sweep here is its first consumer. +dig-store-cache = "0.1.1" +# The mTLS peer-RPC LISTENER (server side): present the node cert + REQUIRE a client cert (peer_id +# enforced). rustls/tokio-rustls with ring only (no OpenSSL/aws-lc for OUR usage), matching dig-nat. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +# IPv6-first, IPv4-fallback (ecosystem HARD RULE): bind the peer-RPC listener on the IPv6 unspecified +# address `[::]` as a DUAL-STACK socket (IPV6_V6ONLY cleared) so ONE socket serves both native IPv6 +# and IPv4-mapped peers. Mirrors dig-relay's `net.rs` / dig-gossip's own dual-stack bind. +socket2 = "0.5" +# -- Engine-side identity session (NODE-1, #910) ----------------------------------------------------- +# The engine is IDENTITY-AGNOSTIC: it holds NO user signing key. `control.session.attach` proves the +# attaching dig-app holds the profile's slot-0x0010 identity key by VERIFYING an Ed25519 signature over +# the byte-identical `DIGNET-SESSION-v1 ‖ nonce ‖ profile_did` challenge (session.rs). Ed25519 +# verification rides `ring` — ALREADY in the locked graph (the rustls/tokio-rustls transport uses it), +# so it adds no new crate. VERIFY-ONLY: the engine never holds a signing key. (dig-app signs with +# ed25519-dalek; ring verifies RFC-8032 signatures + is strict against malleable/small-order inputs.) +ring = "0.17" +tracing = "0.1" +# Best-effort CONCURRENT fan-out of the multi-source provider union (`UnionLocator`, #1443): query +# dig-dht + PEX + relay-introducer sources at once via `future::join_all` (order-preserving, so the +# dedup keeps DHT-first precedence). rustls/tokio already pull futures transitively; declaring it here +# makes it a first-class prod dep of the union seam. Minimal feature set (no executor). +futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } + +[dev-dependencies] +tempfile = "3" +# `dig_keystore::hardware::double::FakeDevice` -- the only way to exercise a SPECIFIC hardware +# outcome from a test. The real providers ship now (dig-keystore-hardware above), but which one a +# host answers with is a property of that host: no CI runner can be made to produce an +# indeterminate TPM probe on demand, and that is the outcome the refusal path turns on. +# Test-only: `testing` is never enabled for the library build. +dig-keystore = { version = "0.13", features = ["testing"] } +# Same git rev as the normal dependency above, with dig-gossip's `test-util` feature added. That +# feature gates `__connect_stub_peer_with_direction`, which registers a connected peer without real +# TLS — the only way to assert what a broadcast ACTUALLY put on the wire from a test. Declared here +# rather than on the normal dependency so the shipped binary never carries the test hook: cargo +# unifies the feature into test builds only, since dev-dependencies are not built for `cargo build`. +# NC-7 exception: same permanent git pin as the runtime dep above (dig_ecosystem#2647). +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee", default-features = false, features = ["rustls", "relay", "test-util"] } +# Generates the P-256 leaf key pairs the opcode-222 holdings tests sign with: `public_key_der()` IS the +# leaf `SubjectPublicKeyInfo` DER the wire carries as `provider_spki`, and whose SHA-256 is the +# announcing peer_id — so a test signer is a real §5.2 identity rather than a stand-in that could hide +# an attribution bug (#1429). +rcgen = "0.13" +# dig-download's in-memory harness (`MockRangeTransport`, `MockContent`, …), which the download-path +# tests drive the real orchestrator over. +# +# It is a DEV dependency and MUST STAY ONE (#1576). The `testkit` feature is also what makes +# `AcceptAnyModuleAnchor` — a fail-OPEN module anchor verifier — nameable at all; the crate compiles it +# out of a default consumer build precisely so a production wiring cannot reach it. The reshare path's +# anchor gate is its ONLY root of trust, so enabling this feature on the PRODUCTION `dig-download` entry +# above would put a bypass of that root of trust one `use` away. Dev-dependency features do not +# propagate to a consumer's build, so the node binaries never see it. +# +# Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which +# fails if `testkit` ever appears on the production entry. +dig-download = { version = "0.21", features = ["testkit"] } +# Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the +# serve-observability tests (#1595) assert what an operator would actually see in the node log — +# and that no payload byte or proof ever reaches it. +tracing-subscriber = { version = "0.3", features = ["fmt"] } +# Builds canonical `did:chia:` strings for the on-chain DID-resolver tests (matches dig-identity's codec). +chia-sdk-utils = "0.36.0" +# The session attach tests sign challenges with the BLS12-381 G2 AugScheme (the app's slot-0x0010 +# identity key), matching dig-ipc-protocol's signing contract and dig-identity's published G1 key. +chia-bls = "0.36.1" +# axum is a TEST-ONLY dependency of the engine library: the node no longer serves its +# own HTTP (the `dig-node-service` shell owns the axum transport, `dig-runtime` owns the +# in-process FFI path — dispatch is `handle_rpc`, which is transport-agnostic). The +# integration/unit tests still spawn small in-process axum mock servers (the §21 remote +# + a module server) to exercise the fetch/cache path hermetically. +axum = "0.7" +# In-process loopback mTLS peer drives the peer-network tests with NO real network (the CA-signed +# `dig_tls::NodeCert` minted by `load_or_generate_node_cert` is the test client's mTLS identity). +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "io-util", "time", "test-util"] } +futures = { version = "0.3", default-features = false, features = ["std", "async-await"] } +# The peer_network.rs integration test implements PeerRpcResponder (an async trait). +async-trait = "0.1" diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 082c8375..82418b16 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -16,18 +16,36 @@ //! So both are asserted where they are actually decided: the manifest and the lock. /// This crate's manifest, read at compile time so the assertion cannot drift from the build. -const MANIFEST: &str = include_str!("../Cargo.toml"); +const MANIFEST_RAW: &str = include_str!("../Cargo.toml"); + +/// The manifest with its line endings normalised to `\n`. +/// +/// An editor on Windows rewrites a manifest with CRLF without changing a single declaration. +/// Searching those bytes for `"\n[section]\n"` then finds nothing — which is a fact about the file's +/// line endings, not about its dependencies. dig-node#412 hit exactly that: a dependency-tier commit +/// saved this file as CRLF, and the section lookup below went on to report `dig-download` as absent +/// from a production tree it had never left. Normalising first keeps every assertion in this file +/// about DECLARATIONS, which is the only thing any of them means to be about. +fn manifest() -> &'static str { + static NORMALISED: std::sync::OnceLock = std::sync::OnceLock::new(); + NORMALISED.get_or_init(|| MANIFEST_RAW.replace("\r\n", "\n")) +} /// The workspace lock (two levels up from this crate). const LOCK: &str = include_str!("../../../Cargo.lock"); /// The body of `section` in the manifest (up to the next `[` at column 0). fn manifest_section(section: &str) -> &'static str { - let start = MANIFEST + let manifest = manifest(); + // Panics rather than falling back to offset 0. A lookup that FAILED is not evidence about any + // dependency, and offset 0 silently searches `[package]`, where nothing is declared — so every + // "X is a production dependency" assertion below would then report absence with total + // confidence. Failing here names the section that could not be read instead. + let start = manifest .find(&format!("\n[{section}]\n")) .map(|i| i + section.len() + 4) - .unwrap_or(0); - let rest = &MANIFEST[start..]; + .unwrap_or_else(|| panic!("this manifest has no `[{section}]` section to read")); + let rest = &manifest[start..]; let end = rest.find("\n[").unwrap_or(rest.len()); &rest[..end] } From 01bc66a40f1dd1cda31d2d1b998f5506942ed720 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 05:28:35 -0700 Subject: [PATCH 05/19] fix(mirror): make the salvaged observation tests compile against the real types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane that wrote `mirror::observe` died before it ever built, so its test fixtures were written against remembered shapes rather than the published ones. Three errors, all in the test module and none in `observe` itself: - `CollateralRequirementResult::Known` has no `census_height`. Replaced with the real fields — `protocol_version`, `multiplier_micros` and `handicap_dig_base_units` — matching `pass::tests::known_at`, so the two fixtures describe the same requirement. - `BondState::Bonded` is a struct variant carrying the coin and the amount, not a unit value, so it cannot be compared with `==`. Both `Bonded` assertions were rewritten to match rather than weakened. One now pins the PAYLOAD — the coin id, the epoch, and the 600 that coin locks — because a bare variant check cannot see a row that names the other coin or that reports this epoch's requirement instead of the coin's own amount, and both are the plausible wrong answers here. That assertion needed a fixture change to be able to fail: every fixture coin shared the id `"cc" * 32`, so a claim about "the coin bonding aa/11" held equally against the coin bonding bb/22. Coin ids are now distinct per `(store, root)`. Also applies rustfmt to `operator_puzzle_hash`. Refs #412 --- crates/dig-node-service/src/mirror/observe.rs | 37 +++++++++++++++---- crates/dig-wallet/src/operator_wallet.rs | 6 ++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/dig-node-service/src/mirror/observe.rs b/crates/dig-node-service/src/mirror/observe.rs index 1a77891f..6f867766 100644 --- a/crates/dig-node-service/src/mirror/observe.rs +++ b/crates/dig-node-service/src/mirror/observe.rs @@ -174,9 +174,14 @@ mod tests { } } + /// A coin whose id is DISTINCT per `(store, root)`. + /// + /// One shared id across every fixture coin would make `coin_id` useless as a discriminator: + /// an assertion about "the coin bonding aa/11" would hold just as well against the coin bonding + /// bb/22, so a `Bonded` row that named the wrong coin would still pass. fn coin(store: &str, root: &str, epoch: i64, collateral: u64) -> HeldMirror { HeldMirror { - coin_id: "cc".repeat(32), + coin_id: format!("{store}{root}").repeat(16), store_id: store.repeat(32), root: root.repeat(32), epoch, @@ -191,10 +196,12 @@ mod tests { current_epoch: 7, requirement: CollateralRequirementResult::Known { epoch: 7, + protocol_version: 1, required_per_store_dig_base_units: REQUIRED, stores: 10, owners: 3, - census_height: 100, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, }, margin_bp: 0, creates_enabled: true, @@ -219,12 +226,25 @@ mod tests { observation.locked_dig_base_units, 1_000, "both owned coins are locked; the reclaimed one has not confirmed" ); + // Asserted on the PAYLOAD, not merely on the variant. `Bonded` carries the coin a person + // looks up and the amount that coin locks, and a row naming the other coin — or this + // epoch's requirement instead of the coin's own 600 — is exactly the plausible wrong answer + // a bare variant check cannot see. assert!( observation .states .iter() - .any(|(b, s)| *b == bond("aa", "11") && *s == BondState::Bonded), - "the covered capsule is bonded" + .any(|(b, s)| *b == bond("aa", "11") + && matches!( + s, + BondState::Bonded { coin_id, epoch, amount_dig_base_units } + if *coin_id == format!("{}{}", "aa", "11").repeat(16) + && *epoch == 7 + && *amount_dig_base_units == 600 + )), + "the covered capsule is bonded by its OWN coin, for the amount that coin locks: \ + {:?}", + observation.states ); } @@ -280,10 +300,11 @@ mod tests { .find(|(k, _)| *k == b) .map(|(_, s)| s.clone()) }; - assert_eq!( - state_of(bond("aa", "11")), - Some(BondState::Bonded), - "a covered bond does not depend on the balance" + assert!( + matches!(state_of(bond("aa", "11")), Some(BondState::Bonded { .. })), + "a covered bond does not depend on the balance, so an unreadable wallet must not \ + disturb it: {:?}", + state_of(bond("aa", "11")) ); assert_eq!( state_of(bond("bb", "22")), diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index a230bd78..705d1d53 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -129,7 +129,11 @@ impl OperatorWallet { /// seed reaches this process. pub fn operator_puzzle_hash(paths: &WalletPaths) -> Option { let phrase = autoseed::open_operator_phrase(paths)?; - Some(digstore_chain::keys::derive_wallet_keys(&phrase).ok()?.owner_puzzle_hash) + Some( + digstore_chain::keys::derive_wallet_keys(&phrase) + .ok()? + .owner_puzzle_hash, + ) } #[cfg(test)] From c649ce90e9113476edb7d33aa8cc6f8dccf75136 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 06:47:47 -0700 Subject: [PATCH 06/19] docs(wallet): state what dig_balance_base_units actually guarantees The doc promised `None` for "an unsynced replica that cannot answer for this address". `balance_for_address` returns `Err(NotSynced)` only from the `Source::Fallback` arm with no live fallback; the ordinary in-scope-but-behind case returns `Ok(WalletBalanceResult { synced: false, .. })`, so this returns `Some` of a possibly-stale figure. Authority for an address and currency with the chain are independent questions and only the first can fail the read, so staleness was never covered by `None`. Step 8 will call this on a funding decision because this sentence said the case was handled; correcting the claim now costs nothing and removes a born-false guarantee from a money path. Notes the residue rather than silencing it: discriminating on `synced` alone would report UNKNOWN for every fallback-served answer, since that arm hard-codes it false. Also drops the tracked `.step7-wip` scratch marker, which a squash would otherwise land on main. Co-Authored-By: Claude --- .step7-wip | 1 - crates/dig-wallet/src/sage/rpc.rs | 13 +++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) delete mode 100644 .step7-wip diff --git a/.step7-wip b/.step7-wip deleted file mode 100644 index a5ff0e78..00000000 --- a/.step7-wip +++ /dev/null @@ -1 +0,0 @@ -step 7 lane: mirror bond observation + DHT pointer. WIP. diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 9eb7b963..be737215 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -1215,8 +1215,8 @@ impl WalletBackend { /// The spendable **$DIG** at this node's own operator puzzle hash, in DIG CAT base units. /// - /// `None` when the balance could not be read — an unreachable chain source, an unsynced replica - /// that cannot answer for this address, a figure too large for a `u64`. `None` is **not zero**: + /// `None` when the balance could not be read — an unreachable chain source, a replica that is + /// not authoritative for this address, a figure too large for a `u64`. `None` is **not zero**: /// §25's bond surface reports an uncovered bond as `deferred{balance_unreadable}` on `None` and /// as `unfunded` on `Some(0)`, and those are opposite claims — the first says the node does not /// know, the second raises an out-of-funds alarm. Substituting zero for an unreadable balance is @@ -1228,6 +1228,15 @@ impl WalletBackend { /// address string could read the right amount of the wrong asset at the wrong network's prefix, /// and every one of those returns a confident number. /// + /// **Staleness is NOT covered by `None`, and a caller must not read it as freshness.** Being + /// authoritative for an address and being current with the chain are independent questions, and + /// only the first can fail the read: a replica that is in scope but behind answers `Ok` with + /// `synced: false`, so this returns `Some` of a figure that may lag the chain. A caller that + /// needs currency must ask for it — `balance_for_address` returns `source`, `synced` and + /// `peak_height`, and this narrowing keeps only `balance`. Discriminating on `synced` alone + /// would be wrong in the other direction, because the fallback arm hard-codes it false for + /// answers that are perfectly good. + /// /// This is a READ. It confers no custody and touches no key: the puzzle hash is a public value. pub async fn dig_balance_base_units(&self, owner_puzzle_hash: Bytes32) -> Option { let address = self.address_of(&hex::encode(owner_puzzle_hash)); From 63c83d55ea18f394e75f0cd8b61e619c901786c2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:13:51 -0700 Subject: [PATCH 07/19] chore(mirror): open the step-7 wiring lane (lifecycle bring-up, WIP) --- .../dig-node-service/src/mirror/lifecycle.rs | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 crates/dig-node-service/src/mirror/lifecycle.rs diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs new file mode 100644 index 00000000..c4e6e8fa --- /dev/null +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -0,0 +1,457 @@ +//! Bring-up and scheduling for the mirror-coin lifecycle — the production half of `SPEC.md` §25 +//! (dig-node#412 step 7). +//! +//! [`super::runner::PassRunner`] knows what a pass DOES and [`super::observe`] knows what a bond +//! observation IS. Neither had a production caller, so `control.mirror.bondStates` answered +//! `unknown { reason: "chain_unreadable" }` on every call and no pass ever ran. This module is the +//! part that was missing: it opens the operator wallet once, builds the effects a pass needs, runs +//! one on the round timer, and publishes what it saw. +//! +//! # The read surface serves a SNAPSHOT, and that is a security property rather than a cache +//! +//! `control.mirror.bondStates` reads [`BondSnapshot`] — the observation the last pass published — +//! and does no chain work of its own. The alternative, observing per request, turns one ~200-byte +//! paired-token call into a seed unseal, a PBKDF2, up to `dig_mirror_coin::MAX_CANDIDATES` chain +//! lookups and an oracle read. A paired token is a much weaker predicate than "trusted", so that +//! would be a real amplification surface on a branch with no ingress limiter of its own. Reading a +//! published value costs a lock and cannot be amplified: the chain work happens on the round timer +//! whether anybody asks or not. +//! +//! The same reasoning removes the second unseal route. The owner puzzle hash is derived ONCE here, +//! at bring-up, where the operator wallet is already being opened under the device key, and held +//! afterwards as the public value it is. No read path re-opens the sealed seed. +//! +//! # An incomplete inventory is `unknown`, never a short answer +//! +//! `dig_mirror_coin::list` scans a puzzle hash every mirror coin in existence shares, so a stranger +//! can add candidates to it for the price of a dust coin, and the scan stops at `MAX_CANDIDATES`. +//! A truncated inventory reports LESS locked $DIG than is actually locked — money shown as free +//! while it sits on chain, which is the one direction a money figure must never be wrong in. So +//! [`MirrorInventory::is_complete`](dig_mirror_coin::MirrorInventory::is_complete) is consulted and +//! an incomplete scan aborts the pass, leaving the surface saying `unknown` with a named reason. +//! +//! # What this node can and cannot do with money today +//! +//! **Reclaims work.** `dig_mirror_coin::reclaim` recreates the full locked amount at the owner's own +//! puzzle hash and is supported at `fee = 0`, which needs no fee coins at all — so a node whose XCH +//! is exhausted can still recover $DIG it has locked. That is §25.4.4, and it is the invariant that +//! matters most, because its failure mode is collateral locked forever. +//! +//! **Creates do not, yet, and they refuse rather than guess.** `dig_mirror_coin::create` takes the +//! `Cat` inputs from its caller, and selecting them requires a $DIG coin selector scoped to the +//! OPERATOR puzzle hash. The node-custodied [`WalletBackend`](dig_wallet::sage::rpc::WalletBackend) +//! selector is scoped to its own replica instead, so using it would fund a mirror coin from the +//! wrong wallet's coins. [`NodeMirrorEffects::create`] therefore returns a named +//! [`PassError::Wallet`], the pass reports it in `stopped_at`, and §25.8 keeps reporting the bond +//! as uncovered — which is true. The selector is dig-node#420. +//! +//! # Nothing here relaxes the audit shape +//! +//! [`MirrorSigner::sign`](super::signer::MirrorSigner::sign) takes a [`SpendJournal`] and returns a +//! `RecordedSpend`, which is obtainable no other way. So this module cannot sign without journaling +//! — not because it promises not to, but because there is no expressible call that does. +//! +//! # The signer is module-private and is NOT installed on the general wallet +//! +//! [`MirrorSigner`] is held inside [`NodeMirrorEffects`] and reachable from nowhere else. In +//! particular it is never attached to the served `WalletBackend`: doing so would enable that +//! backend's own signing surface — including default-on auto-tipping — as a side effect of +//! collateralising capsules, which is an unreviewed behaviour change on a money path. A test asserts +//! the served backend still answers `None` for its current signer after bring-up. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use chia_protocol::Bytes32; +use dig_chainsource_interface::ChainSource; +use dig_mirror_coin::MirrorCoin; +use dig_node_core::Node; +use dig_wallet::autoseed::WalletPaths; +use dig_wallet::operator_wallet::OperatorWallet; +use dig_wallet::sage::rpc::WalletBackend; +use dig_wallet::sage::spend::Broadcaster; + +use crate::spend_audit::{FailureStage, SpendJournal, SpendLog, Submission, TargetCoinId}; + +use super::observe::held_mirrors; +use super::plan::{Bond, HeldMirror, ReclaimReason}; +use super::runner::{MirrorEffects, ObservedCapsule, PassError, PassReport}; +use super::signer::MirrorSigner; +use super::states::BondObservation; + +/// The §25.8 observation the control surface serves, or `None` before the first pass has run. +/// +/// `None` is UNKNOWN and the surface says so. It is deliberately not an empty observation: a page of +/// no rows is a definite claim that this node holds no bonds, and a node that has simply not +/// observed yet is not in a position to make it. +pub type BondSnapshot = Arc>>; + +/// A fresh, empty snapshot. The surface reads `unknown` from one of these until a pass fills it. +pub fn new_snapshot() -> BondSnapshot { + Arc::new(RwLock::new(None)) +} + +/// The production [`MirrorEffects`], built fresh for each pass. +/// +/// Built per pass rather than held, because two of its four readings are taken ASYNCHRONOUSLY by the +/// scheduler before the pass begins — the disk scan and the $DIG balance — and handing them in as +/// values is what lets the pass itself be synchronous. One pass therefore sees one disk state and +/// one balance throughout, which is the same guarantee [`super::runner::PassContext`] gives the +/// epoch and the requirement, and for the same reason. +pub struct NodeMirrorEffects<'a, S: ChainSource> { + /// The capsules on disk, WITH provenance, already read. + capsules: Result, PassError>, + /// Spendable $DIG at the operator address, already read. `Err` defers creates, never reclaims. + dig_balance: Result, + /// The chain, for the owned-coin scan and for the reclaim spends. + source: &'a S, + /// This node's operator puzzle hash — a public value, derived once at bring-up. + owner_puzzle_hash: Bytes32, + /// The signer, when an operator wallet opened AND live broadcast is enabled. + signer: Option<&'a MirrorSigner>, + /// Where every automated spend is recorded before it is signed. + journal: &'a SpendJournal, + /// How a signed bundle reaches the mempool. `None` means this node does not broadcast. + broadcaster: Option<&'a dyn Broadcaster>, + /// A tokio handle, so the synchronous spend path can drive the asynchronous broadcast. + runtime: tokio::runtime::Handle, + /// The authenticated coins the last `observe_chain` resolved, keyed by coin id. + /// + /// `dig_mirror_coin::reclaim` needs the [`MirrorCoin`] itself — its lineage proof is what proves + /// ownership — while [`MirrorEffects::reclaim`] is handed the planner's flat [`HeldMirror`]. + /// Retaining the coins the scan already authenticated is what bridges the two WITHOUT a second + /// chain read, and more importantly without a second ownership derivation: the coin reclaimed is + /// byte-for-byte the coin whose ownership was proven. + resolved: std::cell::RefCell>, +} + +impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { + /// Assemble the effects for one pass from readings the scheduler has already taken. + #[allow(clippy::too_many_arguments)] + pub fn new( + capsules: Result, PassError>, + dig_balance: Result, + source: &'a S, + owner_puzzle_hash: Bytes32, + signer: Option<&'a MirrorSigner>, + journal: &'a SpendJournal, + broadcaster: Option<&'a dyn Broadcaster>, + runtime: tokio::runtime::Handle, + ) -> Self { + Self { + capsules, + dig_balance, + source, + owner_puzzle_hash, + signer, + journal, + broadcaster, + runtime, + resolved: std::cell::RefCell::new(HashMap::new()), + } + } + + /// Sign, journal and broadcast `spends`, resolving the audit record either way. + /// + /// The record is opened by the signer, from the spends, so its account of the money cannot + /// disagree with the bundle. This function's only job is to carry the outcome back onto it: a + /// bundle that reached the mempool is `Submitted`, and a broadcast that failed is + /// `Failed { stage: Broadcast }` rather than being dropped — dropping it writes `Unresolved`, + /// which claims the node signed and does not know what became of it. That claim is true only + /// when the broadcast's own outcome is genuinely unknown, and a returned error is not that. + fn sign_and_broadcast(&self, spends: &super::spends::MirrorSpends) -> Result<(), PassError> { + let signer = self + .signer + .ok_or_else(|| PassError::Wallet("no operator wallet is available to sign".into()))?; + let broadcaster = self.broadcaster.ok_or_else(|| { + PassError::Wallet( + "live broadcast is disabled (DIG_WALLET_ENABLE_LIVE_BROADCAST), so no mirror spend \ + is sent" + .into(), + ) + })?; + + let (bundle, recorded) = signer + .sign(spends, self.journal) + .map_err(|e| PassError::Wallet(e.to_string()))?; + + // The intended coin is the one the spends declare they create. Recorded as an EXPECTATION + // here; only a chain observation may promote it to `Confirmed`, which is why + // `SpendJournal::confirmed` is not called from this path at all. + let submission = Submission { + intended_coin_id: TargetCoinId::new(hex::encode(spends.intended_coin_id())), + funding_coin_ids: spends.funding_coin_ids(), + }; + + match self.runtime.block_on(broadcaster.broadcast(&bundle)) { + Ok(()) => { + self.journal.submitted(&recorded, submission); + Ok(()) + } + Err(e) => { + let cause = e.to_string(); + self.journal + .failed(&recorded, FailureStage::Broadcast, cause.clone()); + Err(PassError::Wallet(cause)) + } + } + } +} + +impl MirrorEffects for NodeMirrorEffects<'_, S> { + fn observe_disk(&self) -> Result, PassError> { + self.capsules.clone() + } + + fn observe_chain(&self) -> Result, PassError> { + let inventory = dig_mirror_coin::list(self.source, self.owner_puzzle_hash) + .map_err(|e| PassError::Chain(e.to_string()))?; + + // Fail CLOSED on an incomplete scan. See the module doc: a short inventory under-reports + // locked money, and the truncation point is purchasable with dust. + if !inventory.is_complete() { + return Err(PassError::Chain(format!( + "the owned-coin scan was incomplete ({} candidates unresolved, truncated: {}), so \ + this node's locked collateral is UNKNOWN rather than short", + inventory.skipped().len(), + inventory.is_truncated(), + ))); + } + + // Retain the authenticated coins for the reclaim path before flattening them. + let mut resolved = self.resolved.borrow_mut(); + resolved.clear(); + for coin in inventory.coins() { + resolved.insert(hex::encode(coin.coin().coin_id()), coin.clone()); + } + drop(resolved); + + Ok(held_mirrors(&inventory)) + } + + fn dig_balance_base_units(&self) -> Result { + self.dig_balance.clone() + } + + fn reclaim(&self, mirror: &HeldMirror, reason: ReclaimReason) -> Result<(), PassError> { + let coin = self + .resolved + .borrow() + .get(&mirror.coin_id) + .cloned() + .ok_or_else(|| { + PassError::Chain(format!( + "coin {} was planned for reclaim but is not in the authenticated scan; it is \ + not reclaimed rather than reclaimed from an unverified record", + mirror.coin_id + )) + })?; + + let signer = self + .signer + .ok_or_else(|| PassError::Wallet("no operator wallet is available to sign".into()))?; + + // `fee = 0` with no fee coins, always. §25.4.4: a zero-fee reclaim may not be admitted under + // fee pressure, and the next pass retries it — whereas a reclaim gated on selectable XCH + // cannot run at all on the exhausted wallet that needs it most. + let spends = super::spends::build_reclaim(&coin, signer.synthetic_key(), Vec::new(), 0) + .map_err(|e| PassError::Wallet(e.to_string()))?; + + tracing::info!( + target: "mirror", + coin_id = %mirror.coin_id, + store_id = %mirror.store_id, + reason = ?reason, + "reclaiming mirror collateral" + ); + self.sign_and_broadcast(&spends) + } + + fn create(&self, bond: &Bond, _epoch: i64, amount_dig_base_units: u64) -> Result<(), PassError> { + // REFUSED, by name, rather than funded from the wrong wallet. See the module doc: the only + // $DIG selector this process has is scoped to the node-custodied replica, not to the + // operator puzzle hash, and a mirror coin funded from the wrong coins is a real spend that + // looks successful. dig-node#420 is the operator-scoped selector. + Err(PassError::Wallet(format!( + "creating the {} bond needs {} DIG base units selected from the OPERATOR wallet, and \ + this node has no operator-scoped $DIG coin selector yet (dig-node#420); no spend was \ + attempted", + bond.store_id, amount_dig_base_units + ))) + } +} + +/// What bring-up found, and therefore what this node's lifecycle can do. +/// +/// Reported as one value rather than inferred from a null signer at each use, so "is an operator +/// wallet available at all" is answered once, at the place that can say why. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpendCapability { + /// An operator wallet opened and live broadcast is enabled: this node may create and reclaim. + Available, + /// The §16.4 wallet did not open — absent, `Locked`, or `Orphaned`. Observation continues. + WalletUnavailable, + /// A wallet opened but `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off, the money-safe default. + BroadcastDisabled, +} + +impl SpendCapability { + /// Whether a pass may spend at all. + pub fn may_spend(self) -> bool { + matches!(self, SpendCapability::Available) + } +} + +/// Open the operator wallet, or say why the lifecycle cannot spend. +/// +/// [`OperatorWallet::open`] returns `None` for BOTH §16.4 `Locked` and `Orphaned`, which is the +/// behaviour this wants: neither state has a key it would be correct to substitute, so the honest +/// outcome is the same in both — no signer, and a lifecycle that observes without spending. +pub fn open_signer(paths: &WalletPaths, live_broadcast: bool) -> (Option, SpendCapability) { + let Some(wallet) = OperatorWallet::open(paths, dig_constants::DIG_MAINNET.genesis_challenge()) + else { + return (None, SpendCapability::WalletUnavailable); + }; + if !live_broadcast { + // The wallet opened, so the capability is not missing — it is switched off. Saying so + // distinguishes "this node has no wallet" from "this node has one and will not spend it", + // which are different things for an operator to fix. + return (None, SpendCapability::BroadcastDisabled); + } + (Some(MirrorSigner::new(wallet)), SpendCapability::Available) +} + +/// Publish what a pass observed, so `control.mirror.bondStates` can answer from it. +/// +/// A TRANSLATION of [`PassReport`], never a recomputation: the states and the locked total are +/// carried across unchanged. Deriving either a second time here would be a second answer to +/// "what does this node have bonded", and the two would drift in the direction nobody tests. +pub fn publish(snapshot: &BondSnapshot, report: &PassReport, epoch: i64) { + let observation = BondObservation { + states: report.states.clone(), + locked_dig_base_units: report.locked_dig_base_units, + epoch, + }; + match snapshot.write() { + Ok(mut slot) => *slot = Some(observation), + // A poisoned lock means a previous writer panicked. The observation is dropped rather than + // recovered into: the surface then keeps saying `unknown`, which is worse for nobody, while + // clearing the poison would hide a panic on a money path. + Err(_) => tracing::error!( + target: "mirror", + "the bond-state snapshot lock is poisoned; this pass's observation was not published" + ), + } +} + +/// Read the disk set, with provenance, as the pass needs it. +/// +/// Asynchronous, so it is taken by the scheduler before the pass begins. The `Held`/`Relayed` split +/// is NOT applied here — [`super::runner::split_by_provenance`] owns it, and applying it early would +/// put §25.1's exclusion in a second place. +pub async fn observe_disk(node: &Node) -> Result, PassError> { + let cached = node + .cache_list_cached() + .await + .map_err(|e| PassError::Disk(e.to_string()))?; + Ok(cached + .into_iter() + .map(|capsule| ObservedCapsule { + bond: Bond::new(capsule.store_id, capsule.root), + provenance: capsule.provenance, + }) + .collect()) +} + +/// Read spendable $DIG at the OPERATOR address. +/// +/// Scoped to `owner_puzzle_hash` rather than to the node-custodied replica's own set, because the +/// money a mirror coin locks is the operator wallet's. `None` from the backend is UNKNOWN and +/// becomes an `Err` here, which the runner turns into deferred creates and attempted reclaims — +/// never a fabricated zero, which would raise an out-of-funds alarm about a wallet nobody read. +pub async fn observe_dig_balance( + wallet: &WalletBackend, + owner_puzzle_hash: Bytes32, +) -> Result { + wallet + .dig_balance_base_units(owner_puzzle_hash) + .await + .ok_or_else(|| PassError::Wallet("the operator wallet's $DIG balance is unreadable".into())) +} + +/// A [`SpendJournal`] over the machine-wide audit log. +pub fn journal() -> SpendJournal { + SpendJournal::new(SpendLog::in_state_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Bring-up with no operator wallet yields NO signer, and says which of the two reasons it was. + /// + /// The two branches are asserted separately because they collapse to the same `None` signer and + /// an operator fixes them differently: an absent wallet needs a seed, a disabled broadcast needs + /// an environment variable. A test that only checked `signer.is_none()` would pass against an + /// implementation that reported either reason for both. + #[test] + fn a_disabled_broadcast_is_reported_differently_from_an_unopenable_wallet() { + let empty = tempfile::tempdir().expect("a temp dir"); + let paths = WalletPaths::resolve(empty.path().join("seed")); + + let (signer, capability) = open_signer(&paths, true); + assert!(signer.is_none(), "no seed exists, so nothing may sign"); + assert_eq!( + capability, + SpendCapability::WalletUnavailable, + "an absent seed is the WALLET being unavailable, not a switched-off broadcast" + ); + assert!(!capability.may_spend()); + } + + /// `publish` carries the report's own figures across, and does not recompute either. + /// + /// The fixture's `locked_dig_base_units` is deliberately INCONSISTENT with its `states` — a + /// locked total no sum over those rows could produce — so an implementation that recomputed the + /// total from the states would be visible. A consistent fixture cannot tell a translation from a + /// recomputation, which is the whole property. + #[test] + fn publishing_translates_the_report_rather_than_recomputing_it() { + use super::super::pass::BondState; + + let snapshot = new_snapshot(); + let report = PassReport { + reclaimed: Vec::new(), + created: Vec::new(), + reclaim_failures: Vec::new(), + stopped_at: None, + states: vec![(Bond::new("aa".repeat(32), "11".repeat(32)), BondState::Withheld)], + per_coin_dig_base_units: None, + locked_dig_base_units: 4_242, + }; + + publish(&snapshot, &report, 9); + + let published = snapshot.read().expect("unpoisoned").clone().expect("published"); + assert_eq!( + published.locked_dig_base_units, 4_242, + "the report's own total, not a sum over the rows" + ); + assert_eq!(published.epoch, 9); + assert_eq!(published.states, report.states); + } + + /// An unpublished snapshot is `None` — UNKNOWN — and never an empty observation. + /// + /// An empty `BondObservation` pages as a complete answer of zero rows, which asserts that this + /// node holds no bonds. A node that has not observed yet is not entitled to that claim, and the + /// two are indistinguishable downstream once the empty value exists. + #[test] + fn a_snapshot_before_the_first_pass_is_unknown_not_empty() { + let snapshot = new_snapshot(); + assert!( + snapshot.read().expect("unpoisoned").is_none(), + "nothing has been observed, so there is no observation" + ); + } +} From 889835dd420e10d9e76f3da6d7d15a689ea117a6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:16:53 -0700 Subject: [PATCH 08/19] feat(mirror): serve bondStates from a published pass observation (#412 step 7) --- crates/dig-node-service/src/control.rs | 143 +++++++++++++++--- .../dig-node-service/src/mirror/lifecycle.rs | 83 ++++++++-- crates/dig-node-service/src/mirror/mod.rs | 1 + crates/dig-node-service/src/mirror/signer.rs | 10 ++ crates/dig-wallet/src/operator_wallet.rs | 13 ++ 5 files changed, 219 insertions(+), 31 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 38cddce2..08b6e1b8 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -842,6 +842,17 @@ pub struct ControlCtx { /// The node-custodied wallet backend (#368), for the READ-ONLY `control.wallet.balance` /// chain read (#1851). A public-address balance read only — never a spend/custody path. pub wallet: Arc, + /// §25.8's observation, as the last mirror pass published it (dig-node#412 step 7). + /// + /// A published SNAPSHOT rather than a per-request observation, and that is a security property + /// rather than a cache: observing here would turn one ~200-byte token-gated call into a seed + /// unseal, a PBKDF2, up to `dig_mirror_coin::MAX_CANDIDATES` chain lookups and an oracle read. + /// Reading a value the round timer already produced cannot be amplified by asking more often. + /// + /// `None` inside means the first pass has not completed, which the surface reports as `unknown` + /// with a named reason. It is deliberately not an empty observation — see + /// [`crate::mirror::lifecycle::BondSnapshot`]. + pub mirror_bonds: crate::mirror::lifecycle::BondSnapshot, } /// Dispatch a single authorized CONTROL method. The caller has ALREADY enforced the @@ -930,7 +941,7 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu // The mirror-coin bond surface (dig-node#412, SPEC.md §25.8) -- one page of this // node's OWN bonds. Shell-owned because the bonds are the SERVICE's money, not the // embedded engine's. - "control.mirror.bondStates" => mirror_bond_states(id, params), + "control.mirror.bondStates" => mirror_bond_states(mirror_bond_observation(ctx), id, params), "control.wallet.broadcast" => wallet_broadcast(ctx, id, params).await, // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s // own status file + CLI (see `crate::updater`'s module doc for why nothing here @@ -3475,13 +3486,17 @@ fn collateral_buffer(id: Value) -> Value { /// (`cache_list_cached`) — and [`crate::mirror::pass::decide`] already keys its states on `held` /// AND `relayed` for this reason. /// -/// # Today this answers `unknown` with a NAMED reason, which is the honest answer and not a stub +/// # The answer comes from the last PASS, not from a read this call performs +/// +/// Stating a bond's state needs the mirror coins this wallet owns, and that read +/// (`dig_mirror_coin::list`) is a scan of a puzzle hash anyone may add to. Performing it per request +/// would turn one ~200-byte token-gated call into a seed unseal, a PBKDF2, up to +/// `dig_mirror_coin::MAX_CANDIDATES` chain lookups and an oracle read — a genuine amplification +/// surface, since a paired token is a much weaker predicate than "trusted". So the mirror lifecycle +/// observes on its own round timer and PUBLISHES what it saw +/// ([`crate::mirror::lifecycle::BondSnapshot`]), and this method reads that. /// -/// Stating a bond's state needs the mirror COINS this wallet owns. That read -/// (`dig_mirror_coin::list`) needs a `dig_chainsource_interface::ChainSource` and the operator -/// wallet's own puzzle hash, and neither is reachable from the control plane: the reconcile -/// runner's bring-up is dig-node#412 step 7. So the node cannot tell a bonded capsule from an -/// unbonded one, and it says so. +/// # `unknown` remains the honest answer before the first pass, and is not an empty page /// /// Every alternative is a fabrication on a money surface. An empty page reports a funded node as /// holding no bonds; a page of `unfunded` rows raises the out-of-funds alarm about a wallet nobody @@ -3490,9 +3505,17 @@ fn collateral_buffer(id: Value) -> Value { /// naming the wrong missing fact would send an operator to repair a capsule cache that is working. /// [`collateral_buffer`] answers the same way, for the same reason. /// -/// The rest of the method is [`crate::mirror::states::page`], already exercised against fixtures, so -/// step 7 lights this up by handing it an observation rather than by writing a surface. -fn mirror_bond_states(id: Value, params: &Value) -> Value { +/// The observation is a PARAMETER rather than something this function fetches, so the paging, +/// ordering and wire mapping stay exercisable against fixtures with no wallet, no chain and no +/// `ControlCtx` anywhere near them. +fn mirror_bond_states( + observation: Result< + crate::mirror::states::BondObservation, + dig_node_control_interface::results::MirrorBondStatesUnknownReason, + >, + id: Value, + params: &Value, +) -> Value { use dig_node_control_interface::params::MirrorBondStatesParams; use dig_node_control_interface::results::MirrorBondStatesResult; @@ -3505,7 +3528,7 @@ fn mirror_bond_states(id: Value, params: &Value) -> Value { Err(e) => return control_error(id, ErrorCode::InvalidParams, e.to_string()), }; - let answer = match mirror_bond_observation() { + let answer = match observation { Ok(observation) => crate::mirror::states::page( &observation, params.after.as_ref(), @@ -3522,15 +3545,25 @@ fn mirror_bond_states(id: Value, params: &Value) -> Value { /// The whole §25.8 answer before paging, or the ONE fact the node is missing. /// /// Separated from [`mirror_bond_states`] so the surface and the observation are different concerns: -/// paging, ordering and the wire mapping are settled and tested, and this is the single call site -/// dig-node#412 step 7 replaces when the reconcile runner is brought up. A caller MUST NOT -/// substitute an empty observation for an `Err` — a page of no rows says "this node holds no -/// bonds", which is a definite claim about money, and it is not the claim being made here. -fn mirror_bond_observation() -> Result< +/// paging, ordering and the wire mapping are settled and tested against fixtures, and this is the +/// one place that touches live state. A caller MUST NOT substitute an empty observation for an +/// `Err` — a page of no rows says "this node holds no bonds", which is a definite claim about +/// money, and it is not the claim being made here. +fn mirror_bond_observation( + ctx: &ControlCtx, +) -> Result< crate::mirror::states::BondObservation, dig_node_control_interface::results::MirrorBondStatesUnknownReason, > { - Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable) + use dig_node_control_interface::results::MirrorBondStatesUnknownReason; + + // A poisoned lock is UNKNOWN, not empty: a writer panicked on a money path and this node cannot + // vouch for what it last published. + let slot = ctx + .mirror_bonds + .read() + .map_err(|_| MirrorBondStatesUnknownReason::ChainUnreadable)?; + slot.clone().ok_or(MirrorBondStatesUnknownReason::ChainUnreadable) } /// `control.collateral.margin.get` — the node's local safety margin, in basis points. @@ -3602,7 +3635,11 @@ mod tests { /// `state` beside an `entries` key would still hand a lenient client a zero to render. #[test] fn mirror_bond_states_says_which_fact_it_is_missing_rather_than_returning_an_empty_page() { - let answer = mirror_bond_states(json!(1), &json!({})); + let answer = mirror_bond_states( + Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + json!(1), + &json!({}), + ); let result = &answer["result"]; assert_eq!(result["state"], "unknown"); assert_eq!(result["reason"], "chain_unreadable"); @@ -3612,6 +3649,62 @@ mod tests { ); } + /// **Proves:** a PUBLISHED observation reaches the wire as rows — the surface stops answering + /// `unknown` the moment a pass has run. + /// + /// **Catches:** the whole reason this step exists. Before step 7 the observation seam returned + /// `Err` unconditionally, so every green test above passed against a method that could not + /// answer anything, and `chain_unreadable` was indistinguishable from a stub. + /// + /// The fixture carries TWO bonds with DIFFERENT states, and a `locked_dig_base_units` that is + /// not derivable from either — an implementation that paged only the first row, that reported + /// one state for every row, or that recomputed the total from the page would each be visible. + /// A single-row fixture could distinguish none of them. + #[test] + fn a_published_observation_is_served_as_rows_rather_than_as_unknown() { + use crate::mirror::pass::BondState; + use crate::mirror::plan::Bond; + use crate::mirror::states::BondObservation; + + let observation = BondObservation { + states: vec![ + ( + Bond::new("aa".repeat(32), "11".repeat(32)), + BondState::Withheld, + ), + ( + Bond::new("bb".repeat(32), "22".repeat(32)), + BondState::FundsUnknown, + ), + ], + locked_dig_base_units: 7_777, + epoch: 4, + }; + + let answer = mirror_bond_states(Ok(observation), json!(1), &json!({})); + let result = &answer["result"]; + + assert_eq!(result["state"], "known", "a published observation is KNOWN: {result}"); + assert_eq!( + result["locked_dig_base_units"], 7_777, + "the observation's own total, carried across unchanged" + ); + assert_eq!(result["epoch"], 4); + + let entries = result["entries"].as_array().expect("rows"); + assert_eq!(entries.len(), 2, "both bonds are rows: {result}"); + // Asserted on the PAYLOAD, and on the two rows SEPARATELY. A `withheld` capsule stays + // `withheld` — it is held on a stranger's behalf and deliberately never advertised — while + // the uncovered one reports `deferred{balance_unreadable}` rather than a fabricated + // shortfall. A count-only assertion is satisfied by an implementation that emits one state + // for every row. + assert_eq!(entries[0]["store_id"], "aa".repeat(32)); + assert_eq!(entries[0]["state"]["state"], "withheld"); + assert_eq!(entries[1]["store_id"], "bb".repeat(32)); + assert_eq!(entries[1]["state"]["state"], "deferred"); + assert_eq!(entries[1]["state"]["reason"], "balance_unreadable"); + } + /// **Proves:** an out-of-range page size is REFUSED as `INVALID_PARAMS`, not clamped. /// /// **Catches:** a clamp, which hands back a cursor for a position the caller never asked @@ -3620,7 +3713,11 @@ mod tests { #[test] fn mirror_bond_states_refuses_a_page_size_it_will_not_serve() { for limit in [json!(0), json!(1001)] { - let answer = mirror_bond_states(json!(1), &json!({ "limit": limit })); + let answer = mirror_bond_states( + Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + json!(1), + &json!({ "limit": limit }), + ); assert_eq!( answer["error"]["code"], json!(ErrorCode::InvalidParams.code()), @@ -3629,7 +3726,11 @@ mod tests { } // The control: an in-range limit is not refused, so the assertion above is about the // BOUND rather than about the params being rejected wholesale. - let ok = mirror_bond_states(json!(1), &json!({ "limit": 1000 })); + let ok = mirror_bond_states( + Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + json!(1), + &json!({ "limit": 1000 }), + ); assert!(ok.get("error").is_none(), "1000 is in range: {ok}"); } @@ -3643,6 +3744,7 @@ mod tests { fn mirror_bond_states_refuses_a_malformed_cursor_and_tolerates_a_prefixed_one() { let hex = "11".repeat(32); let malformed = mirror_bond_states( + Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), json!(1), &json!({ "after": { "store_id": "not-hex", "root": hex } }), ); @@ -3653,6 +3755,7 @@ mod tests { ); let prefixed = mirror_bond_states( + Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), json!(1), &json!({ "after": { "store_id": format!("0x{hex}"), "root": hex } }), ); diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index c4e6e8fa..87e3c618 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -71,7 +71,9 @@ use dig_wallet::operator_wallet::OperatorWallet; use dig_wallet::sage::rpc::WalletBackend; use dig_wallet::sage::spend::Broadcaster; -use crate::spend_audit::{FailureStage, SpendJournal, SpendLog, Submission, TargetCoinId}; +use crate::spend_audit::{ + FailureStage, FundingCoinId, SpendJournal, SpendLog, Submission, TargetCoinId, +}; use super::observe::held_mirrors; use super::plan::{Bond, HeldMirror, ReclaimReason}; @@ -159,7 +161,11 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { /// `Failed { stage: Broadcast }` rather than being dropped — dropping it writes `Unresolved`, /// which claims the node signed and does not know what became of it. That claim is true only /// when the broadcast's own outcome is genuinely unknown, and a returned error is not that. - fn sign_and_broadcast(&self, spends: &super::spends::MirrorSpends) -> Result<(), PassError> { + fn sign_and_broadcast( + &self, + spends: &super::spends::MirrorSpends, + intended: Option, + ) -> Result<(), PassError> { let signer = self .signer .ok_or_else(|| PassError::Wallet("no operator wallet is available to sign".into()))?; @@ -175,17 +181,40 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { .sign(spends, self.journal) .map_err(|e| PassError::Wallet(e.to_string()))?; - // The intended coin is the one the spends declare they create. Recorded as an EXPECTATION - // here; only a chain observation may promote it to `Confirmed`, which is why - // `SpendJournal::confirmed` is not called from this path at all. - let submission = Submission { - intended_coin_id: TargetCoinId::new(hex::encode(spends.intended_coin_id())), - funding_coin_ids: spends.funding_coin_ids(), - }; + // The coins CONSUMED are read from the bundle itself rather than stated: every `CoinSpend` + // in it spends exactly its own coin, so this cannot disagree with what was signed. + let funding_coin_ids = spends + .coin_spends() + .iter() + .map(|cs| FundingCoinId(hex::encode(cs.coin.coin_id()))) + .collect(); match self.runtime.block_on(broadcaster.broadcast(&bundle)) { Ok(()) => { - self.journal.submitted(&recorded, submission); + match intended { + // Recorded as an EXPECTATION. Only a chain observation may promote it to + // `Confirmed`, which is why `SpendJournal::confirmed` is not called from this + // path at all. + Some(intended_coin_id) => self.journal.submitted( + &recorded, + Submission { + intended_coin_id, + funding_coin_ids, + }, + ), + // No coin id this node can DERIVE, so none is stated. Dropping `recorded` + // resolves it `Unresolved`, which this crate defines as "the node signed and + // does not know what became of it" — and after a successful broadcast with an + // underivable target, that is precisely true. Naming a plausible coin instead + // would let §23.5's reconcile confirm this spend against a coin it never + // created, which is the legacy defect `TargetCoinId` exists to make + // inexpressible. + None => tracing::warn!( + target: "mirror", + operation = spends.operation().as_str(), + "broadcast a mirror spend whose created coin this node cannot derive; the audit entry resolves UNRESOLVED rather than naming a guessed coin" + ), + } Ok(()) } Err(e) => { @@ -264,7 +293,7 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { reason = ?reason, "reclaiming mirror collateral" ); - self.sign_and_broadcast(&spends) + self.sign_and_broadcast(&spends, Some(reclaimed_coin_id(&coin))) } fn create(&self, bond: &Bond, _epoch: i64, amount_dig_base_units: u64) -> Result<(), PassError> { @@ -281,6 +310,38 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { } } +/// The coin a reclaim of `mirror` CREATES, derived rather than guessed. +/// +/// `dig_mirror_coin::reclaim` recreates the entire locked amount at the owner's own puzzle hash, as +/// a $DIG CAT — so the created coin is fully determined by three things the coin itself carries: its +/// own id becomes the parent, the amount is the collateral it locked, and the puzzle hash is the CAT +/// wrapping of the owner's standard puzzle hash under [`dig_mirror_coin::DIG_ASSET_ID`]. +/// +/// Derived here rather than read back from the bundle because the audit record must name the coin +/// whose EXISTENCE confirms this spend, and a spend that has only been broadcast has created +/// nothing yet. Getting it wrong is the legacy defect [`TargetCoinId`] exists to prevent: confirming +/// against a coin the spend did not create — or against the funding coin, which a competing spend +/// removes identically — proves nothing at all. +/// +/// The CAT wrapping is NOT optional and is not a detail: a mirror coin's collateral is $DIG, so the +/// returned coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. Naming the +/// unwrapped hash would produce a coin id that can never appear on chain, so the reclaim would stay +/// unconfirmed forever while having genuinely succeeded. +fn reclaimed_coin_id(mirror: &MirrorCoin) -> TargetCoinId { + use chia_puzzle_types::cat::CatArgs; + + let inner: clvm_utils::TreeHash = mirror.owner_puzzle_hash().into(); + let puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(dig_mirror_coin::DIG_ASSET_ID, inner).into(); + + let created = chia_protocol::Coin::new( + mirror.coin().coin_id(), + puzzle_hash, + mirror.collateral(), + ); + TargetCoinId(hex::encode(created.coin_id())) +} + /// What bring-up found, and therefore what this node's lifecycle can do. /// /// Reported as one value rather than inferred from a null signer at each use, so "is an operator diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index e8afa63f..cff25bff 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,7 @@ //! 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 lifecycle; pub mod observe; pub mod pass; pub mod plan; diff --git a/crates/dig-node-service/src/mirror/signer.rs b/crates/dig-node-service/src/mirror/signer.rs index 8164c72f..41e9e9b0 100644 --- a/crates/dig-node-service/src/mirror/signer.rs +++ b/crates/dig-node-service/src/mirror/signer.rs @@ -140,6 +140,16 @@ impl MirrorSigner { self.wallet.owner_puzzle_hash() } + /// The PUBLIC synthetic key the mirror spend builders curry. + /// + /// Exposed so the lifecycle can BUILD a spend for the same wallet that will sign it, without + /// holding the wallet. Handing out a public key is not a capability: `sign` still refuses any + /// bundle whose owner is not this wallet, so a caller cannot use this key to have something + /// else signed. + pub fn synthetic_key(&self) -> chia_bls::PublicKey { + self.wallet.synthetic_key() + } + /// Open an audit record for `spends` and sign them, returning both. /// /// The record is opened HERE, from the spends, and exactly one is opened per signature. Its diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index 705d1d53..281bc79a 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -52,6 +52,7 @@ use crate::sage::spend::WalletSigner; pub struct OperatorWallet { signer: WalletSigner, owner_puzzle_hash: Bytes32, + synthetic_key: chia_bls::PublicKey, } impl OperatorWallet { @@ -82,10 +83,22 @@ impl OperatorWallet { let keys = digstore_chain::keys::derive_wallet_keys(phrase).ok()?; Some(Self { owner_puzzle_hash: keys.owner_puzzle_hash, + synthetic_key: keys.synthetic_sk.public_key(), signer: WalletSigner::new(vec![keys.synthetic_sk], agg_sig_data), }) } + /// The PUBLIC synthetic key the spend builders curry into a standard layer. + /// + /// Public, and deliberately typed as such: `dig_mirror_coin::create` and `::reclaim` both take a + /// [`chia_bls::PublicKey`] to derive the owner they build for, and handing them the secret key + /// would be neither necessary nor expressible. Derived once at construction from the same + /// secret the signer holds, so the key a spend is BUILT for and the key it is SIGNED with cannot + /// be two different keys. + pub fn synthetic_key(&self) -> chia_bls::PublicKey { + self.synthetic_key.clone() + } + /// The signer for this wallet's keys. pub fn signer(&self) -> &WalletSigner { &self.signer From c8a1342c84e28ffb19b6ef1781d1123fd6bdc6b7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:20:00 -0700 Subject: [PATCH 09/19] feat(mirror): schedule the reconcile pass and open the signer at bring-up (#412 step 7) --- crates/dig-node-service/Cargo.toml | 4 + .../dig-node-service/src/mirror/lifecycle.rs | 13 ++ crates/dig-node-service/src/mirror/runner.rs | 18 ++ crates/dig-node-service/src/server.rs | 190 ++++++++++++++++++ 4 files changed, 225 insertions(+) diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 5696c221..ec8ee10f 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -124,6 +124,10 @@ dig-chainsource-interface = "0.3" chia-bls = "0.36.1" chia-protocol = "0.36.1" chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +# The CAT puzzle-hash currying, for deriving the coin a mirror RECLAIM creates before it exists. +# Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned +# coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. +chia-puzzle-types = "0.36.1" # `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the # signer can refuse spends that are not its own wallet's rather than trusting the call site to have # passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 87e3c618..e5575633 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -411,6 +411,8 @@ pub fn publish(snapshot: &BondSnapshot, report: &PassReport, epoch: i64) { /// is NOT applied here — [`super::runner::split_by_provenance`] owns it, and applying it early would /// put §25.1's exclusion in a second place. pub async fn observe_disk(node: &Node) -> Result, PassError> { + use dig_node_core::CapsuleStore as _; + let cached = node .cache_list_cached() .await @@ -440,6 +442,17 @@ pub async fn observe_dig_balance( .ok_or_else(|| PassError::Wallet("the operator wallet's $DIG balance is unreadable".into())) } +/// Wall-clock milliseconds, for §25.5's presence window. +/// +/// Named here rather than inlined so the ONE clock a pass reads is a named step. A pass that read +/// the clock twice could debounce against one instant and price against another. +pub fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + /// A [`SpendJournal`] over the machine-wide audit log. pub fn journal() -> SpendJournal { SpendJournal::new(SpendLog::in_state_dir()) diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 6199e747..a54c048f 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -203,6 +203,24 @@ impl PassRunner { } } + /// Adopt an existing presence tracker, so §25.5's debounce survives across runners. + /// + /// A production scheduler rebuilds its [`MirrorEffects`] every round — the chain source is + /// per-round and does not outlive it — so the runner is rebuilt too. Without this, each round + /// would begin with a FRESH tracker, and a fresh tracker suppresses every capsule it has ever + /// seen exactly once: no bond would ever settle, and the node would never create a coin while + /// looking like it was reconciling normally. Carrying the tracker is what makes the debounce a + /// window in wall-clock time rather than a window per runner. + pub fn with_presence(mut self, presence: super::presence::PresenceTracker) -> Self { + self.presence = presence; + self + } + + /// Hand the presence tracker back, for the next round's runner. + pub fn into_presence(self) -> super::presence::PresenceTracker { + self.presence + } + /// Use a non-default settling window (§25.5). pub fn with_settling_window_ms(mut self, window_ms: u64) -> Self { self.settling_window_ms = window_ms; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 66fd2496..26bb52fd 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -119,6 +119,12 @@ pub struct AppState { /// re-implemented — it is already a per-[`RequestorId`] token-bucket registry with the /// identity-cycling table bound this needs. control_ingress: Arc, + /// §25.8's bond observation, as the last mirror pass published it (dig-node#412 step 7). + /// + /// Held on the shared state rather than rebuilt per request precisely so the control surface + /// cannot be made to do chain work by asking: the mirror pass observes on its own round timer + /// and writes here, and `control.mirror.bondStates` only ever reads. + mirror_bonds: crate::mirror::lifecycle::BondSnapshot, } /// Per-source burst for OPEN control reads (dig_ecosystem#3051): how many token-less reads one @@ -588,6 +594,7 @@ pub async fn build_state(config: &Config) -> AppState { wallet: wallet_service.backend, wallet_cert: wallet_service.cert, wallet_chain: wallet_service.chain, + mirror_bonds: crate::mirror::lifecycle::new_snapshot(), control_ingress: Arc::new(dig_node_core::rate_limit::MissRateLimiter::new( CONTROL_INGRESS_BURST, CONTROL_INGRESS_REFILL_PER_SEC, @@ -669,6 +676,7 @@ fn control_ctx(state: &AppState) -> ControlCtx { sync_available: state.sync_available, pairings: state.pairings.clone(), wallet: state.wallet.clone(), + mirror_bonds: state.mirror_bonds.clone(), } } @@ -2125,6 +2133,13 @@ where // precisely so nothing dials. if config.enable_chain_sync { spawn_collateral_census(state.wallet_chain.clone()); + spawn_mirror_passes( + state.node.clone(), + state.wallet.clone(), + state.wallet_chain.clone(), + state.mirror_bonds.clone(), + config.enable_live_broadcast, + ); } // §14 autonomous sync (#213): bring up the L7 peer network — the connected peer @@ -2632,6 +2647,181 @@ pub(crate) fn log_census_observation(observed: &crate::collateral_census::Census const COLLATERAL_CENSUS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(dig_constants::MIRROR_ROUND_LENGTH_MS as u64); +/// The interval between mirror reconcile passes — §25.4's round. +/// +/// The SAME constant the collateral census uses, and deliberately so: a pass prices its creates from +/// the epoch record the census writes, so a mirror round that ran faster than the census would keep +/// re-deriving an answer from a record that had not moved. +const MIRROR_PASS_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(dig_constants::MIRROR_ROUND_LENGTH_MS as u64); + +/// Run the §25 mirror-coin reconcile pass on the round timer, and publish what each pass observed. +/// +/// Detached and best-effort, exactly like the collateral census beside it: a node that cannot +/// observe still serves content, and every outcome is logged rather than swallowed. +/// +/// # The operator wallet is opened ONCE, here, and never again on a read path +/// +/// [`crate::mirror::lifecycle::open_signer`] unseals the §16.4 seed under the device key a single +/// time at bring-up. The public puzzle hash it yields is held for the life of the task and used by +/// every pass, so no request — and no later edit to a request path — can cause a second unseal. A +/// `Locked` or `Orphaned` wallet yields no signer, and the lifecycle then OBSERVES without spending +/// rather than degrading into a node that reports nothing. +/// +/// # Every pass re-reads everything; nothing is carried between rounds but the presence tracker +/// +/// The epoch, the requirement and the margin are read per pass for the same reason the census reads +/// its target per pass: this task outlives an epoch boundary, and a value captured at start-up would +/// leave the node permanently one epoch behind from the moment the schedule rolled over. The +/// [`PassRunner`](crate::mirror::runner::PassRunner) is long-lived only because §25.5's presence +/// debounce is, by definition, memory between rounds — so it is built once, outside the loop. +fn spawn_mirror_passes( + node: Arc, + wallet: Arc, + chain: Arc, + snapshot: crate::mirror::lifecycle::BondSnapshot, + live_broadcast: bool, +) { + use crate::collateral::{current_epoch_now, CurrentEpoch, EpochRecordStore}; + use crate::mirror::lifecycle::{self, NodeMirrorEffects, SpendCapability}; + use crate::mirror::runner::{PassContext, PassRunner}; + + tokio::spawn(async move { + let paths = dig_wallet::autoseed::default_paths(); + let (signer, capability) = lifecycle::open_signer(&paths, live_broadcast); + + // The owner puzzle hash comes from the SIGNER when there is one, so the key a spend is built + // for and the address its bonds are observed under cannot be two different values. Without a + // signer it is derived on its own — a public value, and the observation half needs it even + // when nothing may spend. + let Some(owner_puzzle_hash) = signer + .as_ref() + .map(|s| s.owner_puzzle_hash()) + .or_else(|| dig_wallet::operator_wallet::operator_puzzle_hash(&paths)) + else { + tracing::warn!( + target: "mirror", + capability = ?capability, + "no operator wallet is available, so this node cannot observe or bond its own \ + capsules; SPEC.md §25.8 reports unknown until one exists" + ); + return; + }; + + match capability { + SpendCapability::Available => tracing::info!( + target: "mirror", + "the mirror lifecycle is live: this node may create and reclaim collateral" + ), + SpendCapability::BroadcastDisabled => tracing::info!( + target: "mirror", + "the mirror lifecycle OBSERVES only: DIG_WALLET_ENABLE_LIVE_BROADCAST is off, the \ + money-safe default, so no mirror spend is sent" + ), + SpendCapability::WalletUnavailable => tracing::warn!( + target: "mirror", + "the mirror lifecycle OBSERVES only: the operator wallet (SPEC.md §16.4) did not open" + ), + } + + let journal = lifecycle::journal(); + let mut presence = crate::mirror::presence::PresenceTracker::new(); + + loop { + let epoch = match current_epoch_now() { + CurrentEpoch::Final(epoch) => epoch as i64, + // No epoch in force means no requirement and no amount, so a pass could plan + // nothing. Waiting is the whole action. + _ => { + tokio::time::sleep(MIRROR_PASS_INTERVAL).await; + continue; + } + }; + let requirement = crate::collateral::requirement( + &EpochRecordStore::in_state_dir(), + current_epoch_now(), + ); + let config = crate::collateral::CollateralConfig::load(); + + // The two asynchronous readings, taken BEFORE the pass so the pass itself is + // synchronous and sees one disk state and one balance throughout. + let capsules = lifecycle::observe_disk(&node).await; + let dig_balance = lifecycle::observe_dig_balance(&wallet, owner_puzzle_hash).await; + + match chain.chain_source(tokio::runtime::Handle::current()).await { + Ok(source) => { + let runtime = tokio::runtime::Handle::current(); + let signer_ref = signer.as_ref(); + let ctx = PassContext { + now_unix_ms: lifecycle::now_unix_ms(), + current_epoch: epoch, + requirement, + margin_bp: config.margin_bp, + creates_enabled: config.mirror_enabled, + }; + // `block_in_place` rather than `spawn_blocking`: the runner borrows the signer, + // the journal and the chain source, none of which is `'static`, and moving them + // into a task would mean opening the wallet per pass — the second unseal route + // this design exists to remove. + let outcome = tokio::task::block_in_place(|| { + let effects = NodeMirrorEffects::new( + capsules, + dig_balance, + &source, + owner_puzzle_hash, + signer_ref, + &journal, + None, + runtime, + ); + let mut pass = PassRunner::new( + effects, + crate::spend_audit::SpendLog::in_state_dir(), + ) + .with_presence(std::mem::take(&mut presence)); + let report = pass.run(&ctx); + (report, pass.into_presence()) + }); + let (outcome, carried) = outcome; + presence = carried; + + match outcome { + Ok(report) => { + lifecycle::publish(&snapshot, &report, epoch); + tracing::debug!( + target: "mirror", + epoch, + bonds = report.states.len(), + locked_dig_base_units = report.locked_dig_base_units, + reclaimed = report.reclaimed.len(), + created = report.created.len(), + "mirror pass complete" + ); + } + // NOT published. An observation that failed is not a smaller observation: + // publishing an empty one would tell an operator this node holds no bonds + // and locks no money, which is a definite claim it is in no position to + // make. The surface keeps saying `unknown`, and any previous pass's answer + // is left in place rather than replaced by a worse one. + Err(e) => tracing::warn!( + target: "mirror", + error = %e, + "the mirror pass could not observe; SPEC.md §25.8 keeps its previous answer" + ), + } + } + Err(e) => tracing::warn!( + target: "mirror", + error = %e, + "no chain source for the mirror pass this round" + ), + } + + tokio::time::sleep(MIRROR_PASS_INTERVAL).await; + } + }); +} + /// Run the collateral census on a timer, against the node's own chain transport. /// /// Detached and best-effort, exactly like the record bring-up above: a node that cannot census From fea4861147465fac3632707ac81295f4d79f9cf2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:24:25 -0700 Subject: [PATCH 10/19] feat(mirror): bring the lifecycle up, and state in SPEC.md what now answers (#412 step 7) --- SPEC.md | 56 +++++--- crates/dig-node-service/src/control.rs | 24 +++- .../dig-node-service/src/mirror/lifecycle.rs | 128 +++++++++++++++--- crates/dig-node-service/src/server.rs | 8 +- 4 files changed, 165 insertions(+), 51 deletions(-) diff --git a/SPEC.md b/SPEC.md index 1bba7389..5db01b11 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7940,20 +7940,32 @@ itself (SYSTEM.md §4.1). > * §25.8's **method and verb**: `control.mirror.bondStates` is served (`control.rs`) and > `dign mirror bond-states` is the verb. The wire mapping, the whole-set locked total, the > canonical-key ordering and the paging are `mirror/states.rs`; `BondState::FundsUnknown` maps to -> `deferred { balance_unreadable }` per row, never to `unfunded`. **What is not present is an -> OBSERVATION**: no pass is constructed, so the node cannot read its own mirror coins and the -> method answers `unknown { chain_unreadable }` on every call. The shape is satisfied; the answer -> is not. +> `deferred { balance_unreadable }` per row, never to `unfunded`. The method now ANSWERS: it +> serves the observation the last pass published (`mirror/lifecycle.rs`), and `unknown +> { chain_unreadable }` remains only for a node whose first pass has not completed. +> * A **production `MirrorEffects`** (`mirror::lifecycle::NodeMirrorEffects`) and a SCHEDULED pass +> (`server::spawn_mirror_passes`, on `dig_constants::MIRROR_ROUND_LENGTH_MS`). The operator wallet +> is opened ONCE at bring-up under the device key; a §16.4 `Locked` or `Orphaned` wallet yields no +> signer, and the lifecycle then OBSERVES without spending rather than degrading. `dig_mirror_coin::list` +> is called, and an INCOMPLETE inventory (`MAX_CANDIDATES` truncation, unresolved candidates) +> aborts the pass rather than under-reporting locked money. > -> **Everything else in §25 is PENDING**, tracked as -> . In particular **no pass is CONSTRUCTED and -> none is SCHEDULED**: `mirror::runner::MirrorEffects` has no implementation, so nothing reaches -> `MirrorSigner::new`, `build_create`, `build_reclaim` or `dig_mirror_coin::list`, and the three -> triggers in §25.4 — start-up, the round tick, the debounced presence change — do not fire. The -> runner decides and orders; nothing yet hands it a chain, a wallet or a disk. So a reader MUST NOT -> infer that any coin is created, reclaimed, broadcast or confirmed at this head, and MUST NOT infer -> that any state is REPORTED: §25.8's method is served, and it answers -> `unknown { chain_unreadable }` because the observation it would describe does not exist. +> **Two things in §25 remain PENDING**, tracked as +> : +> +> * **CREATES are refused, by name.** `dig_mirror_coin::create` takes its `Vec` from the +> caller, and this node has no $DIG coin selector scoped to the OPERATOR puzzle hash — the +> node-custodied selector reads a different wallet's coins. `NodeMirrorEffects::create` therefore +> returns a named error, the pass reports it, and §25.8 keeps reporting the bond as uncovered, +> which is true. Tracked as . **RECLAIMS are +> implemented** and are supported at `fee = 0` with no fee coins, which is §25.4.4. +> * **§25.6's DHT pointer is not attached.** `ProviderRecord::unverified_mirror_coin_id` lives in +> dig-dht 0.15, and `dig-download` 0.21.0 and `dig-peer-selector` 0.10.0 both require +> `dig-dht ^0.13` — semver-incompatible on a `0.x` line, so taking 0.15 here would resolve two +> dig-dht lines. Tracked as . +> +> So a reader MUST NOT infer that any coin is CREATED at this head, and MUST NOT infer that a mirror +> coin id reaches the DHT. > > **A clause not named in the list above MUST be read as pending, whatever its grammatical voice**, > and the list is to be read NARROWLY: where an entry names a file or a function, it satisfies the @@ -8242,12 +8254,18 @@ one setting to turn off** (§6.0/#207). ### 25.8. The per-store state surface -> **PARTIALLY IMPLEMENTED.** The method `control.mirror.bondStates` IS served and `dign mirror -> bond-states` IS the verb (§8.6 CLI parity). What is NOT yet present is an OBSERVATION for it to -> describe: nothing constructs a reconcile pass, so the node cannot read the mirror coins it owns -> and every call answers `unknown { reason: "chain_unreadable" }`. A reader MUST NOT infer that any -> bond state is REPORTED at this head; it may rely on the shape, the paging, the ordering and the -> refusals described below. Tracked as dig-node#412 step 7. +> **IMPLEMENTED.** The method `control.mirror.bondStates` is served, `dign mirror bond-states` is +> the verb (§8.6 CLI parity), and it now ANSWERS: the mirror pass observes on its own round timer +> and publishes what it saw, and this method serves that observation. +> +> The answer is a published SNAPSHOT rather than a read this call performs, and that is a security +> property rather than a cache. Observing per request would turn one token-gated call into an +> operator-seed unseal, a PBKDF2, up to `dig_mirror_coin::MAX_CANDIDATES` chain lookups and an +> oracle read — a real amplification surface, since a paired token is a much weaker predicate than +> "trusted". A node whose first pass has not yet completed answers +> `unknown { reason: "chain_unreadable" }`, which remains the honest answer and is never an empty +> page. A bond whose create is refused for want of an operator-scoped $DIG selector +> (dig-node#421) reports as uncovered, which is what it is. The lifecycle exposes, per `(store, root)`, over the control plane and with a `dign` verb (§8.6 CLI parity): the bond state — `bonded { coin_id, epoch, amount }`, `pending` (in-flight create), diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 08b6e1b8..56557da4 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3563,7 +3563,8 @@ fn mirror_bond_observation( .mirror_bonds .read() .map_err(|_| MirrorBondStatesUnknownReason::ChainUnreadable)?; - slot.clone().ok_or(MirrorBondStatesUnknownReason::ChainUnreadable) + slot.clone() + .ok_or(MirrorBondStatesUnknownReason::ChainUnreadable) } /// `control.collateral.margin.get` — the node's local safety margin, in basis points. @@ -3636,7 +3637,9 @@ mod tests { #[test] fn mirror_bond_states_says_which_fact_it_is_missing_rather_than_returning_an_empty_page() { let answer = mirror_bond_states( - Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + Err( + dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable, + ), json!(1), &json!({}), ); @@ -3684,7 +3687,10 @@ mod tests { let answer = mirror_bond_states(Ok(observation), json!(1), &json!({})); let result = &answer["result"]; - assert_eq!(result["state"], "known", "a published observation is KNOWN: {result}"); + assert_eq!( + result["state"], "known", + "a published observation is KNOWN: {result}" + ); assert_eq!( result["locked_dig_base_units"], 7_777, "the observation's own total, carried across unchanged" @@ -3727,7 +3733,9 @@ mod tests { // The control: an in-range limit is not refused, so the assertion above is about the // BOUND rather than about the params being rejected wholesale. let ok = mirror_bond_states( - Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + Err( + dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable, + ), json!(1), &json!({ "limit": 1000 }), ); @@ -3744,7 +3752,9 @@ mod tests { fn mirror_bond_states_refuses_a_malformed_cursor_and_tolerates_a_prefixed_one() { let hex = "11".repeat(32); let malformed = mirror_bond_states( - Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + Err( + dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable, + ), json!(1), &json!({ "after": { "store_id": "not-hex", "root": hex } }), ); @@ -3755,7 +3765,9 @@ mod tests { ); let prefixed = mirror_bond_states( - Err(dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable), + Err( + dig_node_control_interface::results::MirrorBondStatesUnknownReason::ChainUnreadable, + ), json!(1), &json!({ "after": { "store_id": format!("0x{hex}"), "root": hex } }), ); diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index e5575633..8054bcd1 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -43,7 +43,7 @@ //! selector is scoped to its own replica instead, so using it would fund a mirror coin from the //! wrong wallet's coins. [`NodeMirrorEffects::create`] therefore returns a named //! [`PassError::Wallet`], the pass reports it in `stopped_at`, and §25.8 keeps reporting the bond -//! as uncovered — which is true. The selector is dig-node#420. +//! as uncovered — which is true. The selector is dig-node#421. //! //! # Nothing here relaxes the audit shape //! @@ -102,7 +102,11 @@ pub fn new_snapshot() -> BondSnapshot { /// epoch and the requirement, and for the same reason. pub struct NodeMirrorEffects<'a, S: ChainSource> { /// The capsules on disk, WITH provenance, already read. - capsules: Result, PassError>, + /// + /// Not a `Result`: `Node::cache_list_cached` is infallible — it reports the capsules it could + /// read and nothing else — so there is no disk failure for this to carry. `MirrorEffects` keeps + /// the fallible shape because a different implementation may have one. + capsules: Vec, /// Spendable $DIG at the operator address, already read. `Err` defers creates, never reclaims. dig_balance: Result, /// The chain, for the owned-coin scan and for the reclaim spends. @@ -131,7 +135,7 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { /// Assemble the effects for one pass from readings the scheduler has already taken. #[allow(clippy::too_many_arguments)] pub fn new( - capsules: Result, PassError>, + capsules: Vec, dig_balance: Result, source: &'a S, owner_puzzle_hash: Bytes32, @@ -229,7 +233,7 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { impl MirrorEffects for NodeMirrorEffects<'_, S> { fn observe_disk(&self) -> Result, PassError> { - self.capsules.clone() + Ok(self.capsules.clone()) } fn observe_chain(&self) -> Result, PassError> { @@ -296,14 +300,19 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { self.sign_and_broadcast(&spends, Some(reclaimed_coin_id(&coin))) } - fn create(&self, bond: &Bond, _epoch: i64, amount_dig_base_units: u64) -> Result<(), PassError> { + fn create( + &self, + bond: &Bond, + _epoch: i64, + amount_dig_base_units: u64, + ) -> Result<(), PassError> { // REFUSED, by name, rather than funded from the wrong wallet. See the module doc: the only // $DIG selector this process has is scoped to the node-custodied replica, not to the // operator puzzle hash, and a mirror coin funded from the wrong coins is a real spend that - // looks successful. dig-node#420 is the operator-scoped selector. + // looks successful. dig-node#421 is the operator-scoped selector. Err(PassError::Wallet(format!( "creating the {} bond needs {} DIG base units selected from the OPERATOR wallet, and \ - this node has no operator-scoped $DIG coin selector yet (dig-node#420); no spend was \ + this node has no operator-scoped $DIG coin selector yet (dig-node#421); no spend was \ attempted", bond.store_id, amount_dig_base_units ))) @@ -334,11 +343,8 @@ fn reclaimed_coin_id(mirror: &MirrorCoin) -> TargetCoinId { let puzzle_hash: Bytes32 = CatArgs::curry_tree_hash(dig_mirror_coin::DIG_ASSET_ID, inner).into(); - let created = chia_protocol::Coin::new( - mirror.coin().coin_id(), - puzzle_hash, - mirror.collateral(), - ); + let created = + chia_protocol::Coin::new(mirror.coin().coin_id(), puzzle_hash, mirror.collateral()); TargetCoinId(hex::encode(created.coin_id())) } @@ -368,7 +374,10 @@ impl SpendCapability { /// [`OperatorWallet::open`] returns `None` for BOTH §16.4 `Locked` and `Orphaned`, which is the /// behaviour this wants: neither state has a key it would be correct to substitute, so the honest /// outcome is the same in both — no signer, and a lifecycle that observes without spending. -pub fn open_signer(paths: &WalletPaths, live_broadcast: bool) -> (Option, SpendCapability) { +pub fn open_signer( + paths: &WalletPaths, + live_broadcast: bool, +) -> (Option, SpendCapability) { let Some(wallet) = OperatorWallet::open(paths, dig_constants::DIG_MAINNET.genesis_challenge()) else { return (None, SpendCapability::WalletUnavailable); @@ -410,20 +419,17 @@ pub fn publish(snapshot: &BondSnapshot, report: &PassReport, epoch: i64) { /// Asynchronous, so it is taken by the scheduler before the pass begins. The `Held`/`Relayed` split /// is NOT applied here — [`super::runner::split_by_provenance`] owns it, and applying it early would /// put §25.1's exclusion in a second place. -pub async fn observe_disk(node: &Node) -> Result, PassError> { +pub async fn observe_disk(node: &Node) -> Vec { use dig_node_core::CapsuleStore as _; - let cached = node - .cache_list_cached() + node.cache_list_cached() .await - .map_err(|e| PassError::Disk(e.to_string()))?; - Ok(cached .into_iter() .map(|capsule| ObservedCapsule { bond: Bond::new(capsule.store_id, capsule.root), provenance: capsule.provenance, }) - .collect()) + .collect() } /// Read spendable $DIG at the OPERATOR address. @@ -483,6 +489,79 @@ mod tests { assert!(!capability.may_spend()); } + /// The needles the guard below searches for, ASSEMBLED rather than written. + /// + /// Spelled with `concat!` because a guard that searches its own file for a literal finds that + /// literal in itself. The first version of this test failed on its own fixture — which is a + /// pleasing proof that the search works, and useless as a standing guard. `concat!` runs at + /// compile time, so the source carries only the fragments and the guard sees only real calls. + fn forbidden_installations() -> [&'static str; 2] { + [ + concat!(".with_", "signer("), + concat!(".with_", "broadcaster("), + ] + } + + /// The mirror signer NEVER reaches the served wallet backend. + /// + /// Asserted STRUCTURALLY, over the crate's own source, because the property is about something + /// that must not exist rather than about a value: `WalletBackend`'s signer field is private and + /// `with_signer` is its only door, so proving the door is never opened proves + /// `current_signer()` still answers `None` for every caller of the served backend — which a + /// runtime assertion from this crate cannot reach, the accessor being private too. + /// + /// **Catches** the exact regression bring-up invites: attaching the operator signer to the + /// shared backend "so the wallet can spend too". That would enable that backend's whole signing + /// surface — including DEFAULT-ON auto-tipping — as a side effect of collateralising capsules, + /// an unreviewed behaviour change on a money path that no other mirror test would fail on. + /// + /// Both spellings are checked because the two halves are separately dangerous: a signer makes + /// the backend sign, and a broadcaster makes it send. Requiring the opening parenthesis is what + /// keeps prose — `signer.rs`'s module doc NAMES `WalletBackend::with_signer` — from satisfying + /// or breaking the guard. + #[test] + fn no_signer_or_broadcaster_is_ever_installed_on_the_served_wallet_backend() { + for (name, source) in [ + ("lifecycle.rs", include_str!("lifecycle.rs")), + ("signer.rs", include_str!("signer.rs")), + ("../server.rs", include_str!("../server.rs")), + ] { + for forbidden in forbidden_installations() { + assert!( + !source.contains(forbidden), + "{name} calls {forbidden} — the operator signer must stay inside the mirror lifecycle, never installed on the shared WalletBackend" + ); + } + } + } + + /// The guard is looking at real text and WOULD fail if the call appeared. + /// + /// Two failure modes it removes, both of which leave a green guard proving nothing: a needle + /// that matches no possible call, and an `include_str!` pointing at a file that does not contain + /// the code being guarded. The second is asserted by requiring a string every guarded file + /// genuinely contains, so a path typo is a failure rather than an empty search. + #[test] + fn the_installation_guard_can_actually_fail() { + for needle in forbidden_installations() { + let planted = format!("let _ = backend{needle}s);"); + assert!( + planted.contains(needle), + "{needle} is the spelling a real installation would write" + ); + } + for (name, source) in [ + ("lifecycle.rs", include_str!("lifecycle.rs")), + ("signer.rs", include_str!("signer.rs")), + ("../server.rs", include_str!("../server.rs")), + ] { + assert!( + source.contains("WalletBackend"), + "{name} must be the file that could install a signer; an empty or wrong include makes the guard pass forever" + ); + } + } + /// `publish` carries the report's own figures across, and does not recompute either. /// /// The fixture's `locked_dig_base_units` is deliberately INCONSISTENT with its `states` — a @@ -499,14 +578,21 @@ mod tests { created: Vec::new(), reclaim_failures: Vec::new(), stopped_at: None, - states: vec![(Bond::new("aa".repeat(32), "11".repeat(32)), BondState::Withheld)], + states: vec![( + Bond::new("aa".repeat(32), "11".repeat(32)), + BondState::Withheld, + )], per_coin_dig_base_units: None, locked_dig_base_units: 4_242, }; publish(&snapshot, &report, 9); - let published = snapshot.read().expect("unpoisoned").clone().expect("published"); + let published = snapshot + .read() + .expect("unpoisoned") + .clone() + .expect("published"); assert_eq!( published.locked_dig_base_units, 4_242, "the report's own total, not a sum over the rows" diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 26bb52fd..a9a094ff 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2774,11 +2774,9 @@ fn spawn_mirror_passes( None, runtime, ); - let mut pass = PassRunner::new( - effects, - crate::spend_audit::SpendLog::in_state_dir(), - ) - .with_presence(std::mem::take(&mut presence)); + let mut pass = + PassRunner::new(effects, crate::spend_audit::SpendLog::in_state_dir()) + .with_presence(std::mem::take(&mut presence)); let report = pass.run(&ctx); (report, pass.into_presence()) }); From 403a0718775f1057ae8402c43e61a416e0e525cb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:26:20 -0700 Subject: [PATCH 11/19] feat(collateral): let the buffer advice read the same observation bondStates serves (#412) --- crates/dig-node-service/src/control.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 56557da4..9f00857e 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -937,7 +937,7 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.collateral.requirement" => collateral_requirement(id), "control.collateral.margin.get" => collateral_margin_get(id), "control.collateral.margin.set" => collateral_margin_set(id, params), - "control.collateral.buffer" => collateral_buffer(id), + "control.collateral.buffer" => collateral_buffer(mirror_bond_observation(ctx).ok(), id), // The mirror-coin bond surface (dig-node#412, SPEC.md §25.8) -- one page of this // node's OWN bonds. Shell-owned because the bonds are the SERVICE's money, not the // embedded engine's. @@ -3451,18 +3451,25 @@ fn collateral_requirement(id: Value) -> Value { /// (dig-node#387), and the operator's spendable balance is not a fact this node holds — it cannot /// know which address holds their $DIG, and a balance read of the wrong address returns a confident /// number about the wrong money. A zero would read as "no buffer needed" and have them post nothing. -fn collateral_buffer(id: Value) -> Value { +fn collateral_buffer( + observation: Option, + id: Value, +) -> Value { let store = crate::collateral::EpochRecordStore::in_state_dir(); let requirement = crate::collateral::requirement(&store, current_collateral_epoch()); let margin_bp = crate::collateral::CollateralConfig::load().margin_bp; let answer = crate::collateral::buffer_advice( - // The served set and the spendable balance are both genuinely unknown to the node today, so - // each is passed as `None` and reported through its own reason. They are NOT approximated - // from the hosted-store list or from an arbitrary address: a set that merely resembles the - // served pairs, or a balance for the wrong address, yields a plausible wrong number on a - // money surface — which is worse than no number. - None, + // The served set comes from the SAME published observation `control.mirror.bondStates` + // serves (dig-node#412 step 7), so the two surfaces cannot disagree about how many pairs + // this node serves. Before that observation existed both answered `unknown`, and this one + // still does when the first pass has not completed: `None` here is the absence of an + // observation, never a served set of zero. + // + // It is the count of BOND ROWS, which is the served set — held AND relayed — and it is not + // approximated from the hosted-store list: a set that merely resembles the served pairs + // yields a plausible wrong number on a money surface, which is worse than no number. + observation.map(|o| o.states.len() as u64), &requirement, margin_bp, None, From 96b364956da87f28a65c3e771598fbaf28f42003 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:27:21 -0700 Subject: [PATCH 12/19] fix(wallet): the synthetic public key is Copy, so hand it back without a clone --- crates/dig-wallet/src/operator_wallet.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index 281bc79a..5363b49c 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -96,7 +96,7 @@ impl OperatorWallet { /// secret the signer holds, so the key a spend is BUILT for and the key it is SIGNED with cannot /// be two different keys. pub fn synthetic_key(&self) -> chia_bls::PublicKey { - self.synthetic_key.clone() + self.synthetic_key } /// The signer for this wallet's keys. From ab6b25a128861171d16b28ccaab352d3221495c2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:32:20 -0700 Subject: [PATCH 13/19] test(mirror): assert the bond row at its FLATTENED wire spelling, which is what clients parse --- crates/dig-node-service/src/control.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 9f00857e..a2765b9a 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3711,11 +3711,19 @@ mod tests { // the uncovered one reports `deferred{balance_unreadable}` rather than a fabricated // shortfall. A count-only assertion is satisfied by an implementation that emits one state // for every row. + // + // The state is FLATTENED into the row under the `bond_state` tag — one object carrying + // `store_id`, `root`, `bond_state` and that state's own payload, never a nested envelope. + // Asserted at the flattened spelling because that is the shape dig-app#289 and #300 parse; + // an assertion against a nested one would pass on a producer no client can read. assert_eq!(entries[0]["store_id"], "aa".repeat(32)); - assert_eq!(entries[0]["state"]["state"], "withheld"); + assert_eq!(entries[0]["bond_state"], "withheld"); assert_eq!(entries[1]["store_id"], "bb".repeat(32)); - assert_eq!(entries[1]["state"]["state"], "deferred"); - assert_eq!(entries[1]["state"]["reason"], "balance_unreadable"); + assert_eq!(entries[1]["bond_state"], "deferred"); + assert_eq!( + entries[1]["reason"], "balance_unreadable", + "an unreadable wallet is DEFERRED with its reason, never a fabricated shortfall" + ); } /// **Proves:** an out-of-range page size is REFUSED as `INVALID_PARAMS`, not clamped. From 9a5d39247ecf33a188f05da464c8c48076928288 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:58:32 -0700 Subject: [PATCH 14/19] test(mirror): prove the presence tracker carries between runners (#412) --- crates/dig-node-service/src/mirror/runner.rs | 166 +++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index a54c048f..bbf51e5d 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -586,6 +586,91 @@ mod tests { PassRunner::new(effects, log).with_settling_window_ms(0) } + /// The presence tracker CARRIES between runners, and a fresh one on the second pass suppresses. + /// + /// **Proves** the reason [`PassRunner::with_presence`] exists. The production scheduler rebuilds + /// its [`MirrorEffects`] every round — the chain source is per-round and does not outlive it — + /// so the runner is rebuilt too. §25.5's window is wall-clock, not per-runner, and a fresh + /// tracker restarts every bond's window at the moment it is built. + /// + /// **Catches** the regression that drops the carry from the scheduler. It compiles, it passes + /// every other test, and it produces a node that never settles a bond and never creates a coin + /// **while looking like it is reconciling normally** — the runner's own doc says the failure is + /// invisible on every other signal, which is exactly why it needs a test naming it. + /// + /// The two halves are asserted as a PAIR, and only the pair discriminates. The carried run + /// alone is satisfied by an implementation with no debounce at all — one that settles + /// everything immediately would pass it — so the fresh-tracker half is what proves the window + /// is real and that the first half's success came from the CARRY rather than from its absence. + #[test] + fn the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses() { + use super::super::presence::SETTLING_WINDOW_MS; + + const FIRST_SEEN_MS: u64 = 1_000_000; + let capsule = bond("aa", "11"); + + // A pass whose ONLY reason not to create is the debounce: the wallet is funded, the + // requirement is known, creates are on, and nothing is on chain. So a create appearing or + // not appearing is a statement about the presence window and about nothing else. + let effects = || FakeEffects { + disk: held(&[capsule.clone()]), + balance: REQUIRED * 10, + ..FakeEffects::default() + }; + let at = |now_unix_ms| PassContext { + now_unix_ms, + ..ctx() + }; + let created = |report: &PassReport| report.created.clone(); + + // A real, EMPTY audit log per runner. Empty matters: an in-flight create recorded for this + // (store, root, epoch) would suppress the create through §25.4.6 instead, and the test + // would then pass for a reason that has nothing to do with the presence window. + let dir = tempfile::tempdir().expect("a temp dir"); + let log = |tag: &str| SpendLog::at(dir.path().join(format!("{tag}.jsonl"))); + + // Pass 1 — the capsule has just appeared. Nothing settles, so nothing is created. This is + // the control: it shows the window is doing something before the carry is tested at all. + let mut first = PassRunner::new(effects(), log("first")); + let opening = first + .run(&at(FIRST_SEEN_MS)) + .expect("the observation succeeds"); + assert!( + created(&opening).is_empty(), + "a capsule seen for the first time has not settled, so no coin is created: {:?}", + created(&opening) + ); + + // Pass 2, CARRYING the tracker, one full window later. The bond's window began at + // FIRST_SEEN_MS and has now elapsed, so it settles and the create happens. + let mut carried = + PassRunner::new(effects(), log("carried")).with_presence(first.into_presence()); + let settled = carried + .run(&at(FIRST_SEEN_MS + SETTLING_WINDOW_MS)) + .expect("the observation succeeds"); + assert_eq!( + created(&settled), + vec![capsule.clone()], + "the carried tracker remembers when the capsule appeared, so one window later it \ + settles and is bonded: {:?}", + created(&settled) + ); + + // The SAME second pass, at the SAME instant, with a FRESH tracker. The bond's window + // restarts now, so it cannot have elapsed, and nothing is created. This is the regression + // itself, reproduced. + let mut restarted = PassRunner::new(effects(), log("restarted")); + let stalled = restarted + .run(&at(FIRST_SEEN_MS + SETTLING_WINDOW_MS)) + .expect("the observation succeeds"); + assert!( + created(&stalled).is_empty(), + "a fresh tracker restarts the window, so the identical pass creates nothing — this is \ + the silent stall the carry exists to prevent: {:?}", + created(&stalled) + ); + } + /// An audit intent for a mirror create of `(store, root, epoch)`. fn create_intent(store: &str, root: &str, epoch: i64) -> SpendIntent { SpendIntent { @@ -1041,6 +1126,87 @@ mod tests { ); } + /// The presence tracker CARRIED between rounds is what lets a bond ever settle. + /// + /// The scheduler rebuilds its effects — and therefore its runner — every round, because the + /// chain source is per-round and does not outlive it. `with_presence` / `into_presence` are the + /// only thing that survives that rebuild. Drop the carry and every round begins with a fresh + /// tracker; a fresh tracker has never seen the bond before, so it is never stable, so no create + /// is ever made — **while the node logs a completed pass each round and looks like it is + /// reconciling normally**. Nothing else in the suite goes red for that, which is precisely why + /// this test exists. + /// + /// The assertion is a PAIR over the same two passes, because only the pair discriminates. "The + /// second pass creates" alone is satisfied by an implementation with no debounce whatsoever, and + /// "a fresh tracker suppresses" alone is satisfied by an implementation that suppresses forever. + /// Carried settles, fresh does not — that is the property, and it needs both halves. + /// + /// The window is REAL here (`SETTLING_WINDOW_MS`) rather than the zero the shared `runner` + /// helper uses, since a zero window makes every tracker settle immediately and the carry + /// unobservable — the shape of fixture that would let this defect through while reading as + /// thorough. + #[test] + fn dropping_the_presence_carry_between_rounds_silently_stops_every_create() { + use super::super::presence::{PresenceTracker, SETTLING_WINDOW_MS}; + + let dir = tempfile::tempdir().expect("tempdir"); + let (_journal, log) = journal(dir.path()); + + let settling = bond("aa", "11"); + // One round's fixture: the same capsule on disk, funded, nothing on chain yet. + let effects = || FakeEffects { + disk: held(&[settling.clone()]), + chain: Vec::new(), + balance: 10 * REQUIRED, + ..Default::default() + }; + let at = |now_unix_ms: u64| PassContext { + now_unix_ms, + ..ctx() + }; + + const FIRST: u64 = 1_000_000; + let second = FIRST + SETTLING_WINDOW_MS + 1; + + // Round one, from nothing: the bond has just been seen for the first time, so it is not yet + // stable and buys nothing. This is the state the carry has to transport. + let mut round_one = PassRunner::new(effects(), log.clone()) + .with_settling_window_ms(SETTLING_WINDOW_MS) + .with_presence(PresenceTracker::new()); + let first_report = round_one.run(&at(FIRST)).expect("the pass runs"); + assert!( + first_report.created.is_empty(), + "a bond seen once has not been stable for a window, so it must buy nothing yet: {:?}", + first_report.created + ); + let carried = round_one.into_presence(); + + // Round two WITH the carry, a full window later: the bond has now held one state across the + // window, so it settles and the create is made. + let mut carried_round = PassRunner::new(effects(), log.clone()) + .with_settling_window_ms(SETTLING_WINDOW_MS) + .with_presence(carried); + let carried_report = carried_round.run(&at(second)).expect("the pass runs"); + assert_eq!( + carried_report.created, + vec![settling.clone()], + "carrying the tracker is what makes the window wall-clock time rather than per-runner" + ); + + // The SAME round two with a FRESH tracker — the exact regression a dropped + // `.with_presence(...)` produces. Same capsule, same clock, same funds, and nothing settles. + let mut fresh_round = PassRunner::new(effects(), log) + .with_settling_window_ms(SETTLING_WINDOW_MS) + .with_presence(PresenceTracker::new()); + let fresh_report = fresh_round.run(&at(second)).expect("the pass runs"); + assert!( + fresh_report.created.is_empty(), + "a fresh tracker each round re-observes the bond as new forever, so no bond ever \ + settles and no coin is ever created -- with every pass still reporting success: {:?}", + fresh_report.created + ); + } + /// A `Relayed` capsule buys no coin, and is reported as withheld rather than omitted. /// /// This is the one place an attacker influences what the node spends its own money on: a From 8f93bd63db7a042b32ed28021cc3f65e1946eb91 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:02:37 -0700 Subject: [PATCH 15/19] fix(mirror): derive the reported spend capability from the broadcaster the pass actually gets The sole production `NodeMirrorEffects::new` passed `broadcaster: None` unconditionally, so `sign_and_broadcast` short-circuited and no reclaim could reach chain -- while `open_signer` reported `SpendCapability::Available` and bring-up logged "this node may create and reclaim collateral". Two answers to one question, with only one of them on the path the money takes. The refusal compounded it: it named `DIG_WALLET_ENABLE_LIVE_BROADCAST`, the flag an operator must already have set to reach `Available` at all, so a person who set the flag was told to set the flag. The broadcaster is not wired here rather than wired live: `ChiaQueryBroadcaster` needs the `Arc` held behind `ChainTransport::shared_client`, which is `pub(crate)` to dig-wallet, so reaching it is a dig-wallet API decision and a live-mainnet behaviour change -- both outside a gate fix on an observation step. So the reporting is made honest instead, and the wiring has a ticket (dig-node#424). - `production_broadcaster()` is now the ONE seam. `open_signer` derives the capability from it and the scheduler passes the same call into the effects, so the two cannot disagree. - New `SpendCapability::BroadcasterUnwired`, distinct from `BroadcastDisabled`: one is a switch the operator can flip and the other is not. - The refusal names the missing wiring and its ticket, the way `create` names dig-node#421. - `spend_capability` is separated so BOTH branches are testable; the wired branch is unreachable through `open_signer` on this build, and an untestable branch is how the wrong answer shipped. Also, non-gating, from the same review: - The signer-installation guard now scans `control.rs` and `wallet_mtls.rs`, which hold the same served `Arc`. dig-wallet's own construction site stays out of scope with the reason stated: an `include_str!` outside the package would leave the crate unpackageable. - `the_installation_guard_can_actually_fail` no longer interpolates the needle into its own fixture and then asserts the fixture contains it, which held for every needle including a wrong one. It matches each needle against a sample line assembled independently, and asserts a near-miss spelling matches nothing. - `collateral_buffer` counts BONDABLE rows rather than every served row. A `Withheld` row is relayed on a stranger's behalf and locks nothing, and the count feeds `one_epoch_lock` -- an amount of $DIG the operator must have available to LOCK. Latent today (the balance is still `None`), a wrong money figure the moment it is wired. Refs #412, #424 --- SPEC.md | 7 + crates/dig-node-service/src/control.rs | 33 ++- .../dig-node-service/src/mirror/lifecycle.rs | 214 +++++++++++++++--- crates/dig-node-service/src/server.rs | 13 +- 4 files changed, 236 insertions(+), 31 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5db01b11..40bd861f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8082,6 +8082,13 @@ address the wallet already tracks. > Nor is the audit-EXECUTION paragraph below satisfied: no entry is ever written for a mirror spend, > no confirmation is observed, and nothing reconciles an `unresolved` or `failed` one. Both are > tracked as . +> +> **A mirror spend is BUILT and not SENT.** The node wires no production broadcaster for this +> lifecycle, so a planned reclaim refuses by name before it signs and no mirror spend reaches the +> mempool — the create half refuses separately, for the coin selector +> (). The refusal is reported rather than +> silent, and the capability the node announces is derived from the same seam, so it cannot claim a +> power it does not have. Tracked as . **Every spend is audited, structurally — exactly ONE entry per signature, and it cannot lie about the spend.** The signer takes the `SpendJournal` (§23.3) and opens the record itself, returning the diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index a2765b9a..2e359e46 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3451,6 +3451,24 @@ fn collateral_requirement(id: Value) -> Value { /// (dig-node#387), and the operator's spendable balance is not a fact this node holds — it cannot /// know which address holds their $DIG, and a balance read of the wrong address returns a confident /// number about the wrong money. A zero would read as "no buffer needed" and have them post nothing. +/// How many of an observation's served pairs this node will actually BOND. +/// +/// Every state except `Withheld` is a pair this node either has collateralised or intends to — +/// `Bonded`, `Pending`, `Unfunded` and `FundsUnknown` all describe a coin that exists or is owed. +/// `Withheld` is the one row that describes a capsule relayed on a stranger's behalf, deliberately +/// never advertised and therefore never bonded, so it locks nothing. +/// +/// Named and separated from [`collateral_buffer`] so the distinction is testable without a state +/// directory: the caller feeds this into an amount of money, and a count that quietly includes rows +/// locking nothing is a wrong figure on a money surface rather than a wrong figure about rows. +fn bondable_pairs(observation: &crate::mirror::states::BondObservation) -> u64 { + observation + .states + .iter() + .filter(|(_, state)| !matches!(state, crate::mirror::pass::BondState::Withheld)) + .count() as u64 +} + fn collateral_buffer( observation: Option, id: Value, @@ -3466,10 +3484,17 @@ fn collateral_buffer( // still does when the first pass has not completed: `None` here is the absence of an // observation, never a served set of zero. // - // It is the count of BOND ROWS, which is the served set — held AND relayed — and it is not - // approximated from the hosted-store list: a set that merely resembles the served pairs - // yields a plausible wrong number on a money surface, which is worse than no number. - observation.map(|o| o.states.len() as u64), + // It counts the BONDABLE rows, not every row. `states` is the SERVED set — held and relayed + // both, so `withheld` has a producer — but a `Withheld` row is relayed on a stranger's + // behalf, is never bonded, and therefore locks nothing. `buffer_advice` feeds this straight + // into `one_epoch_lock`, which is an amount of $DIG the operator must have available to + // LOCK, so counting rows that lock nothing advises a buffer for money that will never be + // spent. The parameter is `pairs_served_by_this_node`; the arithmetic is about the pairs + // this node will BOND, and where those two readings diverge the arithmetic wins. + // + // It is not approximated from the hosted-store list: a set that merely resembles the served + // pairs yields a plausible wrong number on a money surface, which is worse than no number. + observation.as_ref().map(bondable_pairs), &requirement, margin_bp, None, diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 8054bcd1..c1eee0a2 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -32,10 +32,18 @@ //! //! # What this node can and cannot do with money today //! -//! **Reclaims work.** `dig_mirror_coin::reclaim` recreates the full locked amount at the owner's own -//! puzzle hash and is supported at `fee = 0`, which needs no fee coins at all — so a node whose XCH -//! is exhausted can still recover $DIG it has locked. That is §25.4.4, and it is the invariant that -//! matters most, because its failure mode is collateral locked forever. +//! **Reclaims are BUILT but not yet SENT.** `dig_mirror_coin::reclaim` recreates the full locked +//! amount at the owner's own puzzle hash and is supported at `fee = 0`, which needs no fee coins at +//! all — so a node whose XCH is exhausted can still recover $DIG it has locked. That is §25.4.4, and +//! it is the invariant that matters most, because its failure mode is collateral locked forever. +//! +//! The spend is complete; the WIRING is not. This node attaches no production [`Broadcaster`] yet +//! ([`production_broadcaster`] is `None`, dig-node#424), so a planned reclaim refuses by name before +//! it signs, exactly as a create refuses for dig-node#421. Both refusals are reported, both name +//! their missing piece, and neither is a guess dressed as a spend. The reported +//! [`SpendCapability`] is DERIVED from the same seam, so the node cannot announce a power it does +//! not have: while the broadcaster is absent the capability is +//! [`SpendCapability::BroadcasterUnwired`] and never `Available`. //! //! **Creates do not, yet, and they refuse rather than guess.** `dig_mirror_coin::create` takes the //! `Cat` inputs from its caller, and selecting them requires a $DIG coin selector scoped to the @@ -173,10 +181,15 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { let signer = self .signer .ok_or_else(|| PassError::Wallet("no operator wallet is available to sign".into()))?; + // Named the way `create` names dig-node#421: at the point this refusal is reachable, the + // operator has ALREADY set `DIG_WALLET_ENABLE_LIVE_BROADCAST` — `open_signer` yields no + // signer without it, and the signer is checked first — so blaming that flag would tell a + // person to set the flag they just set. What is missing is the wiring, and the wiring has a + // ticket. let broadcaster = self.broadcaster.ok_or_else(|| { PassError::Wallet( - "live broadcast is disabled (DIG_WALLET_ENABLE_LIVE_BROADCAST), so no mirror spend \ - is sent" + "this node has no production broadcaster wired for the mirror lifecycle \ + (dig-node#424), so the spend was built and then NOT sent; nothing was signed" .into(), ) })?; @@ -354,12 +367,21 @@ fn reclaimed_coin_id(mirror: &MirrorCoin) -> TargetCoinId { /// wallet available at all" is answered once, at the place that can say why. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpendCapability { - /// An operator wallet opened and live broadcast is enabled: this node may create and reclaim. + /// An operator wallet opened, live broadcast is enabled, AND a broadcaster is wired: this node + /// may create and reclaim. Available, /// The §16.4 wallet did not open — absent, `Locked`, or `Orphaned`. Observation continues. WalletUnavailable, /// A wallet opened but `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off, the money-safe default. BroadcastDisabled, + /// Everything the OPERATOR controls is in place — a wallet opened and live broadcast is on — but + /// this build wires no production [`Broadcaster`] (dig-node#424), so a spend can be built and + /// signed for and still reach nothing. + /// + /// Distinguished from [`Self::BroadcastDisabled`] because the two ask different things of the + /// reader: one is a switch they can flip, and this one is not. Reporting a missing wiring as a + /// disabled flag sends an operator to set a flag that is already set. + BroadcasterUnwired, } impl SpendCapability { @@ -388,7 +410,41 @@ pub fn open_signer( // which are different things for an operator to fix. return (None, SpendCapability::BroadcastDisabled); } - (Some(MirrorSigner::new(wallet)), SpendCapability::Available) + // The signer is yielded either way: the owner puzzle hash the OBSERVATION half needs comes from + // it, and no spend can escape on the back of it, because `sign_and_broadcast` checks the + // broadcaster before it signs. + ( + Some(MirrorSigner::new(wallet)), + spend_capability(production_broadcaster().is_some()), + ) +} + +/// What an OPENED wallet with live broadcast on can actually do, given whether a broadcaster exists. +/// +/// Separated from [`open_signer`] so the decision is reachable from a test on both branches: the +/// wired branch cannot be exercised through `open_signer` on a build where +/// [`production_broadcaster`] is `None`, and an untestable branch is how the previous version came +/// to report `Available` on a node that could not send. +pub fn spend_capability(broadcaster_wired: bool) -> SpendCapability { + if broadcaster_wired { + SpendCapability::Available + } else { + SpendCapability::BroadcasterUnwired + } +} + +/// The [`Broadcaster`] this build attaches to the mirror lifecycle — `None` until dig-node#424. +/// +/// ONE seam, read by both the reported capability ([`open_signer`]) and the effects the scheduler +/// builds, precisely so the two cannot disagree. The alternative — a capability computed from the +/// environment and a broadcaster passed separately at the construction site — is what let this node +/// log "this node may create and reclaim collateral" while every reclaim refused: two answers to one +/// question, and only one of them on the path the money takes. +/// +/// `&'static` because the honest answer is a property of the build rather than of a request, and +/// because a static coerces into the shorter lifetime `NodeMirrorEffects` borrows for. +pub fn production_broadcaster() -> Option<&'static dyn Broadcaster> { + None } /// Publish what a pass observed, so `control.mirror.bondStates` can answer from it. @@ -502,6 +558,101 @@ mod tests { ] } + /// `Available` and a wired broadcaster are the SAME fact, so the node cannot announce a power a + /// spend does not have. + /// + /// This is the assertion the earlier wiring had no room for. `open_signer` reported `Available` + /// from the environment while the scheduler built its effects with a hard-coded `None`, so the + /// bring-up log said "this node may create and reclaim collateral" and every reclaim refused — + /// two answers to one question, with only one of them on the path the money takes. Deriving both + /// from [`production_broadcaster`] makes disagreement inexpressible, and this test fails the + /// moment someone reintroduces a second source: report `Available` with no broadcaster wired, or + /// wire one while still reporting `BroadcasterUnwired`, and the implication below breaks. + /// + /// Written as an implication over the seam rather than as `assert_eq!(capability, Unwired)` + /// because the second spelling becomes a FALSE failure the day dig-node#424 lands — a test that + /// has to be deleted to ship the fix is not guarding the property, it is guarding the gap. + #[test] + fn the_reported_capability_cannot_disagree_with_what_can_actually_broadcast() { + // BOTH branches, which is why the decision is a named function: the wired branch is + // unreachable through `open_signer` on this build, and a branch no fixture can take reads as + // covered while never having run once. + assert_eq!( + spend_capability(true), + SpendCapability::Available, + "a wired broadcaster is what Available means" + ); + assert_eq!( + spend_capability(false), + SpendCapability::BroadcasterUnwired, + "with no broadcaster the node must say so, not report a switched-off flag the operator \ + has already switched on" + ); + assert!( + !spend_capability(false).may_spend(), + "a node with no broadcaster may not spend, whatever the operator has enabled" + ); + + // The load-bearing half: on THIS build the seam is empty, so no path may report Available. + // Hard-code `Available` back into `open_signer` and this fails; wire dig-node#424 and it + // keeps passing, because the assertion is an implication over the seam rather than a + // snapshot of the gap. + assert_eq!( + spend_capability(production_broadcaster().is_some()) == SpendCapability::Available, + production_broadcaster().is_some(), + "Available and a wired broadcaster must be the same fact" + ); + } + + /// What a real installation LOOKS like, assembled independently of the needles. + /// + /// Independently is the whole point: the discriminating test below asks whether each needle + /// matches a line somebody might actually write. Building the sample by interpolating the needle + /// into it answers a different question — whether a string contains itself — which is true for + /// every needle including a wrong one, and is the tautology this pair replaces. + /// + /// `concat!` for the same reason the needles use it — and this comment is fragmented for the + /// same reason again: an installation spelled out in full anywhere in this file, prose included, + /// sits in the file's own source and trips the guard on itself. + fn sample_installations() -> [&'static str; 2] { + [ + concat!( + " let backend = backend.with_", + "signer(signer.clone());" + ), + concat!( + " let backend = backend.with_", + "broadcaster(pusher);" + ), + ] + } + + /// Every file that HOLDS or BUILDS the served backend, and could therefore install on it. + /// + /// `service.rs` is the file that CONSTRUCTS it (`WalletService::build_with`), reaching + /// `AppState.wallet` from `server.rs`; `wallet_mtls.rs` and `control.rs` hold the same `Arc`. + /// The earlier list named only the three mirror-adjacent files, so a regression introduced at + /// the construction site — the likeliest place for one — would not have tripped the guard. + /// + /// `rpc.rs` is deliberately ABSENT: it DEFINES `with_signer`/`with_broadcaster` and its own + /// tests call them, so scanning it would fail on the definition and force the guard to be + /// weakened into uselessness. + /// + /// **Known residue, stated rather than implied.** `dig-wallet`'s own `sage/service.rs` — where + /// `WalletService::build_with` constructs the backend this crate is handed — is NOT scanned: an + /// `include_str!` reaching outside this package would leave the crate unpackageable, which is a + /// worse defect than the one it guards. The property holds there today (neither spelling appears + /// anywhere outside `rpc.rs`), and the guard that would cover it belongs in `dig-wallet`. + fn guarded_sources() -> [(&'static str, &'static str); 5] { + [ + ("lifecycle.rs", include_str!("lifecycle.rs")), + ("signer.rs", include_str!("signer.rs")), + ("../server.rs", include_str!("../server.rs")), + ("../control.rs", include_str!("../control.rs")), + ("../wallet_mtls.rs", include_str!("../wallet_mtls.rs")), + ] + } + /// The mirror signer NEVER reaches the served wallet backend. /// /// Asserted STRUCTURALLY, over the crate's own source, because the property is about something @@ -521,11 +672,7 @@ mod tests { /// or breaking the guard. #[test] fn no_signer_or_broadcaster_is_ever_installed_on_the_served_wallet_backend() { - for (name, source) in [ - ("lifecycle.rs", include_str!("lifecycle.rs")), - ("signer.rs", include_str!("signer.rs")), - ("../server.rs", include_str!("../server.rs")), - ] { + for (name, source) in guarded_sources() { for forbidden in forbidden_installations() { assert!( !source.contains(forbidden), @@ -537,24 +684,39 @@ mod tests { /// The guard is looking at real text and WOULD fail if the call appeared. /// - /// Two failure modes it removes, both of which leave a green guard proving nothing: a needle - /// that matches no possible call, and an `include_str!` pointing at a file that does not contain - /// the code being guarded. The second is asserted by requiring a string every guarded file - /// genuinely contains, so a path typo is a failure rather than an empty search. + /// Three failure modes it removes, each of which leaves a green guard proving nothing: a needle + /// that matches no possible call, a needle so loose it matches anything, and an `include_str!` + /// pointing at a file that does not contain the code being guarded. The last is asserted by + /// requiring a string every guarded file genuinely contains, so a path typo is a failure rather + /// than an empty search. + /// + /// The first two are what the earlier version could not see: it interpolated the needle into its + /// own fixture and then asserted the fixture contained it, which holds for EVERY needle — a + /// misspelling such as `.with_signer (` included. Matching against a sample written on its own + /// terms is what makes the assertion capable of failing. #[test] fn the_installation_guard_can_actually_fail() { - for needle in forbidden_installations() { - let planted = format!("let _ = backend{needle}s);"); + for (needle, sample) in forbidden_installations() + .into_iter() + .zip(sample_installations()) + { assert!( - planted.contains(needle), - "{needle} is the spelling a real installation would write" + sample.contains(needle), + "{needle} must match the line a real installation would write: {sample}" ); } - for (name, source) in [ - ("lifecycle.rs", include_str!("lifecycle.rs")), - ("signer.rs", include_str!("signer.rs")), - ("../server.rs", include_str!("../server.rs")), - ] { + + // A needle that is subtly wrong matches NOTHING, and the guard built on it would be green + // forever. Spelled with a space before the parenthesis — the plausible near-miss. + let near_miss = concat!(".with_", "signer ("); + for sample in sample_installations() { + assert!( + !sample.contains(near_miss), + "a needle that matches no real call must be visible as a failure, not a pass" + ); + } + + for (name, source) in guarded_sources() { assert!( source.contains("WalletBackend"), "{name} must be the file that could install a signer; an empty or wrong include makes the guard pass forever" diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index a9a094ff..c012caa7 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2722,6 +2722,14 @@ fn spawn_mirror_passes( target: "mirror", "the mirror lifecycle OBSERVES only: the operator wallet (SPEC.md §16.4) did not open" ), + // Deliberately NOT phrased as a flag to set: the operator has already set + // DIG_WALLET_ENABLE_LIVE_BROADCAST to reach this arm at all. + SpendCapability::BroadcasterUnwired => tracing::info!( + target: "mirror", + "the mirror lifecycle OBSERVES only: the wallet opened and live broadcast is on, \ + but this build wires no broadcaster (dig-node#424), so a reclaim is planned and \ + reported and no spend is sent" + ), } let journal = lifecycle::journal(); @@ -2771,7 +2779,10 @@ fn spawn_mirror_passes( owner_puzzle_hash, signer_ref, &journal, - None, + // The SAME seam `open_signer` derived the reported capability from, so + // what this node says it can do and what a spend can actually reach + // cannot be two different answers (dig-node#424). + lifecycle::production_broadcaster(), runtime, ); let mut pass = From e22e8aee2f507306ff23ec9048aae9c3384a93a2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:06:28 -0700 Subject: [PATCH 16/19] test(mirror): exercise open_signer itself, so the capability test can fail The first version asserted `spend_capability(production_broadcaster().is_some())` against `production_broadcaster().is_some()` -- a property of the helper, not of the caller. Reverting the fix by hard-coding `Available` back into `open_signer` left it GREEN, because the helper it asserted over was untouched. It pinned the outcome where the fix is a PLACEMENT. The replacement mints a real operator wallet into a temp layout and calls `open_signer` with live broadcast ON, which is the only path a production node takes. Hard-code `Available` there and it goes red on the reported capability. The fixture asserts the wallet actually OPENED first, since the `WalletUnavailable` path would otherwise satisfy every remaining assertion without reaching the decision under test -- the same vacuity, one door along. The both-branches assertion stays as its own test: the wired branch is unreachable on this build, and a branch no fixture can take reads as covered while never having run. Refs #412, #424 --- .../dig-node-service/src/mirror/lifecycle.rs | 72 +++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index c1eee0a2..15a4db52 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -573,10 +573,56 @@ mod tests { /// because the second spelling becomes a FALSE failure the day dig-node#424 lands — a test that /// has to be deleted to ship the fix is not guarding the property, it is guarding the gap. #[test] - fn the_reported_capability_cannot_disagree_with_what_can_actually_broadcast() { - // BOTH branches, which is why the decision is a named function: the wired branch is - // unreachable through `open_signer` on this build, and a branch no fixture can take reads as - // covered while never having run once. + fn an_opened_wallet_with_broadcast_enabled_still_may_not_spend_with_no_broadcaster_wired() { + use dig_wallet::autoseed::BootstrapState; + + let dir = tempfile::tempdir().expect("a temp dir"); + let paths = WalletPaths::resolve(dir.path().join("seed")); + + // A REAL operator wallet, minted into the temp layout. Without one this test would take the + // `WalletUnavailable` path and pass while never reaching the decision under test — which is + // exactly how the first version of it went green against the very regression it names. + let state = crate::wallet_bootstrap::ensure_wallet_seed_at(&paths) + .expect("the autoseed bootstrap yields a state"); + assert!( + matches!(state, BootstrapState::Created | BootstrapState::Opened), + "the fixture must actually OPEN a wallet, or the assertions below are vacuous: {state:?}" + ); + + // Live broadcast ON — the operator has already done everything they can do. + let (signer, capability) = open_signer(&paths, true); + + assert!( + signer.is_some(), + "an opened wallet yields a signer; the observation half needs its puzzle hash" + ); + assert_eq!( + capability, + spend_capability(production_broadcaster().is_some()), + "open_signer must REPORT what the pass will actually be handed, never re-decide it" + ); + + if production_broadcaster().is_none() { + assert_eq!( + capability, + SpendCapability::BroadcasterUnwired, + "with the seam empty the honest answer is the missing wiring, not a switched-off \ + flag the operator has already switched on" + ); + assert!( + !capability.may_spend(), + "and a node that cannot broadcast must not announce that it may create and reclaim" + ); + } + } + + /// Both branches of the capability decision, including the one this build cannot reach. + /// + /// Separate from the test above because that one can only exercise the branch the current + /// [`production_broadcaster`] selects. A branch no fixture can take reads as covered while + /// never having run once, so the wired branch is asserted directly. + #[test] + fn the_capability_decision_says_available_only_for_a_wired_broadcaster() { assert_eq!( spend_capability(true), SpendCapability::Available, @@ -585,23 +631,9 @@ mod tests { assert_eq!( spend_capability(false), SpendCapability::BroadcasterUnwired, - "with no broadcaster the node must say so, not report a switched-off flag the operator \ - has already switched on" - ); - assert!( - !spend_capability(false).may_spend(), - "a node with no broadcaster may not spend, whatever the operator has enabled" - ); - - // The load-bearing half: on THIS build the seam is empty, so no path may report Available. - // Hard-code `Available` back into `open_signer` and this fails; wire dig-node#424 and it - // keeps passing, because the assertion is an implication over the seam rather than a - // snapshot of the gap. - assert_eq!( - spend_capability(production_broadcaster().is_some()) == SpendCapability::Available, - production_broadcaster().is_some(), - "Available and a wired broadcaster must be the same fact" + "and its absence is a different answer, not the same one" ); + assert!(!spend_capability(false).may_spend()); } /// What a real installation LOOKS like, assembled independently of the needles. From 2401100baad7e92f3bd56eff892fd1b747a80cb4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:13:27 -0700 Subject: [PATCH 17/19] test(mirror): keep one presence-carry test, and make it pass clippy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A concurrent commit on this branch (9a5d392) swept in TWO tests of the same property -- its own `the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses` and an in-progress duplicate of mine. Two tests of one property test the module twice and the rest of it not at all, so the duplicate is removed. The survivor is the better fixture: it gives each runner its OWN empty SpendLog, so §25.4.6's in-flight suppression cannot be what makes a create disappear. The survivor also failed `clippy -D warnings` on `held(&[capsule.clone()])`, which would have blocked the required lint check. It borrows now. Refs #412 --- crates/dig-node-service/src/mirror/runner.rs | 83 +------------------- 1 file changed, 1 insertion(+), 82 deletions(-) diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index bbf51e5d..1b3709a1 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -613,7 +613,7 @@ mod tests { // requirement is known, creates are on, and nothing is on chain. So a create appearing or // not appearing is a statement about the presence window and about nothing else. let effects = || FakeEffects { - disk: held(&[capsule.clone()]), + disk: held(std::slice::from_ref(&capsule)), balance: REQUIRED * 10, ..FakeEffects::default() }; @@ -1126,87 +1126,6 @@ mod tests { ); } - /// The presence tracker CARRIED between rounds is what lets a bond ever settle. - /// - /// The scheduler rebuilds its effects — and therefore its runner — every round, because the - /// chain source is per-round and does not outlive it. `with_presence` / `into_presence` are the - /// only thing that survives that rebuild. Drop the carry and every round begins with a fresh - /// tracker; a fresh tracker has never seen the bond before, so it is never stable, so no create - /// is ever made — **while the node logs a completed pass each round and looks like it is - /// reconciling normally**. Nothing else in the suite goes red for that, which is precisely why - /// this test exists. - /// - /// The assertion is a PAIR over the same two passes, because only the pair discriminates. "The - /// second pass creates" alone is satisfied by an implementation with no debounce whatsoever, and - /// "a fresh tracker suppresses" alone is satisfied by an implementation that suppresses forever. - /// Carried settles, fresh does not — that is the property, and it needs both halves. - /// - /// The window is REAL here (`SETTLING_WINDOW_MS`) rather than the zero the shared `runner` - /// helper uses, since a zero window makes every tracker settle immediately and the carry - /// unobservable — the shape of fixture that would let this defect through while reading as - /// thorough. - #[test] - fn dropping_the_presence_carry_between_rounds_silently_stops_every_create() { - use super::super::presence::{PresenceTracker, SETTLING_WINDOW_MS}; - - let dir = tempfile::tempdir().expect("tempdir"); - let (_journal, log) = journal(dir.path()); - - let settling = bond("aa", "11"); - // One round's fixture: the same capsule on disk, funded, nothing on chain yet. - let effects = || FakeEffects { - disk: held(&[settling.clone()]), - chain: Vec::new(), - balance: 10 * REQUIRED, - ..Default::default() - }; - let at = |now_unix_ms: u64| PassContext { - now_unix_ms, - ..ctx() - }; - - const FIRST: u64 = 1_000_000; - let second = FIRST + SETTLING_WINDOW_MS + 1; - - // Round one, from nothing: the bond has just been seen for the first time, so it is not yet - // stable and buys nothing. This is the state the carry has to transport. - let mut round_one = PassRunner::new(effects(), log.clone()) - .with_settling_window_ms(SETTLING_WINDOW_MS) - .with_presence(PresenceTracker::new()); - let first_report = round_one.run(&at(FIRST)).expect("the pass runs"); - assert!( - first_report.created.is_empty(), - "a bond seen once has not been stable for a window, so it must buy nothing yet: {:?}", - first_report.created - ); - let carried = round_one.into_presence(); - - // Round two WITH the carry, a full window later: the bond has now held one state across the - // window, so it settles and the create is made. - let mut carried_round = PassRunner::new(effects(), log.clone()) - .with_settling_window_ms(SETTLING_WINDOW_MS) - .with_presence(carried); - let carried_report = carried_round.run(&at(second)).expect("the pass runs"); - assert_eq!( - carried_report.created, - vec![settling.clone()], - "carrying the tracker is what makes the window wall-clock time rather than per-runner" - ); - - // The SAME round two with a FRESH tracker — the exact regression a dropped - // `.with_presence(...)` produces. Same capsule, same clock, same funds, and nothing settles. - let mut fresh_round = PassRunner::new(effects(), log) - .with_settling_window_ms(SETTLING_WINDOW_MS) - .with_presence(PresenceTracker::new()); - let fresh_report = fresh_round.run(&at(second)).expect("the pass runs"); - assert!( - fresh_report.created.is_empty(), - "a fresh tracker each round re-observes the bond as new forever, so no bond ever \ - settles and no coin is ever created -- with every pass still reporting success: {:?}", - fresh_report.created - ); - } - /// A `Relayed` capsule buys no coin, and is reported as withheld rather than omitted. /// /// This is the one place an attacker influences what the node spends its own money on: a From 360366b3d69c9a56525e5cbaed6d6d56a2d7a464 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:22:24 -0700 Subject: [PATCH 18/19] test(collateral): prove a withheld row is not a pair the buffer advice prices `bondable_pairs` shipped untested, which was conspicuous given its own doc says it was named and separated so the distinction would be testable without a state directory. It was made testable and then not tested: delete the filter and the whole suite stayed green. The fixture varies the STATE across two rows rather than the row count, because `len()` and the filtered count agree on every set that is entirely bondable or entirely withheld -- a single-row fixture distinguishes nothing. It asserts `bondable_pairs` directly rather than routing through `collateral_buffer`, which returns early on the absent balance and would pass without the count ever being read. Refs #412 --- crates/dig-node-service/src/control.rs | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 2e359e46..2ab906dd 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3684,6 +3684,56 @@ mod tests { ); } + /// A `Withheld` row locks nothing, so it is not one of the pairs the buffer advice prices. + /// + /// **Proves** the one thing `bondable_pairs` exists to do. The count is handed to + /// `buffer_advice` as `pairs_served_by_this_node` and reaches `one_epoch_lock`, which turns it + /// into an amount of $DIG the operator must have available to LOCK — so a row that will never + /// be bonded inflates a money figure. + /// + /// **Catches** the nearest wrong implementation, which is also the code that shipped: + /// `observation.states.len()`. Delete the filter and this test is the only thing in the repo + /// that goes red. + /// + /// The fixture varies the STATE across two rows rather than the row count, because a + /// single-row fixture distinguishes nothing here — `len()` and the filtered count agree on + /// every set that is entirely bondable or entirely withheld. It deliberately does NOT route + /// through `collateral_buffer`: that call returns early on the absent balance, so a test taken + /// that way passes without the count ever being read. + #[test] + fn a_withheld_row_is_not_a_pair_the_buffer_advice_prices() { + use crate::mirror::pass::BondState; + use crate::mirror::plan::Bond; + use crate::mirror::states::BondObservation; + + let observation = BondObservation { + states: vec![ + ( + Bond::new("aa".repeat(32), "11".repeat(32)), + BondState::Withheld, + ), + ( + Bond::new("bb".repeat(32), "22".repeat(32)), + BondState::FundsUnknown, + ), + ], + locked_dig_base_units: 7_777, + epoch: 4, + }; + + assert_eq!( + bondable_pairs(&observation), + 1, + "two served rows, one of them relayed on a stranger's behalf and never bonded: only \ + the other locks anything" + ); + assert_ne!( + bondable_pairs(&observation), + observation.states.len() as u64, + "and it must not be the row count -- that is the figure this replaced" + ); + } + /// **Proves:** a PUBLISHED observation reaches the wire as rows — the surface stops answering /// `unknown` the moment a pass has run. /// @@ -3751,6 +3801,69 @@ mod tests { ); } + /// **Proves:** [`bondable_pairs`] counts the pairs this node will BOND, which is every served + /// row EXCEPT `Withheld`. + /// + /// **Catches:** the defect this function was extracted to fix — `states.len()`, which counts a + /// relayed capsule that locks nothing and so advises an operator to hold $DIG for money that + /// will never be spent. The function's own doc says it was separated "so the distinction is + /// testable"; without this test the seam is decoration, and substituting `states.len()` for the + /// body leaves the whole suite green. + /// + /// The fixture varies the state field across FOUR rows because a narrower one cannot see the + /// nearest wrong implementations. A single `Withheld` beside a single anything-else is + /// satisfied by "count the last row" as readily as by the contract; three bondable rows of + /// three DIFFERENT states also rule out "count only `Bonded`" (which would answer 1) and "count + /// the first non-withheld row" (also 1). Only the true predicate answers 3. + /// + /// Asserted on `bondable_pairs` DIRECTLY, never through [`collateral_buffer`]: that caller + /// returns early on an unreadable balance, so an assertion routed through it would pass without + /// ever reaching this function. + #[test] + fn bondable_pairs_counts_every_served_row_except_the_relayed_one_that_locks_nothing() { + use crate::mirror::pass::BondState; + use crate::mirror::plan::Bond; + use crate::mirror::states::BondObservation; + + let observation = BondObservation { + states: vec![ + // Relayed on a stranger's behalf: served, never advertised, never bonded. + ( + Bond::new("aa".repeat(32), "11".repeat(32)), + BondState::Withheld, + ), + // The three "this node has collateralised it or intends to" shapes. + ( + Bond::new("bb".repeat(32), "22".repeat(32)), + BondState::FundsUnknown, + ), + (Bond::new("cc".repeat(32), "33".repeat(32)), BondState::Pending), + ( + Bond::new("dd".repeat(32), "44".repeat(32)), + BondState::Bonded { + coin_id: "ee".repeat(32), + epoch: 4, + amount_dig_base_units: 1_000, + }, + ), + ], + locked_dig_base_units: 1_000, + epoch: 4, + }; + + assert_eq!( + observation.states.len(), + 4, + "the fixture must carry a row the answer EXCLUDES, or it cannot tell the contract from \ + a plain row count" + ); + assert_eq!( + bondable_pairs(&observation), + 3, + "a `Withheld` row locks nothing and is not bondable; the other three are" + ); + } + /// **Proves:** an out-of-range page size is REFUSED as `INVALID_PARAMS`, not clamped. /// /// **Catches:** a clamp, which hands back a cursor for a position the caller never asked From 1668f7a14a345f5ea7c6aec8fa400a8cfdd6a762 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:25:38 -0700 Subject: [PATCH 19/19] test(control): keep one bondable_pairs test, on the fixture that discriminates more Two lanes fixed the same gating finding concurrently and `360366b` swallowed both tests, so the branch carried two tests of one property -- which tests `bondable_pairs` twice and everything else not at all. The same duplication `2401100` removed from the runner tests, one commit earlier. The survivor is the fixture that separates more wrong implementations. Both are honest about the defect that shipped, but a two-row fixture of `Withheld` then `FundsUnknown` answers 1 under the contract AND under "count the last row" AND under "count only the unfunded row" -- it pins the answer without pinning the predicate. Four rows carrying three DIFFERENT bondable states answer 3 under the contract and 1 under both of those, so only the true predicate passes. The removed test's reasoning is preserved: assert `bondable_pairs` directly rather than through `collateral_buffer`, which returns early on the absent balance and would go green without the count ever being read. Refs #412 --- crates/dig-node-service/src/control.rs | 55 ++------------------------ 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 2ab906dd..141f8ea7 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3684,56 +3684,6 @@ mod tests { ); } - /// A `Withheld` row locks nothing, so it is not one of the pairs the buffer advice prices. - /// - /// **Proves** the one thing `bondable_pairs` exists to do. The count is handed to - /// `buffer_advice` as `pairs_served_by_this_node` and reaches `one_epoch_lock`, which turns it - /// into an amount of $DIG the operator must have available to LOCK — so a row that will never - /// be bonded inflates a money figure. - /// - /// **Catches** the nearest wrong implementation, which is also the code that shipped: - /// `observation.states.len()`. Delete the filter and this test is the only thing in the repo - /// that goes red. - /// - /// The fixture varies the STATE across two rows rather than the row count, because a - /// single-row fixture distinguishes nothing here — `len()` and the filtered count agree on - /// every set that is entirely bondable or entirely withheld. It deliberately does NOT route - /// through `collateral_buffer`: that call returns early on the absent balance, so a test taken - /// that way passes without the count ever being read. - #[test] - fn a_withheld_row_is_not_a_pair_the_buffer_advice_prices() { - use crate::mirror::pass::BondState; - use crate::mirror::plan::Bond; - use crate::mirror::states::BondObservation; - - let observation = BondObservation { - states: vec![ - ( - Bond::new("aa".repeat(32), "11".repeat(32)), - BondState::Withheld, - ), - ( - Bond::new("bb".repeat(32), "22".repeat(32)), - BondState::FundsUnknown, - ), - ], - locked_dig_base_units: 7_777, - epoch: 4, - }; - - assert_eq!( - bondable_pairs(&observation), - 1, - "two served rows, one of them relayed on a stranger's behalf and never bonded: only \ - the other locks anything" - ); - assert_ne!( - bondable_pairs(&observation), - observation.states.len() as u64, - "and it must not be the row count -- that is the figure this replaced" - ); - } - /// **Proves:** a PUBLISHED observation reaches the wire as rows — the surface stops answering /// `unknown` the moment a pass has run. /// @@ -3837,7 +3787,10 @@ mod tests { Bond::new("bb".repeat(32), "22".repeat(32)), BondState::FundsUnknown, ), - (Bond::new("cc".repeat(32), "33".repeat(32)), BondState::Pending), + ( + Bond::new("cc".repeat(32), "33".repeat(32)), + BondState::Pending, + ), ( Bond::new("dd".repeat(32), "44".repeat(32)), BondState::Bonded {