From db6c5589b7d8c58e4684e9d4117d14ff62c76a32 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:00:04 -0700 Subject: [PATCH 1/5] chore(dht): open lane for the untrusted mirror-coin pointer at announce Refs #422 --- .lane-422.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .lane-422.md diff --git a/.lane-422.md b/.lane-422.md new file mode 100644 index 00000000..9cab3e63 --- /dev/null +++ b/.lane-422.md @@ -0,0 +1,14 @@ +# Lane dn-422 — DHT mirror-coin pointer + +Ticket: DIG-Network/dig-node#422 (epic dig_ecosystem#3166) + +Attach `unverified_mirror_coin_id` to this node's DHT provider announce +(`crates/dig-node-core/src/seams/dig_peer/dht.rs`), and re-announce on epoch +rollover. Untrusted pointer (NC-12): it says where to look, never what the coin +is; its absence must not degrade discovery or read as a fault. + +Prerequisite, now cleared: dig-peer-selector 0.11.0 and dig-download 0.22.0 both +publish against `dig-dht ^0.15`, so dig-node can take the tier in one bump and +resolve a single dig-dht line. + +This file is removed before the PR is marked ready. From cff9f8a68307ddde89839e251e088d150ff57fd9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:08:09 -0700 Subject: [PATCH 2/5] feat(dht): attach the untrusted mirror-coin pointer, refreshed on epoch rollover Refs #422 --- Cargo.lock | 36 +- crates/dig-node-core/Cargo.toml | 8 +- .../dig-node-core/src/seams/dig_peer/dht.rs | 347 +++++++++++++++++- .../src/seams/dig_peer/module_reshare.rs | 4 +- .../src/seams/dig_peer/union_locator.rs | 20 +- 5 files changed, 382 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37edf11d..21a307b6 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", @@ -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/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index e9266f91..3368f8e1 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -399,7 +399,7 @@ dig-social-profile = { version = "0.7", default-features = false } # `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" +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), @@ -445,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.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 @@ -483,7 +483,7 @@ dig-peer = "0.13" # 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" +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 +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.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..42812e0b 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,204 @@ 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..3f4306b1 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,7 +1336,9 @@ 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..d607c727 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,13 +345,17 @@ 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])); let union = UnionLocator::new(vec![dht, pool]); @@ -401,7 +405,9 @@ 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,14 +450,18 @@ 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 { 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 src_a = Arc::new(MockProviderLocator::fixed(vec![earlier])); let src_b = Arc::new(MockProviderLocator::fixed(vec![later])); let union = UnionLocator::new(vec![src_a, src_b]); From 59d43392f13260990c496ef74f1cfb5d64425732 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:20:26 -0700 Subject: [PATCH 3/5] chore(deps): take the dig-dht 0.15 tier and correct the now-false manifest notes Refs #422 --- .lane-422.md | 14 -------------- Cargo.lock | 2 +- Cargo.toml | 27 ++++++++++++++++----------- crates/dig-node-core/Cargo.toml | 31 +++++++++++++++++-------------- 4 files changed, 34 insertions(+), 40 deletions(-) delete mode 100644 .lane-422.md diff --git a/.lane-422.md b/.lane-422.md deleted file mode 100644 index 9cab3e63..00000000 --- a/.lane-422.md +++ /dev/null @@ -1,14 +0,0 @@ -# Lane dn-422 — DHT mirror-coin pointer - -Ticket: DIG-Network/dig-node#422 (epic dig_ecosystem#3166) - -Attach `unverified_mirror_coin_id` to this node's DHT provider announce -(`crates/dig-node-core/src/seams/dig_peer/dht.rs`), and re-announce on epoch -rollover. Untrusted pointer (NC-12): it says where to look, never what the coin -is; its absence must not degrade discovery or read as a fault. - -Prerequisite, now cleared: dig-peer-selector 0.11.0 and dig-download 0.22.0 both -publish against `dig-dht ^0.15`, so dig-node can take the tier in one bump and -resolve a single dig-dht line. - -This file is removed before the PR is marked ready. diff --git a/Cargo.lock b/Cargo.lock index 21a307b6..a2ab618f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 53a0f3bf..7caa180d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 3368f8e1..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,13 +392,15 @@ 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. +# 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 @@ -439,9 +441,9 @@ 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. @@ -480,9 +482,10 @@ 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. +# 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 From d462426e2f25cdb1e1340c865d8c7f7bbb3d6052 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 11:40:02 -0700 Subject: [PATCH 4/5] style: cargo fmt --- crates/dig-node-core/src/seams/dig_peer/dht.rs | 4 +++- .../dig-node-core/src/seams/dig_peer/module_reshare.rs | 2 +- .../dig-node-core/src/seams/dig_peer/union_locator.rs | 10 +++++----- 3 files changed, 9 insertions(+), 7 deletions(-) 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 42812e0b..0232eb42 100644 --- a/crates/dig-node-core/src/seams/dig_peer/dht.rs +++ b/crates/dig-node-core/src/seams/dig_peer/dht.rs @@ -1757,7 +1757,9 @@ mod tests { /// 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"); + let record = records + .first() + .expect("this node provides the id it announced"); record.unverified_mirror_coin_id_bytes() } 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 3f4306b1..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 @@ -1338,7 +1338,7 @@ mod tests { 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 d607c727..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 @@ -347,7 +347,7 @@ mod tests { 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), @@ -355,7 +355,7 @@ mod tests { 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])); let union = UnionLocator::new(vec![dht, pool]); @@ -407,7 +407,7 @@ mod tests { 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]); @@ -452,7 +452,7 @@ mod tests { 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 { content_key: cid.to_key().to_hex(), @@ -461,7 +461,7 @@ mod tests { 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])); let union = UnionLocator::new(vec![src_a, src_b]); From 2754385e00d8d718abdbada97b753e91d22740da Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 12:56:34 -0700 Subject: [PATCH 5/5] chore(release): v0.178.0 Refs #422 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2ab618f..612d3e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.177.0" +version = "0.178.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 7caa180d..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