diff --git a/Cargo.lock b/Cargo.lock index 37edf11d..612d3e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1948,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2647,9 +2647,9 @@ dependencies = [ [[package]] name = "dig-dht" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74e4c62744a1051edd768adcae93c20e3723acde53f7ffe20a4cefcfbec466b3" +checksum = "9b2e63802b4631993433715eaae41afefc0d5b709b632c8fbe5cb8702c769773" dependencies = [ "async-trait", "dig-ip", @@ -2682,9 +2682,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c187e30254a3a94448c76f6b58b384239e28bd002f3970cdb459cedde79c026" +checksum = "c9466c4f1b0cb8f833be2a9be7c10412dc489ef7cbe108f0d823bb541f9265dd" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "axum", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.177.0" +version = "0.178.0" dependencies = [ "async-trait", "axum", @@ -3157,9 +3157,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c21c975473af910434801a34f8437037f44cd5204ebaae1389e519524414eb3b" +checksum = "f646039e6b32a135442cfbf154aecb09aabad91f0cb925cd76fd291737894d9e" dependencies = [ "dig-dht", "dig-nat", @@ -3712,7 +3712,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3860,7 +3860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4487,7 +4487,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4718,7 +4718,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5052,7 +5052,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5663,7 +5663,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5701,9 +5701,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6453,7 +6453,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7165,7 +7165,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8299,7 +8299,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 53a0f3bf..408c5693 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.177.0" +version = "0.178.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over @@ -71,16 +71,21 @@ overflow-checks = true # cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, # which cleared 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 +# The resolved stack is dig-nat 0.21, dig-dht 0.15, dig-download 0.22, dig-peer 0.13, +# dig-peer-selector 0.11 -- 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 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. +# THE dig-dht 0.15 TIER IS TAKEN (dig-node#422), and it was blocked one level DOWN until it was: +# dig-download 0.21.0 and dig-peer-selector 0.10.0 both required `dig-dht ^0.13`, which is +# semver-incompatible with 0.15 on a 0.x line, so declaring "0.15" would have resolved TWO dig-dht +# lines while cargo printed success -- with dig-dht values crossing from this crate into both +# consumers. dig-download 0.22.0 and dig-peer-selector 0.11.0 released against `dig-dht ^0.15` and +# cleared it, which is what makes `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23) reachable +# here. +# +# THE THING A FUTURE LANE WILL GET WRONG: taking the next dig-dht line is STILL an upstream job, not +# an edit in this file. Check dig-download's and dig-peer-selector's LATEST published requirement +# from the crates.io index before moving it, name every crate explicitly on `cargo update -p` +# (a caret-compatible transitive is invisible to `cargo fetch`), and count the entries in the +# resolved `Cargo.lock` rather than reading the caret. 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 e9266f91..c142e9af 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -30,7 +30,7 @@ name = "dig-node-core" # dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; # this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x # rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.62.0" +version = "0.63.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." @@ -392,14 +392,16 @@ dig-social-profile = { version = "0.7", default-features = false } # 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" +# 0.15 (dig-node#422): the line that carries `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23) +# and `announce_provider_with_collateral`, which is what `dht.rs` publishes the untrusted mirror-coin +# pointer through. Taking it was blocked UPSTREAM, not here: dig-download 0.21.0 and +# dig-peer-selector 0.10.0 both required `dig-dht ^0.13`, semver-incompatible with 0.15 on a 0.x +# line, so declaring "0.15" would have resolved TWO dig-dht lines while cargo printed success — with +# dig-dht values crossing from this crate into both consumers. dig-download 0.22.0 and +# dig-peer-selector 0.11.0 released against `^0.15` and cleared it. The next line moves the same way: +# upstream first, verified by counting entries in the resolved `Cargo.lock`, never by reading a +# caret. Bridging two lines with a shim is the §4.1 byte-drift class. +dig-dht = "0.15" # -- 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), @@ -439,13 +441,13 @@ dig-pex = "0.1.1" # capsule-checkpoint filename fix (dig-download#38): `FileStateStore` hex-encoded the download key into # the filename, so a module key `module:<64hex>:<64hex>` (136 bytes) became a 277-character name and # EVERY capsule checkpoint write on Linux failed with `File name too long (os error 36)`. The same fix -# also shipped as 0.20.1, but 0.20 requires dig-dht ^0.12 / dig-nat ^0.20 / dig-peer ^0.12 / -# dig-tls ^0.4 — the whole dig_ecosystem#3152 cascade — which this crate cannot take yet, so the -# backport keeps the 0.19-line pins intact. Regression-tested here through the node's OWN production +# also shipped as 0.20.1. 0.22 is the release that carries `dig-dht ^0.15`, which is what unblocked +# the dig-dht tier above (dig-node#422); the dig_ecosystem#3152 cascade it once waited on is done. +# The 0.19-line regression is still covered: regression-tested here through the node's OWN production # checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), # because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no # filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.21" +dig-download = "0.22" # -- 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 @@ -480,10 +482,11 @@ dig-peer = "0.13" # halves of that split are gone — dig-nat 0.20 and the dig-gossip rev both take `dig-tls ^0.4`, and # the lock now resolves dig-tls ONCE at 0.4.0. # -# 0.10 IS takeable as of dig-gossip v0.32.0, which declares `dig-nat = "0.21"` — the release the old -# note here was waiting on. It carries `dig-dht ^0.13`, which is what holds dig-dht at 0.13; see the -# dig-dht entry above. -dig-peer-selector = "0.10" +# 0.11 carries `dig-dht ^0.15` — one of the two consumer releases that unblocked the dig-dht tier +# above (dig-node#422). Its predecessor 0.10.0 required `^0.13`, and because this crate passes +# dig-dht values into the selector, that requirement is what held dig-dht at 0.13; see the dig-dht +# entry above. +dig-peer-selector = "0.11" # 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 @@ -567,7 +570,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.21", features = ["testkit"] } +dig-download = { version = "0.22", 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/src/seams/dig_peer/dht.rs b/crates/dig-node-core/src/seams/dig_peer/dht.rs index 01e2d13a..0232eb42 100644 --- a/crates/dig-node-core/src/seams/dig_peer/dht.rs +++ b/crates/dig-node-core/src/seams/dig_peer/dht.rs @@ -424,6 +424,50 @@ pub async fn announce_inventory_ids( dht: &DhtService, ids: &[dig_dht::ContentId], concurrency: usize, +) -> usize { + announce_inventory_ids_with_pointers(dht, ids, concurrency, None).await +} + +/// This node's CLAIMED mirror-coin id per content id, together with the collateral epoch those +/// claims were drawn for (#422). +/// +/// The claim is **UNTRUSTED** (NC-12). Publishing it tells a verifier WHERE TO LOOK — one coin to +/// fetch instead of a scan of the mirror puzzle hash — and never WHAT THE COIN IS. A verifier +/// accepts a coin only on the coin's own evidence: found at the mirror puzzle hash, genuinely $DIG +/// with the asset id re-derived from the creating spend, carrying the declared collateral, and +/// `MirrorCoin::advertises(store, root, epoch)` passing. Nothing published here enters that +/// judgement, so a lying peer buys itself a wasted lookup and nothing else. +/// +/// **Absence is a normal, fully supported state.** A node with no coin for a capsule — or no +/// pointer source at all — announces exactly as before; the verifier's fallback is the hint scan. +/// An implementation MUST NOT treat `None` as a fault, and MUST NOT let it suppress the announce. +/// +/// [`epoch`](Self::epoch) exists because a mirror coin bonds a `(store, root, owner, epoch)` tuple +/// while **dig-dht has no clock**: `republish` re-attaches whatever pointer was recorded at announce +/// time, so a pointer that is never refreshed is wrong one epoch after publication and a +/// correctly-collateralised node reads as uncollateralised. `epoch()` is what +/// [`DhtHandle::reannounce_on_epoch_rollover`] compares to detect the rollover. +pub trait MirrorCoinPointers: Send + Sync { + /// The collateral epoch [`coin_id_for`](Self::coin_id_for) currently answers for. A change in + /// this value is what triggers the re-announce. + fn epoch(&self) -> u64; + + /// The mirror-coin id this node CLAIMS bonds `content` in the current epoch, if it has one. + /// `None` is normal and must not degrade discovery. + fn coin_id_for(&self, content: &dig_dht::ContentId) -> Option<[u8; 32]>; +} + +/// As [`announce_inventory_ids`], but attaching the untrusted mirror-coin pointer (#422) that +/// `pointers` supplies for each content id. +/// +/// The lookup is PER CONTENT ID, because a mirror coin bonds one `(store, root, owner, epoch)` +/// tuple — one node's inventory can be partly bonded, and the ids without a coin announce +/// unchanged rather than being held back. +pub async fn announce_inventory_ids_with_pointers( + dht: &DhtService, + ids: &[dig_dht::ContentId], + concurrency: usize, + pointers: Option<&dyn MirrorCoinPointers>, ) -> usize { use futures::stream::StreamExt; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -446,7 +490,8 @@ pub async fn announce_inventory_ids( .for_each_concurrent(concurrency.max(1), |id| { let done = &done; async move { - let _ = dht.announce_provider(id).await; + let coin_id = pointers.and_then(|p| p.coin_id_for(id)); + let _ = dht.announce_provider_with_collateral(id, coin_id).await; let n = done.fetch_add(1, Ordering::Relaxed) + 1; if n.is_multiple_of(stride) && n != total { println!( @@ -486,8 +531,7 @@ pub async fn announce_inventory_ids( pub fn spawn_initial_inventory_announce(handle: Arc) { tokio::spawn(async move { let _ = crate::shared::catch_iteration("initial_inventory_announce", async move { - let ids = handle.announced_ids().await; - announce_inventory_ids(handle.service(), &ids, INITIAL_ANNOUNCE_CONCURRENCY).await; + handle.announce_all().await; }) .await; }); @@ -554,11 +598,24 @@ pub async fn sync_inventory( dht: &DhtService, previous: &[dig_dht::ContentId], cached: &[CachedCapsule], +) -> InventoryDelta { + sync_inventory_with_pointers(dht, previous, cached, None).await +} + +/// As [`sync_inventory`], but attaching the untrusted mirror-coin pointer (#422) to each newly +/// announced id, so a capsule gained between epochs publishes its pointer at once rather than +/// waiting for the next rollover. +pub async fn sync_inventory_with_pointers( + dht: &DhtService, + previous: &[dig_dht::ContentId], + cached: &[CachedCapsule], + pointers: Option<&dyn MirrorCoinPointers>, ) -> InventoryDelta { let current = inventory_content_ids(cached); let (to_announce, to_withdraw) = inventory_diff(previous, ¤t); for id in &to_announce { - let _ = dht.announce_provider(id).await; + let coin_id = pointers.and_then(|p| p.coin_id_for(id)); + let _ = dht.announce_provider_with_collateral(id, coin_id).await; } for id in &to_withdraw { dht.retract_own_provider(id).await; @@ -610,17 +667,86 @@ pub struct DhtHandle { /// The content ids currently announced, guarded so [`Self::refresh_inventory`] can diff + update /// atomically against concurrent maintenance. announced: tokio::sync::Mutex>, + /// The untrusted mirror-coin pointer source (#422), or `None` on a node that publishes no + /// pointers — which is an ordinary, fully supported configuration, not a degraded one. + pointers: Option>, + /// The collateral epoch the currently-published pointers were drawn for, so + /// [`Self::reannounce_on_epoch_rollover`] can tell a rollover from a quiet tick. `None` until + /// the first pointer-bearing announce. + announced_epoch: tokio::sync::Mutex>, } impl DhtHandle { /// Wrap a bootstrapped [`DhtService`], recording the initial announced content-id set. + /// + /// Publishes no mirror-coin pointer; see [`with_mirror_pointers`](Self::with_mirror_pointers). pub fn new(service: Arc, initial: Vec) -> Arc { + Self::with_mirror_pointers(service, initial, None) + } + + /// As [`new`](Self::new), but attaching this node's untrusted mirror-coin pointers (#422) to + /// every announce it makes, and re-attaching them on each epoch rollover. + pub fn with_mirror_pointers( + service: Arc, + initial: Vec, + pointers: Option>, + ) -> Arc { Arc::new(DhtHandle { service, announced: tokio::sync::Mutex::new(initial), + pointers, + announced_epoch: tokio::sync::Mutex::new(None), }) } + /// Announce this node's whole current content-id set, attaching the mirror-coin pointer each id + /// has one for, and remembering the epoch those pointers were drawn for. + pub async fn announce_all(&self) -> usize { + let ids = self.announced_ids().await; + if let Some(p) = self.pointers.as_ref() { + *self.announced_epoch.lock().await = Some(p.epoch()); + } + announce_inventory_ids_with_pointers( + &self.service, + &ids, + INITIAL_ANNOUNCE_CONCURRENCY, + self.pointers.as_deref(), + ) + .await + } + + /// Re-announce every content id with FRESHLY READ pointers when the collateral epoch has rolled + /// over since the last announce. Returns how many ids were re-announced (`0` on a quiet tick, + /// and always `0` on a node with no pointer source). + /// + /// This exists because **dig-dht has no clock**. `republish` faithfully re-attaches whatever + /// pointer was recorded at announce time, so without this the published pointer names last + /// epoch's coin forever: a verifier fetches a coin that no longer advertises the current epoch, + /// and a node that IS correctly collateralised reads as uncollateralised. Nothing about that + /// failure is visible on the day the pointer is first published, which is why it is asserted + /// directly rather than left to the republish path. + pub async fn reannounce_on_epoch_rollover(&self) -> usize { + let Some(pointers) = self.pointers.as_ref() else { + return 0; + }; + let epoch = pointers.epoch(); + { + let mut announced_epoch = self.announced_epoch.lock().await; + if *announced_epoch == Some(epoch) { + return 0; // same epoch: the published pointers are still the right ones + } + *announced_epoch = Some(epoch); + } + let ids = self.announced_ids().await; + announce_inventory_ids_with_pointers( + &self.service, + &ids, + INITIAL_ANNOUNCE_CONCURRENCY, + Some(pointers.as_ref()), + ) + .await + } + /// The underlying service (for the inbound-RPC serving path + diagnostics). pub fn service(&self) -> &Arc { &self.service @@ -646,7 +772,13 @@ impl DhtHandle { /// it, which is what keeps the DHT records and the flood in agreement. pub async fn reconcile_inventory(&self, cached: &[CachedCapsule]) -> InventoryDelta { let mut announced = self.announced.lock().await; - let delta = sync_inventory(&self.service, &announced, cached).await; + let delta = sync_inventory_with_pointers( + &self.service, + &announced, + cached, + self.pointers.as_deref(), + ) + .await; *announced = inventory_content_ids(cached); delta } @@ -724,10 +856,15 @@ pub async fn run_maintenance(handle: Arc, interval: Duration) { // asserting its unwind-safety is sound; the next tick resumes the schedule. let _ = crate::shared::catch_iteration("dht_maintenance", async { let dht = &handle.service; + // BEFORE the republish, not after: republish re-attaches the pointer recorded at + // announce time, so a rollover detected only afterwards would leave last epoch's coin + // id published for a further whole interval (#422). + let repointed = handle.reannounce_on_epoch_rollover().await; let republished = dht.republish().await; let refreshed = dht.refresh_buckets().await; let collected = dht.gc().await; tracing::debug!( + repointed, republished, refreshed, collected, @@ -1564,4 +1701,206 @@ mod tests { "an ordinary cause is not truncated: {whole:?}" ); } + + // -- The untrusted mirror-coin pointer (#422) ----------------------------------------------- + + const COIN_THIS_EPOCH: [u8; 32] = [0xA1; 32]; + const COIN_NEXT_EPOCH: [u8; 32] = [0xB2; 32]; + + /// A pointer source whose epoch AND its coin ids move together, as a real rollover moves them: + /// a mirror coin bonds `(store, root, owner, epoch)`, so the coin that bonds a capsule in epoch + /// N+1 is a DIFFERENT coin from the one that bonded it in epoch N. + /// + /// The two must vary together or the rollover test is blind: a fake that keeps the same coin id + /// across the rollover cannot tell a re-announce that re-reads the pointers from one that + /// replays the pointer recorded at the first announce. + struct EpochPointers { + state: std::sync::Mutex<(u64, std::collections::HashMap)>, + } + + impl EpochPointers { + fn new(epoch: u64, bonded: &[(ContentId, [u8; 32])]) -> Arc { + Arc::new(EpochPointers { + state: std::sync::Mutex::new((epoch, bonded.iter().copied().collect())), + }) + } + + /// The chain moved on: a new epoch, bonded by new coins. + fn roll_to(&self, epoch: u64, bonded: &[(ContentId, [u8; 32])]) { + *self.state.lock().expect("pointers") = (epoch, bonded.iter().copied().collect()); + } + } + + impl MirrorCoinPointers for EpochPointers { + fn epoch(&self) -> u64 { + self.state.lock().expect("pointers").0 + } + + fn coin_id_for(&self, content: &ContentId) -> Option<[u8; 32]> { + self.state.lock().expect("pointers").1.get(content).copied() + } + } + + /// A `DhtService` with an EMPTY routing table, so `announce_provider` stores locally without a + /// network round trip and `find_providers` answers from that same local store — which is what + /// lets these tests read back exactly what this node published about itself. + fn local_only_service(label: &str) -> Arc { + Arc::new(DhtService::new( + PeerId::from_bytes([0x42; 32]), + vec![CandidateAddr::direct("::1", 9444)], + dig_dht::DhtConfig::default(), + Arc::new(test_transport(label)), + )) + } + + /// The mirror-coin pointer this node currently publishes for `id`, read back off its own + /// provider record. + async fn published_pointer(service: &DhtService, id: &ContentId) -> Option<[u8; 32]> { + let records = service.find_providers(id).await.expect("local providers"); + let record = records + .first() + .expect("this node provides the id it announced"); + record.unverified_mirror_coin_id_bytes() + } + + /// Two ids: one this node holds a mirror coin for, one it does not. + fn bonded_and_unbonded() -> (ContentId, ContentId) { + ( + ContentId::capsule([0x01; 32], [0x02; 32]), + ContentId::capsule([0x03; 32], [0x04; 32]), + ) + } + + /// **Proves:** the pointer is attached PER CONTENT ID — the id with a coin publishes it, and the + /// id without one is announced exactly as before. + /// + /// **Catches:** attaching one node-wide pointer to every id (a mirror coin bonds one + /// `(store, root, owner, epoch)` tuple, so a shared pointer would send verifiers to a coin that + /// does not advertise the capsule they asked about), and any implementation that treats a + /// missing coin as a fault by skipping or failing that announce. The unbonded id is the + /// truthful control: it is what makes "absence does not degrade discovery" observable rather + /// than assumed. + #[tokio::test] + async fn the_pointer_is_attached_per_id_and_its_absence_does_not_suppress_the_announce() { + let (bonded, unbonded) = bonded_and_unbonded(); + let service = local_only_service("pointer-per-id-422"); + let pointers = EpochPointers::new(7, &[(bonded, COIN_THIS_EPOCH)]); + let handle = DhtHandle::with_mirror_pointers( + service.clone(), + vec![bonded, unbonded], + Some(pointers), + ); + + assert_eq!( + handle.announce_all().await, + 2, + "both ids are announced; a missing coin is not a reason to withhold one" + ); + + assert_eq!( + published_pointer(&service, &bonded).await, + Some(COIN_THIS_EPOCH), + "the bonded id publishes this node's claimed coin" + ); + assert_eq!( + published_pointer(&service, &unbonded).await, + None, + "the unbonded id publishes no pointer -- and is still a normal provider record" + ); + } + + /// **Proves:** when the collateral epoch rolls over, every announced id is re-announced with + /// FRESHLY READ pointers, and a tick within the same epoch re-announces nothing. + /// + /// **Catches:** the failure this half of #422 exists for, which is invisible on the day the + /// pointer ships. dig-dht has NO CLOCK: `republish` re-attaches whatever pointer was recorded at + /// announce time, so an implementation that publishes the pointer once and leaves refreshing to + /// republish keeps naming LAST epoch's coin forever -- a verifier fetches a coin that no longer + /// advertises the current epoch, and a correctly-collateralised node reads as uncollateralised. + /// The republish assertion in the middle is what pins that: it shows the record surviving a + /// republish UNCHANGED, so the later change can only have come from the rollover re-announce. + /// + /// It also catches a re-announce that replays the cached pointer (the id would still name + /// `COIN_THIS_EPOCH`), and one that fires on every tick regardless of epoch (the two + /// same-epoch calls must return 0). + #[tokio::test] + async fn the_pointer_is_refreshed_when_the_collateral_epoch_rolls_over() { + let (bonded, unbonded) = bonded_and_unbonded(); + let service = local_only_service("pointer-rollover-422"); + let pointers = EpochPointers::new(7, &[(bonded, COIN_THIS_EPOCH)]); + let handle = DhtHandle::with_mirror_pointers( + service.clone(), + vec![bonded, unbonded], + Some(pointers.clone()), + ); + + handle.announce_all().await; + assert_eq!( + published_pointer(&service, &bonded).await, + Some(COIN_THIS_EPOCH) + ); + + // A maintenance tick inside the same epoch: nothing to refresh. + assert_eq!( + handle.reannounce_on_epoch_rollover().await, + 0, + "the published pointers are still the right ones; re-announcing every tick would put \ + the whole inventory back on the wire for nothing" + ); + + // And republish alone cannot refresh it -- it faithfully replays what was recorded. + service.republish().await; + assert_eq!( + published_pointer(&service, &bonded).await, + Some(COIN_THIS_EPOCH), + "republish re-attaches the recorded pointer; it is not a refresh mechanism" + ); + + // The chain rolls over: a new epoch, bonded by a new coin. + pointers.roll_to(8, &[(bonded, COIN_NEXT_EPOCH)]); + + assert_eq!( + handle.reannounce_on_epoch_rollover().await, + 2, + "every announced id is re-announced on a rollover" + ); + assert_eq!( + published_pointer(&service, &bonded).await, + Some(COIN_NEXT_EPOCH), + "the refreshed announce names THIS epoch's coin, read fresh rather than replayed" + ); + assert_eq!( + published_pointer(&service, &unbonded).await, + None, + "an id with no coin in the new epoch still publishes none" + ); + + assert_eq!( + handle.reannounce_on_epoch_rollover().await, + 0, + "the new epoch is now the announced one; a second tick is quiet again" + ); + } + + /// **Proves:** a node with no pointer source at all behaves exactly as it did before #422 -- + /// every id announced, no pointer published, and the rollover check a no-op. + /// + /// **Catches:** making the pointer source load-bearing for discovery. A node that has never + /// minted a mirror coin is an ordinary DIG node, not a broken one, and it must not be held back + /// from announcing or spun through re-announces it has nothing to refresh. + #[tokio::test] + async fn a_node_with_no_pointer_source_announces_exactly_as_before() { + let (bonded, unbonded) = bonded_and_unbonded(); + let service = local_only_service("pointer-absent-422"); + let handle = DhtHandle::new(service.clone(), vec![bonded, unbonded]); + + assert_eq!(handle.announce_all().await, 2); + assert_eq!(published_pointer(&service, &bonded).await, None); + assert_eq!(published_pointer(&service, &unbonded).await, None); + assert_eq!( + handle.reannounce_on_epoch_rollover().await, + 0, + "there is nothing to refresh, and no epoch to compare against" + ); + } } diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index 6dcfbf51..31382c3b 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -1336,6 +1336,8 @@ mod tests { provider_peer_id: "dd".repeat(32), addresses: vec![], expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }]) } } diff --git a/crates/dig-node-core/src/seams/dig_peer/union_locator.rs b/crates/dig-node-core/src/seams/dig_peer/union_locator.rs index 09510299..0909fec1 100644 --- a/crates/dig-node-core/src/seams/dig_peer/union_locator.rs +++ b/crates/dig-node-core/src/seams/dig_peer/union_locator.rs @@ -345,12 +345,16 @@ mod tests { provider_peer_id: mock_peer_hex(1), addresses: vec![CandidateAddr::direct("10.9.9.9", 1)], expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }; let pool_hint = ProviderRecord { content_key: cid.to_key().to_hex(), provider_peer_id: mock_peer_hex(1), addresses: vec![CandidateAddr::direct("10.0.0.1", 9444)], expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }; let dht = Arc::new(MockProviderLocator::fixed(vec![dht_hint])); let pool = Arc::new(MockProviderLocator::fixed(vec![pool_hint])); @@ -401,6 +405,8 @@ mod tests { provider_peer_id: mock_peer_hex(1), addresses, expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }; let src = Arc::new(MockProviderLocator::fixed(vec![bloated])); let union = UnionLocator::new(vec![src]); @@ -444,6 +450,8 @@ mod tests { provider_peer_id: mock_peer_hex(1), addresses: earlier_addrs, expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }; // A LATER source names the SAME peer at one ADDITIONAL, reachable address. let later = ProviderRecord { @@ -451,6 +459,8 @@ mod tests { provider_peer_id: mock_peer_hex(1), addresses: vec![CandidateAddr::direct("10.0.0.1", 9444)], expires_at: u64::MAX, + // No mirror-coin pointer: absence is the normal state for a provider (#422). + unverified_mirror_coin_id: None, }; let src_a = Arc::new(MockProviderLocator::fixed(vec![earlier])); let src_b = Arc::new(MockProviderLocator::fixed(vec![later]));