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/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/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index abe0ac77..e8afa63f 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,7 @@ //! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named //! `*_mojos` and come from separate coins so a fee can never shave collateral. +pub mod observe; pub mod pass; pub mod plan; pub mod presence; diff --git a/crates/dig-node-service/src/mirror/observe.rs b/crates/dig-node-service/src/mirror/observe.rs new file mode 100644 index 00000000..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..6199e747 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -336,7 +336,7 @@ impl PassRunner { /// than a step inside a [`MirrorEffects`] implementation, so that there is exactly one of it: a /// second implementation of this rule is a second answer to "what may this node spend its own money /// advertising", and the two would not stay equal. -fn split_by_provenance(observed: &[ObservedCapsule]) -> (Vec, Vec) { +pub(super) fn split_by_provenance(observed: &[ObservedCapsule]) -> (Vec, Vec) { let mut held = Vec::new(); let mut relayed = Vec::new(); for capsule in observed { diff --git a/crates/dig-wallet/src/operator_wallet.rs b/crates/dig-wallet/src/operator_wallet.rs index 8659798a..705d1d53 100644 --- a/crates/dig-wallet/src/operator_wallet.rs +++ b/crates/dig-wallet/src/operator_wallet.rs @@ -101,6 +101,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 {