diff --git a/Cargo.lock b/Cargo.lock index 1daa60e9..24e835fa 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", @@ -3081,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.172.0" +version = "0.173.0" dependencies = [ "async-trait", "axum", @@ -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..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 @@ -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/SPEC.md b/SPEC.md index 1bba7389..40bd861f 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 @@ -8070,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 @@ -8242,12 +8261,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-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 21a5ecf4..e9266f91 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -289,7 +289,7 @@ zeroize = "1" # 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-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). @@ -337,11 +337,12 @@ dig-nat = "0.20" # 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. +# 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 @@ -349,7 +350,7 @@ dig-nat = "0.20" # 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"] } +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). @@ -387,12 +388,18 @@ dig-social-profile = { version = "0.7", default-features = false } # 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" +# 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), @@ -438,7 +445,7 @@ dig-pex = "0.1.1" # 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" +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 @@ -450,7 +457,7 @@ dig-download = "0.20.4" # 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" +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 @@ -473,10 +480,10 @@ dig-peer = "0.12.0" # 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" +# 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 @@ -542,7 +549,7 @@ dig-keystore = { version = "0.13", features = ["testing"] } # 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"] } +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 @@ -560,7 +567,7 @@ rcgen = "0.13" # # 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"] } +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. 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] } 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/control.rs b/crates/dig-node-service/src/control.rs index 38cddce2..141f8ea7 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 @@ -926,11 +937,11 @@ 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. - "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 @@ -3440,18 +3451,50 @@ 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 { +/// 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, +) -> 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 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, @@ -3475,13 +3518,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. 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. +/// 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. +/// +/// # `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 +3537,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 +3560,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 +3577,26 @@ 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 +3668,13 @@ 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 +3684,139 @@ 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. + // + // 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]["bond_state"], "withheld"); + assert_eq!(entries[1]["store_id"], "bb".repeat(32)); + 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:** [`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 @@ -3620,7 +3825,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 +3838,13 @@ 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 +3858,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, + ), json!(1), &json!({ "after": { "store_id": "not-hex", "root": hex } }), ); @@ -3653,6 +3871,9 @@ 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 new file mode 100644 index 00000000..15a4db52 --- /dev/null +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -0,0 +1,811 @@ +//! 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 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 +//! 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#421. +//! +//! # 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, FundingCoinId, 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. + /// + /// 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. + 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: Vec, + 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, + intended: Option, + ) -> Result<(), PassError> { + 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( + "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(), + ) + })?; + + let (bundle, recorded) = signer + .sign(spends, self.journal) + .map_err(|e| PassError::Wallet(e.to_string()))?; + + // 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(()) => { + 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) => { + 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> { + Ok(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, Some(reclaimed_coin_id(&coin))) + } + + 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#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#421); no spend was \ + attempted", + bond.store_id, amount_dig_base_units + ))) + } +} + +/// 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 +/// 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, 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 { + /// 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); + } + // 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. +/// +/// 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) -> Vec { + use dig_node_core::CapsuleStore as _; + + node.cache_list_cached() + .await + .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())) +} + +/// 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()) +} + +#[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()); + } + + /// 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("), + ] + } + + /// `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 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, + "a wired broadcaster is what Available means" + ); + assert_eq!( + spend_capability(false), + SpendCapability::BroadcasterUnwired, + "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. + /// + /// 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 + /// 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 guarded_sources() { + 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. + /// + /// 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, sample) in forbidden_installations() + .into_iter() + .zip(sample_installations()) + { + assert!( + sample.contains(needle), + "{needle} must match the line a real installation would write: {sample}" + ); + } + + // 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" + ); + } + } + + /// `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" + ); + } +} diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index abe0ac77..cff25bff 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,8 @@ //! 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; 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..6f867766 --- /dev/null +++ b/crates/dig-node-service/src/mirror/observe.rs @@ -0,0 +1,343 @@ +//! §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, + } + } + + /// 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: format!("{store}{root}").repeat(16), + 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, + protocol_version: 1, + required_per_store_dig_base_units: REQUIRED, + stores: 10, + owners: 3, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + }, + 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" + ); + // 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") + && 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 + ); + } + + /// 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!( + 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")), + 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..1b3709a1 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; @@ -336,7 +354,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 { @@ -568,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(std::slice::from_ref(&capsule)), + 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 { 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-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 66fd2496..c012caa7 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,190 @@ 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" + ), + // 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(); + 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, + // 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 = + 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 diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index 8659798a..5363b49c 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 + } + /// The signer for this wallet's keys. pub fn signer(&self) -> &WalletSigner { &self.signer @@ -101,6 +114,41 @@ 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..be737215 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -1213,6 +1213,42 @@ 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, 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 + /// 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. + /// + /// **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)); + 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 {