From 9547221456ba9d1d08bfb1f417dc551045527579 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 6 Sep 2026 08:25:39 -0700 Subject: [PATCH 01/10] chore: open lane for #3212 dig-node share Refs DIG-Network/dig_ecosystem#3212 Co-Authored-By: Claude From 24e2b71d6899865c9fc5155482a4ac4ee2b04d8e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 6 Sep 2026 20:50:05 -0700 Subject: [PATCH 02/10] fix(serve): normalize store_id/root case at the CapsuleKey boundary (dig_ecosystem#2147) CapsuleKey::parse stored store/root as given, so the same 32 bytes named in two different casings produced two distinct keys -- disagreeing Eq/Hash/Display/ module_path for what is the same capsule. Lower-case both components at the single construction boundary; is_canonical_hex_id keeps accepting any case (length + hex only), so a caller who names a capsule in upper or mixed case is still admitted, just normalized once, at the one place it matters. Also fixes the red test itself: its `mixed`-case fixture used an unrelated hex string instead of a mixed-case rendering of the same bytes as `lower`/`upper`, so it compared two different capsules and could never pass regardless of the implementation. Blast radius: grepped every `CapsuleKey::parse` call site across dig-node-core (lib.rs, peer.rs, seams/capsule/*, seams/content/*, seams/dig_peer/*) -- none assert on upper/mixed-case survival through parse, so none are invalidated by normalizing at construction. --- crates/dig-node-core/src/capsule_key.rs | 46 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/capsule_key.rs b/crates/dig-node-core/src/capsule_key.rs index 9db0d3c9..906633b5 100644 --- a/crates/dig-node-core/src/capsule_key.rs +++ b/crates/dig-node-core/src/capsule_key.rs @@ -179,9 +179,13 @@ impl CapsuleKey { /// This is the ONLY boundary at which untrusted key bytes become a usable capsule identity, so it /// is the one place the whitelist has to be right. pub(crate) fn parse(store: &str, root: &str) -> Option { + // dig_ecosystem#2147: lower-case both components at this single construction boundary so the + // same 32 bytes named in two different casings resolve to ONE key — same `Eq`/`Hash`, same + // `Display`. `is_canonical_hex_id` deliberately accepts either case; this is the only place + // case gets normalized, so every derived comparison agrees by construction. (is_canonical_hex_id(store) && is_canonical_hex_id(root)).then(|| CapsuleKey { - store: store.to_string(), - root: root.to_string(), + store: store.to_ascii_lowercase(), + root: root.to_ascii_lowercase(), }) } @@ -491,4 +495,42 @@ mod tests { assert_eq!(names.len(), 2, "only the two `.dig` artifacts remain"); assert!(names.iter().all(|n| n.ends_with(".dig"))); } + + #[test] + fn parse_normalizes_id_case_so_one_capsule_is_one_key() { + // dig_ecosystem#2147: the same capsule named in two different casings must resolve to ONE + // `CapsuleKey` — same store(), same rendering, same hash bucket — never two distinct keys for + // what is the same 32 bytes. + let lower = hex_id(0x7e); + let upper = lower.to_ascii_uppercase(); + // A MIXED-case rendering of the SAME 32 bytes as `lower`/`upper` — alternating the case of + // each hex digit — not a different id. (An earlier draft of this test used an unrelated hex + // string here, which compared two different capsules and could never pass.) + let mixed: String = lower + .chars() + .enumerate() + .map(|(i, c)| { + if i % 2 == 0 { + c.to_ascii_uppercase() + } else { + c + } + }) + .collect(); + + let key_lower = CapsuleKey::parse(&lower, &lower).expect("canonical"); + let key_upper = CapsuleKey::parse(&upper, &upper).expect("canonical"); + let key_mixed = CapsuleKey::parse(&mixed, &mixed).expect("canonical"); + + assert_eq!(key_lower, key_upper, "case must not create a second key"); + assert_eq!(key_lower, key_mixed, "case must not create a second key"); + assert_eq!(key_upper.store(), lower, "store() is always lower-case"); + assert_eq!(key_upper.to_string(), format!("{lower}:{lower}")); + + let mut set = std::collections::HashSet::new(); + set.insert(key_lower); + set.insert(key_upper); + set.insert(key_mixed); + assert_eq!(set.len(), 1, "one capsule must occupy one hash bucket"); + } } From abda7fbc39b90553af4bfb3bb0f3d4733ba51f79 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 6 Sep 2026 21:49:37 -0700 Subject: [PATCH 03/10] fix(cache): tier-0 occupancy reads the eviction-aware ledger, not a counter tier0_occupancy() read the monotonic TIER0_LANDED atomic, which never fell -- a store the eviction sweep purged kept counting toward `cache.stats` tier0_precache.occupancy forever. Read tier0_land_ledger().len() instead (the same eviction-aware HashSet mark_tier0_land/forget_tier0_land already maintain), so a forgotten land un-counts. Deleted TIER0_LANDED (grepped: no other reader in the workspace) and rewrote the doc comment that documented the old, now-false behaviour. Blast radius: TIER0_LANDED was private to tier0_live.rs; forget_tier0_land is called from lib.rs's eviction sweep (crate::tier0_live::forget_tier0_land, untouched, read-only to this lane) which now correctly moves the occupancy gauge it already assumed was live. Refs: dig_ecosystem#2045 --- crates/dig-node-core/src/tier0_live.rs | 68 ++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/dig-node-core/src/tier0_live.rs b/crates/dig-node-core/src/tier0_live.rs index d7de9ad6..59d42f3e 100644 --- a/crates/dig-node-core/src/tier0_live.rs +++ b/crates/dig-node-core/src/tier0_live.rs @@ -68,12 +68,6 @@ use dig_sex::{NodeContext, RelevanceWeights}; /// (SPEC §7.10e/f) so a controller can tell "the flywheel is live" from "the seam is inert". static TIER0_WIRED: AtomicBool = AtomicBool::new(false); -/// The count of stores this process's tier-0 loop has landed in the cache — the `cache.stats` -/// `tier0_precache.occupancy` figure. A monotonic land counter, not a live occupancy (an evicted -/// precache store still counts); reported as the best available tier-0 signal until an -/// eviction-aware ledger lands. -static TIER0_LANDED: AtomicU64 = AtomicU64::new(0); - /// The unix-ms timestamp of the most recent inbound serve/demand event, `0` if none yet. The /// [`InboundLoadSignal`] reads it to back off tier-0 while the node is serving real demand. static INBOUND_ACTIVITY_MS: AtomicU64 = AtomicU64::new(0); @@ -142,10 +136,16 @@ pub(crate) fn tier0_wired() -> bool { TIER0_WIRED.load(Ordering::Relaxed) } -/// The number of stores this process's tier-0 loop has landed (`cache.stats` occupancy figure). +/// The number of stores this process's tier-0 loop currently holds landed — the `cache.stats` +/// `tier0_precache.occupancy` figure. Reads the eviction-aware ledger (`mark_tier0_land`/ +/// `forget_tier0_land`) directly, so a store the eviction sweep purges stops counting; this is a LIVE +/// gauge, not a monotonic land counter (dig_ecosystem#2045). #[must_use] pub(crate) fn tier0_occupancy() -> u64 { - TIER0_LANDED.load(Ordering::Relaxed) + tier0_land_ledger() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .len() as u64 } /// The live inbound-load signal: BUSY iff a real inbound-demand event fired within [`BUSY_COOLDOWN_MS`]. @@ -282,7 +282,6 @@ impl Tier0Fetcher for NodeTier0Fetcher { // the tier-aware size-cap eviction so the self-driven loop PLATEAUS at the cache cap // instead of growing `/modules` to disk-exhaustion. mark_tier0_land(&hex::encode(preimage.store_id)); - TIER0_LANDED.fetch_add(1, Ordering::Relaxed); self.evictor.evict_if_needed().await; FetchOutcome::Cached(bytes) } @@ -752,6 +751,57 @@ mod tests { ); } + #[tokio::test] + async fn occupancy_falls_when_a_tier0_land_is_evicted() { + // dig_ecosystem#2045: `tier0_occupancy()` backs the `cache.stats` occupancy figure, which must + // read as a LIVE gauge. The old `TIER0_LANDED` monotonic counter never fell, so a node that + // landed then evicted a store kept reporting the evicted store as occupied — a lie about how + // much tier-0 content is actually held. Occupancy must instead track the eviction-aware + // ledger (`mark_tier0_land`/`forget_tier0_land`), so a forgotten land is un-counted. + // + // A store id distinct from `preimage()`'s (used by sibling tests sharing this process's + // global ledger) so this test's forget cannot un-count another test's concurrent land. + let unique_store = [0x77u8; 32]; + let store_hex = hex::encode(unique_store); + + let warm = Arc::new(SpyWarm { + verdict: WarmVerdict::Cached(4096), + seen: Mutex::new(Vec::new()), + }); + let f = NodeTier0Fetcher { + lookup: Arc::new(FixedLookup(Some(Preimage { + store_id: unique_store, + root: [0x22; 32], + size_bytes: 4096, + }))), + gate: Arc::new(FixedGate(true)), + warm, + evictor: SpyEvictor::new(), + }; + + let before = tier0_occupancy(); + let outcome = f.fetch_and_cache([0x02; 32], 8192).await; + assert_eq!(outcome, FetchOutcome::Cached(4096)); + assert_eq!( + tier0_occupancy(), + before + 1, + "landing increments the eviction-aware occupancy count" + ); + assert!(is_tier0_precache(&store_hex), "the land is tagged tier-0"); + + forget_tier0_land(&store_hex); + + assert_eq!( + tier0_occupancy(), + before, + "occupancy must fall back once the eviction sweep forgets the land" + ); + assert!( + !is_tier0_precache(&store_hex), + "a forgotten land is no longer tagged tier-0" + ); + } + // -- size_bytes hard-cap ------------------------------------------------------------------------ #[tokio::test] From 0a5343146c52d91a52117e7d9efd57456192dd47 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 6 Sep 2026 21:49:37 -0700 Subject: [PATCH 04/10] fix(profile-sync): serve budget counts bytes; ask the announcer first (dig_ecosystem#3029) --- .../src/seams/dig_peer/profile_sync.rs | 226 +++++++++++++++--- 1 file changed, 193 insertions(+), 33 deletions(-) diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index da1a6890..762c261f 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -113,15 +113,22 @@ pub fn profile_sync_enabled() -> bool { /// used to attribute a much later frame to a peer that has since been replaced in the pool. pub const SOLICITATION_TTL: Duration = Duration::from_secs(120); -/// Maximum number of 225 answers this node will emit per inbound-request burst window. +/// Maximum BYTES of 225 answer bodies this node will emit per inbound-request burst window. /// /// A 224 request is cheap to send and expensive to answer (a disk read plus up to /// [`MAX_PROFILE_BODY_BYTES`] on the wire), so an unbudgeted responder is an amplifier. The budget /// is per-window across ALL peers because the scarce resource being protected is this node's own /// upload, not any single link's fairness. -pub const OUTBOUND_BODY_BUDGET: usize = 32; - -/// The window the [`OUTBOUND_BODY_BUDGET`] refills over. +/// +/// Counted in BYTES, not answers (dig_ecosystem#3029, F3): a token-per-answer budget states no real +/// ceiling on upload — one profile body can be anywhere up to [`MAX_PROFILE_BODY_BYTES`], so "32 +/// tokens" could mean 32 bytes or 32 times [`MAX_PROFILE_BODY_BYTES`] depending on what callers +/// actually asked for. Set to the same worst case the old token budget already permitted (`32 * +/// MAX_PROFILE_BODY_BYTES`) so this change preserves behaviour rather than silently tightening or +/// loosening the cap. +pub const OUTBOUND_BODY_BUDGET_BYTES: usize = 32 * MAX_PROFILE_BODY_BYTES; + +/// The window the [`OUTBOUND_BODY_BUDGET_BYTES`] refills over. pub const OUTBOUND_BUDGET_WINDOW: Duration = Duration::from_secs(10); /// File extension of a persisted profile body. @@ -447,7 +454,10 @@ impl Solicitations { } } -/// A refilling token budget bounding how many 225 answers this node emits per window. +/// A refilling BYTE budget bounding how many bytes of 225 answers this node emits per window +/// (dig_ecosystem#3029, F3 — a token-per-answer budget could not state the real upload ceiling since +/// answer bodies vary in size up to [`MAX_PROFILE_BODY_BYTES`]; this type now charges and refills in +/// the unit that actually bounds upload). #[derive(Clone)] pub struct OutboundBudget { inner: Arc>, @@ -457,23 +467,24 @@ pub struct OutboundBudget { impl Default for OutboundBudget { fn default() -> Self { - Self::new(OUTBOUND_BODY_BUDGET, OUTBOUND_BUDGET_WINDOW) + Self::new(OUTBOUND_BODY_BUDGET_BYTES, OUTBOUND_BUDGET_WINDOW) } } impl OutboundBudget { - /// A budget of `capacity` answers per `window`. + /// A budget of `capacity_bytes` bytes served per `window`. #[must_use] - pub fn new(capacity: usize, window: Duration) -> Self { + pub fn new(capacity_bytes: usize, window: Duration) -> Self { Self { - inner: Arc::new(Mutex::new((capacity, Instant::now()))), - capacity, + inner: Arc::new(Mutex::new((capacity_bytes, Instant::now()))), + capacity: capacity_bytes, window, } } - /// Take one token, returning `false` when the window's budget is exhausted. - pub fn take(&self) -> bool { + /// Try to charge `bytes` bytes against the window's remaining budget, returning `false` (the + /// budget left untouched) when `bytes` would exceed what remains. + pub fn take(&self, bytes: usize) -> bool { let mut guard = self .inner .lock() @@ -483,10 +494,10 @@ impl OutboundBudget { *remaining = self.capacity; *since = Instant::now(); } - if *remaining == 0 { + if *remaining < bytes { return false; } - *remaining -= 1; + *remaining -= bytes; true } } @@ -741,10 +752,12 @@ pub enum ServeOutcome { ReadFailed, } -/// Answer one inbound 224 request from `peer`, within the outbound budget. +/// Answer one inbound 224 request from `peer`, within the outbound BYTE budget. /// -/// The budget is taken only once the artifact is known to exist, so a flood of requests for content -/// this node does not hold cannot starve the budget for peers asking about content it does. +/// The budget is charged only once the artifact is known to exist AND read, so a flood of requests +/// for content this node does not hold cannot starve the budget for peers asking about content it +/// does. Charged by the ACTUAL body length (dig_ecosystem#3029, F3) — the byte budget states a real +/// upload ceiling only if what it charges is what actually goes out on the wire. pub async fn serve_body_request( store: &ProfileBodyStore, transport: &dyn ProfileTransport, @@ -757,9 +770,6 @@ pub async fn serve_body_request( if !store.has(&store_id, &root) { return ServeOutcome::NotHeld; } - if !budget.take() { - return ServeOutcome::Throttled; - } let bytes = match store.get(&store_id, &root) { Ok(Some(bytes)) => bytes, // Raced against a prune between `has` and `get` — indistinguishable from not held, and @@ -771,6 +781,9 @@ pub async fn serve_body_request( } }; let len = bytes.len(); + if !budget.take(len) { + return ServeOutcome::Throttled; + } let body = ProfileBody { store_id: request.store_id, root: request.root, @@ -788,8 +801,9 @@ pub async fn serve_body_request( /// Ask one live peer for the body behind a root this node has ALREADY resolved from chain. /// /// `root` MUST come from [`AnchoredRootResolver`] — that is the invariant [`accept_body`]'s gate 4 -/// relies on, and this is the one function that establishes it. `exclude` skips the peer an announce -/// arrived from only when we have somewhere else to ask; otherwise asking the announcer is correct. +/// relies on, and this is the one function that establishes it. `announcer` is asked FIRST — it just +/// told us it has this root, so it is the peer most likely to answer immediately; this node falls +/// back to another live peer only when the announcer is no longer live (dig_ecosystem#3029, F5). /// /// Returns the peer asked, or `None` if there was nobody to ask. pub async fn request_body( @@ -797,9 +811,14 @@ pub async fn request_body( solicitations: &Solicitations, store_id: [u8; 32], root: [u8; 32], + announcer: PeerId, ) -> Option { let peers = transport.live_peers(); - let peer = peers.first().copied()?; + let peer = if peers.contains(&announcer) { + announcer + } else { + peers.first().copied()? + }; let root_ref = ProfileRootRef { store_id: Bytes32::from(store_id), root: Bytes32::from(root), @@ -826,6 +845,7 @@ pub async fn handle_root_announce( resolver: &dyn AnchoredRootResolver, transport: &dyn ProfileTransport, solicitations: &Solicitations, + announcer: PeerId, announce: &ProfileRootRef, ) -> Option { let store_id: [u8; 32] = announce.store_id.into(); @@ -875,7 +895,7 @@ pub async fn handle_root_announce( ); return None; } - let asked = request_body(transport, solicitations, store_id, chain_root).await; + let asked = request_body(transport, solicitations, store_id, chain_root, announcer).await; match asked { Some(peer) => tracing::info!( store = %hex::encode(store_id), @@ -948,6 +968,7 @@ pub async fn run_profile_sync_ingest( &*ctx.resolver, &*ctx.transport, &ctx.solicitations, + sender, &announce, ) .await; @@ -2228,16 +2249,17 @@ mod tests { #[tokio::test] async fn the_outbound_budget_binds_at_capacity_and_refuses_one_over() { - // Pinned from BOTH sides: the second answer within a capacity-2 window must succeed (a - // bound tested only from above would pass for an off-by-one that throttles too early), and - // the third must not. + // Pinned from BOTH sides: a second full-size answer within a two-body-sized window must + // succeed (a bound tested only from above would pass for an off-by-one that throttles too + // early), and a third must not. Capacity is now BYTES (dig_ecosystem#3029, F3): exactly two + // bodies' worth, not "2" answers regardless of size. let dir = tempdir(); let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); let tx = Transport::default(); - let budget = OutboundBudget::new(2, Duration::from_secs(60)); + let budget = OutboundBudget::new(2 * bytes.len(), Duration::from_secs(60)); let req = root_ref(sid, root); let a = serve_body_request(&store, &tx, &budget, peer(9), &req).await; @@ -2250,21 +2272,27 @@ mod tests { ServeOutcome::Served(bytes.len()), "at capacity must pass" ); - assert_eq!(c, ServeOutcome::Throttled, "one over must fail"); + assert_eq!( + c, + ServeOutcome::Throttled, + "one body over the byte budget must fail" + ); } #[tokio::test] async fn requests_for_content_we_do_not_hold_cannot_starve_the_budget() { - // The ORDERING inside `serve_body_request` is the property: the budget is taken only AFTER - // the artifact is known to exist. A capacity of ONE makes the difference observable — under - // the wrong ordering the single token is spent on a miss and the real request throttles. + // The ORDERING inside `serve_body_request` is the property: the budget is charged only AFTER + // the artifact is known to exist AND read. A capacity of exactly one body's worth of bytes + // makes the difference observable — under the wrong ordering the budget would be spent on a + // miss (which has no bytes to charge, but a token-shaped bug could still consume a slot) and + // the real request would throttle. let dir = tempdir(); let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); let tx = Transport::default(); - let budget = OutboundBudget::new(1, Duration::from_secs(60)); + let budget = OutboundBudget::new(bytes.len(), Duration::from_secs(60)); for i in 0..5u8 { let miss = @@ -2276,8 +2304,134 @@ mod tests { assert_eq!(real, ServeOutcome::Served(bytes.len())); } + /// dig_ecosystem#3029 (F3) — the budget is charged by the ACTUAL body size, not a flat token: a + /// budget sized for exactly one BIG body has nothing left after serving it, but the SAME budget + /// serves two SMALL bodies. A flat-token charge (the pre-fix behaviour) could not tell these + /// apart -- either would consume "one answer" regardless of size. + #[tokio::test] + async fn a_larger_body_draws_down_the_byte_budget_by_more_than_a_smaller_one() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (small, small_root) = dpb("Ada"); + let sid = store_id(1); + store.put(&sid, &small_root, &small).unwrap(); + let big_root = [0xBBu8; 32]; + let big = vec![0u8; small.len() * 3]; + store.put(&sid, &big_root, &big).unwrap(); + let tx = Transport::default(); + + let budget_after_big = OutboundBudget::new(big.len(), Duration::from_secs(60)); + let served_big = serve_body_request( + &store, + &tx, + &budget_after_big, + peer(9), + &root_ref(sid, big_root), + ) + .await; + let then_small = serve_body_request( + &store, + &tx, + &budget_after_big, + peer(9), + &root_ref(sid, small_root), + ) + .await; + assert_eq!(served_big, ServeOutcome::Served(big.len())); + assert_eq!( + then_small, + ServeOutcome::Throttled, + "a big-body-sized budget has nothing left after one big answer" + ); + + let budget_after_small = OutboundBudget::new(big.len(), Duration::from_secs(60)); + let served_small = serve_body_request( + &store, + &tx, + &budget_after_small, + peer(9), + &root_ref(sid, small_root), + ) + .await; + let then_small_again = serve_body_request( + &store, + &tx, + &budget_after_small, + peer(9), + &root_ref(sid, small_root), + ) + .await; + assert_eq!(served_small, ServeOutcome::Served(small.len())); + assert_eq!( + then_small_again, + ServeOutcome::Served(small.len()), + "two small bodies still fit inside a big-body-sized budget" + ); + } + // -- The 223-driven fetch ----------------------------------------------------------------------- + /// dig_ecosystem#3029 (F5) — the peer that just announced this root is asked FIRST, even when it + /// is not the first entry `live_peers()` happens to return. Before this fix `request_body` always + /// picked `peers.first()`, so an announcer buried anywhere but the front of the live-peer list was + /// never the one asked, despite being the peer most likely to answer immediately. + #[tokio::test] + async fn the_announcer_is_asked_first_even_when_it_is_not_the_first_live_peer() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (_, root) = dpb("Ada"); + let sid = store_id(1); + let tx = Transport::with_peers(vec![peer(9), peer(7)]); + let sol = Solicitations::new(); + + let asked = handle_root_announce( + &store, + &Subs(vec![sid]), + &chain_at(root), + &tx, + &sol, + peer(7), + &root_ref(sid, root), + ) + .await; + + assert_eq!( + asked, + Some(peer(7)), + "the announcer must be asked first, not `live_peers().first()`" + ); + } + + /// dig_ecosystem#3029 (F5) — falls back to another live peer when the announcer itself is no + /// longer live (it announced, then dropped before this node could ask it back). + #[tokio::test] + async fn falls_back_to_another_live_peer_when_the_announcer_has_left() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (_, root) = dpb("Ada"); + let sid = store_id(1); + let tx = Transport::with_peers(vec![peer(9)]); + let sol = Solicitations::new(); + let departed_announcer = peer(5); + + let asked = handle_root_announce( + &store, + &Subs(vec![sid]), + &chain_at(root), + &tx, + &sol, + departed_announcer, + &root_ref(sid, root), + ) + .await; + + assert_eq!( + asked, + Some(peer(9)), + "a departed announcer must fall back to another live peer, not return None" + ); + } + #[tokio::test] async fn an_announce_the_chain_confirms_solicits_the_body_under_the_chain_root() { let dir = tempdir(); @@ -2293,6 +2447,7 @@ mod tests { &chain_at(root), &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2324,6 +2479,7 @@ mod tests { &chain_at(on_chain), &tx, &sol, + peer(9), &root_ref(sid, forged), ) .await; @@ -2348,6 +2504,7 @@ mod tests { &chain_unreachable(), &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2384,6 +2541,7 @@ mod tests { &chain, &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2422,6 +2580,7 @@ mod tests { &chain_at(first), &tx, &sol, + peer(9), &root_ref(sid, first), ) .await; @@ -2431,6 +2590,7 @@ mod tests { &chain_at(second), &tx, &sol, + peer(9), &root_ref(sid, second), ) .await; From 3ba209ff629d0d5e5fb96117f25767eeae7d0a6e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 6 Sep 2026 21:49:37 -0700 Subject: [PATCH 05/10] fix(melt): require confirmation depth on the terminal spend before deleting confirm_melt_via_chain concluded MeltStatus::Melted the instant a lineage's terminal spend showed no successor, with no regard for how recently that spend confirmed. A terminal spend only a few blocks behind the peak could still be reorg-reverted -- and unlike an ordinary spend, a melt-triggered delete cannot be undone by the reorg that undoes the spend. Require the terminal spend to be MELT_CONFIRMATION_DEPTH blocks deep before returning Melted; fail CLOSED to Unknown (deletes nothing, tombstones nothing) when the peak or the terminal's spent_block_index is unavailable. MELT_CONFIRMATION_DEPTH = 32 blocks (~10 min at Chia's 18.75s spacing, the depth Chia wallets already treat as reorg-safe): dig-wallet's MAX_REORG_DEPTH is the closest existing precedent but dig-node-core must not depend on dig-wallet, so this is declared locally with the same rationale. The walk tracks the terminal coin's spent_block_index as it advances rather than re-fetching it, so the depth check costs exactly one extra chain read (peak_height) on the rare Melted path, never on Live/Unknown. Also updates two now-stale comments in this file left over from the sibling #2147 fix (CapsuleKey::parse no longer "preserves mixed case" -- it lower-cases at construction; a mixed-case cache directory can still exist from a prior binary or a path that bypasses parse entirely, which is what those tests and comments actually needed to be true about). #2090 (verify StoreMeltedAnnounce's signature on ingest) is NOT in this commit -- see the batch report for why it is blocked, not deferred-and-documented like the other split-off pieces in this epic. Tests: 3 added for the depth gate (shallower-fails / exactly-at-depth-passes / peak-unreachable-fails-closed) plus MockChain gained a peak() control point (previously `unimplemented!("the melt gate must not read the peak")`, which this fix necessarily makes false). 36 store_melted tests green (33 pre-existing + 3 new). Refs: dig_ecosystem#2093 --- .../src/seams/dig_peer/store_melted.rs | 131 +++++++++++++++--- 1 file changed, 115 insertions(+), 16 deletions(-) diff --git a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs index da599caf..e172a001 100644 --- a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs +++ b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs @@ -10,6 +10,12 @@ //! the store's singleton is closed. A forged/replayed announcement, or a chain the node cannot reach, //! deletes NOTHING (see [`confirm_melt`] / [`MeltStatus`]). //! +//! **A terminal spend must also be [`MELT_CONFIRMATION_DEPTH`]-deep** (dig_ecosystem#2093): a +//! lineage that terminates only a handful of blocks behind the peak could still be reorg-reverted, +//! and unlike a spend, a delete cannot be undone by the reorg that un-does it. [`confirm_melt_via_chain`] +//! answers `Unknown` — never `Melted` — until the terminal spend clears that depth or the peak is +//! unreachable. +//! //! # The wire (`dig_gossip`, opcode 221) is a PUBLIC broadcast — §5.4-EXEMPT //! //! A store deletion is public-by-nature and addressed to everyone (like L2 consensus gossip), so the @@ -348,6 +354,19 @@ pub async fn run_melt_tick( /// walk bounded. const MAX_LINEAGE_HOPS: usize = 10_000; +/// How many blocks deep the terminal spend of a melted lineage must sit before this node treats the +/// melt as final (dig_ecosystem#2093). +/// +/// A melt DELETES hosted content, and a delete cannot be undone by a later reorg the way a spend can +/// be un-confirmed. A terminal spend that is only a handful of blocks deep could still be reverted, +/// which would make the "melt" this node just acted on never have happened. No dig-node-core-reachable +/// canonical reorg-depth constant exists (`dig-wallet`'s `MAX_REORG_DEPTH` is real but dig-node-core +/// must not depend on dig-wallet), so this is declared here: 32 blocks is roughly 10 minutes at +/// Chia's 18.75s block spacing, the depth Chia wallets already treat as reorg-safe. The cost of +/// waiting an extra ~10 minutes before deleting is minutes; the cost of deleting on a reorg-reverted +/// melt is unrecoverable data loss, so the asymmetry favours waiting. +pub const MELT_CONFIRMATION_DEPTH: u32 = 32; + /// The deepest live DataLayer lineage measured on mainnet (all 53 launcher coins surveyed). const DEEPEST_MEASURED_MAINNET_LINEAGE: usize = 599; @@ -438,6 +457,10 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) // FACT 2 — follow real parentage from the launcher to the end of the lineage. let mut current = launcher_id; + // The block the CURRENT terminal coin was spent at — tracked as the walk advances so a + // confirmed melt (dig_ecosystem#2093) can be depth-checked without a second chain read for the + // coin the loop already fetched. + let mut terminal_spent_at = launcher.spent_block_index; for hop in 0..MAX_LINEAGE_HOPS { let children = match chain.coin_records_by_parent_ids(&[current], true).await { Ok(children) => children, @@ -458,8 +481,18 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) // hop means the answer is untrustworthy, not that the store is gone. MeltStatus::Unknown } else if children.is_empty() { - // The spend created NOTHING — the lineage terminated. - MeltStatus::Melted + // The spend created NOTHING — the lineage terminated. Not yet authoritative: a + // terminal spend this shallow could still be reverted by a reorg, and a melt is an + // IRREVERSIBLE delete (dig_ecosystem#2093). Require confirmation depth, fail CLOSED + // to `Unknown` (deletes nothing) when the peak itself is unreachable. + match chain.peak_height().await { + Ok(peak) + if peak.saturating_sub(terminal_spent_at) >= MELT_CONFIRMATION_DEPTH => + { + MeltStatus::Melted + } + _ => MeltStatus::Unknown, + } } else { // Children exist but none is a singleton. This is where a TRUNCATED page lands: the // query honours a server-side limit, and a page that dropped the odd successor @@ -475,6 +508,7 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) if !next.spent { return MeltStatus::Live; } + terminal_spent_at = next.spent_block_index; current = next.coin.coin_id(); } MeltStatus::Unknown @@ -607,13 +641,15 @@ impl MeltCache for Arc { let mut removed = 0; for capsule in self.cache_list_cached().await { // Match on the PARSED 32 bytes, never on the hex TEXT. A capsule id is canonical-hex but - // NOT canonical-case — `CapsuleKey::parse` admits and preserves mixed case, so the - // directory name can be `Ab..cD` while `hex::encode` here would produce lowercase. A - // textual compare therefore matches nothing for such a store while `held_store_ids` - // (which decodes, and so is case-insensitive) still reports it held: the node would - // tombstone the store, announce a melt of `generations: 0`, and go on serving the - // content it just told the network it had deleted. Decoding both sides keeps the - // held-check and the delete looking at the same identity. + // a cached directory is not guaranteed canonical-CASE on disk: `CapsuleKey::parse` now + // lower-cases at construction (dig_ecosystem#2147), but a directory written by a prior + // binary — or by any path that names a cache entry without going through `parse` — can + // still be `Ab..cD` while `hex::encode` here would produce lowercase. A textual compare + // therefore matches nothing for such a store while `held_store_ids` (which decodes, and + // so is case-insensitive) still reports it held: the node would tombstone the store, + // announce a melt of `generations: 0`, and go on serving the content it just told the + // network it had deleted. Decoding both sides keeps the held-check and the delete + // looking at the same identity regardless of how the directory name was cased. if parse_hex32(&capsule.store_id).as_ref() == Some(store_id) && self .cache_remove_cached(&capsule.store_id, &capsule.root) @@ -1199,6 +1235,11 @@ mod tests { /// ceiling can stop a walk over this chain. endless: bool, parent_queries: AtomicUsize, + /// The chain tip height `peak_height()` answers with (dig_ecosystem#2093's confirmation-depth + /// gate). Defaults far deeper than any fixture's `spent_block_index` (11) so every existing + /// `Terminated` fixture reads as confirmed unless a test deliberately shallows it via + /// [`Self::with_peak`]. + peak: Answer, } /// A coin record with an explicit parent + amount, so a real parentage chain can be built. @@ -1251,6 +1292,10 @@ mod tests { unreachable_at: None, endless: false, parent_queries: AtomicUsize::new(0), + // `coin_rec` sets `spent_block_index: 11` for every spent coin, so any peak well + // past `11 + MELT_CONFIRMATION_DEPTH` reads every `Terminated` fixture as confirmed + // unless a test deliberately narrows it via `with_peak`. + peak: Answer::Ok(1_000), } } @@ -1266,6 +1311,12 @@ mod tests { self } + /// Override the chain tip `peak_height()` answers with (dig_ecosystem#2093). + fn with_peak(mut self, peak: Answer) -> Self { + self.peak = peak; + self + } + /// The coin the walk is standing on after `hop` steps (0 = the launcher itself). fn coin_at_hop(&self, store_id: [u8; 32], hop: usize) -> [u8; 32] { let mut parent = store_id; @@ -1374,7 +1425,10 @@ mod tests { unimplemented!("the melt gate must not parse spends (#747-immunity)") } async fn peak_height(&self) -> ChainResult { - unimplemented!("the melt gate must not read the peak") + match &self.peak { + Answer::Ok(peak) => Ok(*peak), + Answer::Unreachable => Err(ChainError::Chain("coinset unreachable".into())), + } } async fn push(&self, _bundle: SpendBundle) -> ChainResult<()> { unimplemented!("the melt gate is read-only") @@ -1396,6 +1450,50 @@ mod tests { ); } + /// CHAIN-1b (dig_ecosystem#2093) — a terminal spend SHALLOWER than + /// [`MELT_CONFIRMATION_DEPTH`] is not yet final: a reorg could still revert it, and a melt is an + /// irreversible delete. `Unknown` deletes nothing, so the holder keeps serving until the depth is + /// met. + #[tokio::test] + async fn a_terminal_spend_shallower_than_the_confirmation_depth_is_not_yet_melted() { + // The terminal spend lands at `spent_block_index: 11` (`coin_rec`); one block short of the + // required depth is the sharpest possible off-by-one probe. + let chain = MockChain::minted(store(1), 2, Lineage::Terminated) + .with_peak(Answer::Ok(11 + MELT_CONFIRMATION_DEPTH - 1)); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Unknown, + "one block short of the confirmation depth must not authorize a delete" + ); + } + + /// CHAIN-1c (dig_ecosystem#2093) — exactly [`MELT_CONFIRMATION_DEPTH`] blocks deep IS melted + /// (pinned from the other side of CHAIN-1b, so the boundary itself is proven, not just "less + /// than X fails"). + #[tokio::test] + async fn a_terminal_spend_exactly_at_the_confirmation_depth_is_melted() { + let chain = MockChain::minted(store(1), 2, Lineage::Terminated) + .with_peak(Answer::Ok(11 + MELT_CONFIRMATION_DEPTH)); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Melted, + "exactly the confirmation depth must authorize the delete" + ); + } + + /// CHAIN-1d (dig_ecosystem#2093) — an unreachable peak fails CLOSED, same as every other + /// unreachable chain read in this walk: `Unknown`, never a guessed `Melted`. + #[tokio::test] + async fn an_unreachable_peak_fails_closed_to_unknown() { + let chain = + MockChain::minted(store(1), 2, Lineage::Terminated).with_peak(Answer::Unreachable); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Unknown, + "an unreachable peak must never be treated as confirming a melt" + ); + } + /// CHAIN-2 — a LIVE store: the walk reaches an UNSPENT successor. Covers the shape 52 of the 53 /// mainnet stores have, including the 29 whose tip is one hop from the launcher. /// @@ -1718,12 +1816,13 @@ mod tests { /// REAL-2 — a MIXED-CASE store directory is deleted, not silently skipped. /// - /// `CapsuleKey::parse` admits and preserves mixed case (`is_canonical_hex_id` accepts any ASCII - /// hex digit, with its own test asserting `Ab..cD` parses), so a mixed-case cache directory is - /// reachable. `held_store_ids` DECODES the hex and so is case-insensitive; a delete that compared - /// the hex TEXT against `hex::encode` (always lowercase) matched nothing. The node would then - /// tombstone the store, broadcast a melt of `generations: 0`, and keep serving the content it had - /// just announced as deleted — a melt reported but not performed. + /// `CapsuleKey::parse` now lower-cases at construction (dig_ecosystem#2147), but a directory + /// written directly (bypassing `parse`) — this fixture, or a cache left by a prior binary — is + /// still reachable in mixed case, so the delete path must not assume every on-disk name is + /// already canonical. `held_store_ids` DECODES the hex and so is case-insensitive; a delete that + /// compared the hex TEXT against `hex::encode` (always lowercase) matched nothing. The node + /// would then tombstone the store, broadcast a melt of `generations: 0`, and keep serving the + /// content it had just announced as deleted — a melt reported but not performed. #[tokio::test] async fn the_real_cache_deletes_a_mixed_case_store_directory() { let (node, td) = crate::test_support::test_node_for_peer_surface(); From 1d01ba619e51dd98d5e50df51f30a9542840426f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 7 Sep 2026 00:49:45 -0700 Subject: [PATCH 06/10] fix(serve): answer EngineWarming, not -32004, while the peer tier attaches (dig_ecosystem#2097) A request that lands in the ~30s window before the p2p engine attaches to the HTTP surface used to answer RESOURCE_NOT_AVAILABLE (-32004) when no upstream was configured -- indistinguishable from a genuine not-found. Add a new retryable ENGINE_WARMING code (-32002; -32001 is already the push surface's auth-refusal code) minted independently on both surfaces: dig-node-core's dispatch.rs (no_upstream_miss_error, unit-tested) and dig-node-service's ErrorCode catalog. -32004 now means the peer tier was consulted (or there is none) and still missed; SPEC.md's row is qualified accordingly. --- crates/dig-node-core/SPEC.md | 3 +- .../src/seams/dig_rpc/dispatch.rs | 61 +++++++++++++++++-- crates/dig-node-service/src/meta.rs | 20 ++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/crates/dig-node-core/SPEC.md b/crates/dig-node-core/SPEC.md index b55ab429..1f26a314 100644 --- a/crates/dig-node-core/SPEC.md +++ b/crates/dig-node-core/SPEC.md @@ -273,7 +273,8 @@ ROUTING.md §11` Phase 4). §2.5 + §8 are the normative target the integration | `-32601` | method not found | unknown method, OR a peer/write/control method named on the anonymous read tier | | `-32602` | invalid params | missing/malformed params (bad hex, wrong type, out-of-range) | | `-32000` | server error | upstream failure, chain read failure, file I/O, config write | -| `-32004` | resource unavailable | this node does not hold the content AND located no holder (genuine not-found) | +| `-32002` | `ENGINE_WARMING` | the p2p engine has not yet attached to the HTTP surface (~30s cold-start window) — the peer tier has genuinely not been consulted; retryable (dig_ecosystem#2097) | +| `-32004` | resource unavailable | this node does not hold the content, the peer tier WAS consulted (or there is none), AND located no holder (genuine not-found) — never returned while the peer tier is still attaching; see `-32002` | | `-32005` | `ROOT_NOT_ANCHORED` | served/requested root ≠ chain-anchored root, chain unreachable, or no confirmed generation (§4) — the anchor pin failing closed, UNIFORMLY across the `/s` tier, `dig.getContent` (read), AND `dig.fetchRange` (serve) | | `-32006` | `PEER_UNREACHABLE` | no traversal strategy reached the named peer | | `-32007` | `RANGE_NOT_SATISFIABLE` | `offset ≥ total_length` or the range is otherwise unsatisfiable | diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index ca58d63b..1c432b4d 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -26,6 +26,31 @@ use crate::Node; #[allow(unused_imports)] use crate::*; +/// `-32002` (dig_ecosystem#2097): the peer tier has genuinely not been consulted yet — the p2p +/// engine attaches ~30s after the HTTP surface opens. Distinct from [`RESOURCE_NOT_AVAILABLE`] +/// (`-32004`), which means the peer tier WAS consulted (or there is none) and the content is still +/// not found. Declared here rather than in `lib.rs`'s shared catalogue, matching how +/// `dig-node-service`'s `ErrorCode::EngineWarming` mints the SAME numeric code independently on its +/// own surface. +const ENGINE_WARMING: i64 = -32002; + +/// Decide the miss error for a request that fell all the way through with no configured upstream +/// (dig_ecosystem#2097): `(code, message)`. +/// +/// Pulled out as a pure decision so the ordering rule — `-32004` may only ever mean "the peer tier +/// was consulted (or there is none) and the content is still not found", never "the peer tier has +/// not been asked yet" — is unit-testable without a full [`Node`] fixture. +fn no_upstream_miss_error(p2p_attached: bool) -> (i64, &'static str) { + if p2p_attached { + ( + RESOURCE_NOT_AVAILABLE, + "resource not available: this node does not hold it and no peer served it", + ) + } else { + (ENGINE_WARMING, "peer tier not yet attached; retry") + } +} + /// Seam 4 (dig RPC server) — the node's core JSON-RPC dispatch. #[async_trait::async_trait] pub trait RpcDispatch: Send + Sync { @@ -996,12 +1021,8 @@ impl RpcDispatch for Node { // pin — so even on the proxy path the node never serves a generation the // chain did not confirm. if !node.has_upstream() { - return err( - &id, - RESOURCE_NOT_AVAILABLE, - "resource not available: this node does not hold it and no peer served it" - .to_string(), - ); + let (code, msg) = no_upstream_miss_error(node.p2p_content().is_some()); + return err(&id, code, msg.to_string()); } let upstream_req = pinned_root .map(|pin| pin_request_root(&req, &pin.to_hex())) @@ -1192,3 +1213,31 @@ mod holder_claim_tests { ); } } + +#[cfg(test)] +mod engine_warming_tests { + use super::{no_upstream_miss_error, ENGINE_WARMING, RESOURCE_NOT_AVAILABLE}; + + /// dig_ecosystem#2097 — a node with no upstream and no p2p engine attached has genuinely never + /// asked the peer tier about this content: ENGINE_WARMING, never RESOURCE_NOT_AVAILABLE. + #[test] + fn no_p2p_attached_answers_engine_warming_not_resource_not_available() { + let (code, _msg) = no_upstream_miss_error(false); + assert_eq!( + code, ENGINE_WARMING, + "the peer tier was never consulted; -32004 would misreport an unasked question as a miss" + ); + } + + /// dig_ecosystem#2097 — the SAME node, once the p2p engine has attached, answers the ordinary + /// genuine-miss code. This is the other side of the boundary: proves the fix does not turn + /// EVERY no-upstream miss into ENGINE_WARMING forever. + #[test] + fn p2p_attached_and_a_miss_answers_resource_not_available() { + let (code, _msg) = no_upstream_miss_error(true); + assert_eq!( + code, RESOURCE_NOT_AVAILABLE, + "once the peer tier has been consulted, a miss is a genuine -32004" + ); + } +} diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 680ef847..8e432c48 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -684,6 +684,14 @@ pub enum ErrorCode { /// passes through. Which layer answered is carried by `data.origin`, never by the /// name — so the name is taken from the shared catalogue rather than restated. ResourceUnavailable, + /// `-32002` — the request arrived before this node's peer tier had finished attaching + /// (dig_ecosystem#2097). The HTTP surface opens ~30s before the p2p engine attaches, so a + /// request in that window has genuinely NOT been checked against the peer network yet — + /// distinct from `RESOURCE_UNAVAILABLE`, which means the peer tier WAS consulted (or there is + /// none) and the content is still not found. Reporting `-32004` here would tell a caller "not + /// found" for content this node simply has not finished asking about; this code tells the + /// caller to retry shortly instead. Transient/retryable. Shell error. + EngineWarming, /// `-32010` — the blind-passthrough relay to the upstream DIG RPC failed /// (unreachable / non-JSON). Dig-node-shell error distinguishing a local /// proxy failure from an upstream-returned JSON-RPC error. @@ -803,6 +811,10 @@ impl ErrorCode { ErrorCode::ResourceUnavailable => { shared(dig_rpc_protocol::ErrorCode::ResourceUnavailable) } + // Not in the shared `dig_rpc_protocol` catalogue: dig-node-service-only, so minted + // as a plain literal like the wallet/control bands below rather than restated from a + // shared source that does not define it. + ErrorCode::EngineWarming => -32002, ErrorCode::UpstreamError => shared(dig_rpc_protocol::ErrorCode::UpstreamError), ErrorCode::Unauthorized => shared(dig_rpc_protocol::ErrorCode::Unauthorized), ErrorCode::NotSupported => shared(dig_rpc_protocol::ErrorCode::NotSupported), @@ -844,6 +856,7 @@ impl ErrorCode { ErrorCode::ResourceUnavailable => { dig_rpc_protocol::ErrorCode::ResourceUnavailable.machine_code() } + ErrorCode::EngineWarming => "ENGINE_WARMING", ErrorCode::UpstreamError => dig_rpc_protocol::ErrorCode::UpstreamError.machine_code(), ErrorCode::Unauthorized => dig_rpc_protocol::ErrorCode::Unauthorized.machine_code(), ErrorCode::NotSupported => dig_rpc_protocol::ErrorCode::NotSupported.machine_code(), @@ -878,6 +891,8 @@ impl ErrorCode { | ErrorCode::ControlIngressLimited // The audit record is a node-private FILE read by the shell, not by the node. | ErrorCode::SpendAuditUnreadable + // Minted by the shell's dispatch gate itself, before the read path is ever asked. + | ErrorCode::EngineWarming | ErrorCode::ParseError => "shell", ErrorCode::MethodNotFound => "boundary", // The wallet balance read (#1851) is served by the node-custodied wallet backend. @@ -927,6 +942,10 @@ impl ErrorCode { "relayed upstream did.", ) } + ErrorCode::EngineWarming => { + "Peer tier not yet attached; retry. The request arrived before the p2p engine \ + finished attaching to the HTTP surface." + } ErrorCode::UpstreamError => { "The blind-passthrough relay to the upstream DIG RPC failed." } @@ -984,6 +1003,7 @@ impl ErrorCode { ErrorCode::InvalidParams, ErrorCode::DispatchFailed, ErrorCode::ResourceUnavailable, + ErrorCode::EngineWarming, ErrorCode::UpstreamError, ErrorCode::Unauthorized, ErrorCode::NotSupported, From 6d8d7fce778df2120dc086111beed8daf6ca8a01 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 7 Sep 2026 00:57:13 -0700 Subject: [PATCH 07/10] fix(melt): delete the entry the held-check matched, not a re-cased path (dig_ecosystem#2147) CI (ubuntu, case-sensitive filesystem) failed store_melted::tests::the_real_cache_deletes_a_mixed_case_store_directory: delete_all_generations reported 0 removed for a store held_store_ids still saw. `cache_remove_cached` resolved the unlink path through `CapsuleKey::resolve_cached_path`, which (dig_ecosystem#2147) lower-cases both hex components for identity -- correct for Eq/Hash, wrong for a filesystem path. A directory a pre-#2147 binary wrote in mixed case is only findable by its exact on-disk casing; the lower-cased path named a directory that was never there, so the unlink silently no-op'd while the node had already announced the store melted. `cache_remove_cached` now tries the caller's raw hex casing first (matching whatever a held-check just matched), falling back to `CapsuleKey`'s lower-cased path for a caller that spells a canonically-lower-case (post-2147) directory in different case. Windows (case-insensitive) could not have caught this; the fix is verified by re-running the CI-reported test, not locally. Refs dig_ecosystem#2090 --- .../src/seams/capsule/capsule_store.rs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-core/src/seams/capsule/capsule_store.rs b/crates/dig-node-core/src/seams/capsule/capsule_store.rs index 6de87890..10f6fb6c 100644 --- a/crates/dig-node-core/src/seams/capsule/capsule_store.rs +++ b/crates/dig-node-core/src/seams/capsule/capsule_store.rs @@ -81,6 +81,35 @@ pub(crate) fn list_cached_capsules(modules_root: &std::path::Path) -> Vec std::path::PathBuf { + let build = |ext: &str| { + cache_dir + .join("modules") + .join(store_hex) + .join(format!("{root_hex}.{ext}")) + }; + let unified = build(crate::capsule_key::CACHED_MODULE_EXT); + if unified.exists() { + return unified; + } + let legacy = build(crate::capsule_key::LEGACY_MODULE_EXT); + if legacy.exists() { + return legacy; + } + unified +} + /// Seam 6 (capsule management) — the node's on-disk `.dig` capsule cache: list/remove/fetch a held /// capsule, gap-fill a missing chain-confirmed generation, and the self-reference plumbing that lets /// `&self` read handlers spawn an owned background backfill. @@ -249,7 +278,23 @@ impl CapsuleStore for Node { }; // Remove whichever artifact is on disk — the current `.dig` or a legacy `.module` (#1896) — so // a removal on a not-yet-migrated cache still clears the holder claim. - let path = capsule.resolve_cached_path(&self.cache_dir); + // + // Tried in TWO casings (dig_ecosystem#2147/#2090). First the caller's RAW hex casing: on a + // case-sensitive filesystem (dig-node runs on Linux), a directory a pre-#2147 binary wrote in + // mixed case is findable ONLY by the exact casing the held-check (`held_store_ids`, which + // DECODES hex rather than text-comparing) matched — `CapsuleKey`'s identity-normalized + // lower-case path would name a directory that was never on disk, and the delete would + // silently no-op while the node kept serving content it had just announced melted. Then, if + // that misses, `CapsuleKey`'s lower-cased path: a caller may pass hex in a DIFFERENT casing + // than the (post-#2147, canonically lower-case) directory actually on disk, and that case must + // still resolve — `CapsuleKey::parse` is what makes one 32-byte identity match regardless of + // how a caller spells it. + let raw = resolve_cached_path_raw_case(&self.cache_dir, store_id_hex, root_hex); + let path = if raw.exists() { + raw + } else { + capsule.resolve_cached_path(&self.cache_dir) + }; let _guard = self.cache_lock.lock().await; if !path.exists() { From 1ff0eff8a15c2e33331fb356d72efe1a4ce2cd17 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 7 Sep 2026 01:24:18 -0700 Subject: [PATCH 08/10] chore(deps): bump dig-* deps to latest (dig_ecosystem#3212) dig-stun 0.1 -> 0.2, chia-query 0.24.1 -> 0.24.3, dig-nat 0.21.1 -> 0.21.2, dig-logging 0.2.1 -> 0.2.2 (fixes the --version writability probe, dig_ecosystem#2110). NOT bumped, reverted after breaking compilation and reported to the parent: dig-dht 0.15 -> 0.16 (dig-download 0.22.1, pinned at latest, still requires dig-dht ^0.15 -- no shim); dig-node-control-interface 0.33 -> 0.35 (new required fields on MirrorBondState/MirrorBondStatesResult hit dig-node-service/src/mirror/states.rs, owned by #573, and control_cli.rs, outside this lane's file set). --- Cargo.lock | 47 +++++++++++++++--------------- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-service/Cargo.toml | 2 +- crates/dig-wallet/Cargo.toml | 2 +- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32c264ba..5346010a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -911,9 +911,9 @@ dependencies = [ [[package]] name = "chia-query" -version = "0.24.1" +version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82677c6aba0319eafe8808f253bc0aa34539bfcef8b82c9eb35339bfe71dfcc" +checksum = "79770ff86342fba33d9a9384090d4f3f770e769427cd09d442bf1114d924df32" dependencies = [ "async-trait", "chia-bls 0.36.1", @@ -1948,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]] @@ -2830,9 +2830,9 @@ dependencies = [ [[package]] name = "dig-logging" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbdc1dfe4b2588532a64845ef6ee2826d08a7b0e2d624cb3b92564c295f53c8" +checksum = "20caead0416fcbfdc4cf04fae1b137e9ca0f8c74e4f5d8652bee1eb3dfa50406" dependencies = [ "bip39", "clap", @@ -2920,9 +2920,9 @@ dependencies = [ [[package]] name = "dig-nat" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a3fc5d85d0009b2e7d1e07b7e17bb174db17fb12559be3cda8383dee23cefc8" +checksum = "6d9b5c5aec7827412fb596d633718c11c53fcc0b5aa7d1854517c8d4973b92de" dependencies = [ "arc-swap", "async-trait", @@ -2930,6 +2930,7 @@ dependencies = [ "dig-constants 0.11.2", "dig-identity", "dig-ip", + "dig-stun", "dig-tls", "futures", "futures-util", @@ -3293,9 +3294,9 @@ dependencies = [ [[package]] name = "dig-stun" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97ff6a1425d399f188ae70978a61cdff48930fd49b122cadc5fd0375a22f3c0" +checksum = "b6a980e520a5ceccc776b01e860bae3e0fe0e64037ee01b97774d20e385fb025" dependencies = [ "ring", "thiserror 2.0.20", @@ -3751,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3899,7 +3900,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 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4786,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5168,7 +5169,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]] @@ -5779,7 +5780,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -5817,9 +5818,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]] @@ -6569,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7019,7 +7020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7281,7 +7282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8415,7 +8416,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/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index fdf78c43..c7320e10 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -300,7 +300,7 @@ dig-nat = "0.21" # (`dig-node-service`'s `PublicAddress::corroborated_addresses`) is being retired in favour of this: # a second hand-rolled implementation of a security primitive is exactly the rival CLAUDE.md's # "centralize rival implementations" rule exists to catch. -dig-stun = "0.1" +dig-stun = "0.2" # dig-gossip is the ONE peer-stack exception: it stays a git dependency PINNED to a release commit # (dig_ecosystem#2647, NC-7 exception) — here v0.32.0 (rev 1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee). # It cannot be published to crates.io while its `native-tls` [patch.crates-io] fork stands, because diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 56b11a50..3666c163 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -114,7 +114,7 @@ dig-mirror-coin = "0.9" # classes, global-unicast) before `mirror::advertise::PublicAddress` will let one reach a coin. # Replaces this crate's own pairwise `corroborated_addresses` check, which could not fail closed on # a dissenting THIRD source and did not distinguish source CLASSES from bare source strings. -dig-stun = "0.1" +dig-stun = "0.2" # The canonical `ChainSource` trait `dig-mirror-coin`'s census is generic over. Declared, not # implemented: `chia-query` already provides the implementation this node uses diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index b8cad9fb..922debf4 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -177,7 +177,7 @@ sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runti # affect, so a stale coin-state cache should be checked against #61 before it is treated as a # mystery. Unrelated, also open: chia-query#62, `eject_peer` leaking the ejected peer's reader # task and socket. -chia-query = "0.24.1" +chia-query = "0.24.3" # Diagnostics MUST go through `tracing`, never stderr. dig-node installs the `dig-logging` # subscriber process-globally (dig-node-service::logging), and a Windows service has no stderr # to discard to — an `eprintln!` here reaches nobody, which is why a chain-source failure stayed From 899cc68f0c5681f484b11f99c62cedca2b93db7f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 7 Sep 2026 01:53:49 -0700 Subject: [PATCH 09/10] fix(serve): derive window completeness from the bytes read, not an earlier stat (dig_ecosystem#2148) get_capsule stat'd the module BEFORE calling read_module_window, which stats it AGAIN internally to clamp the window. If the module grew between the two stats, the window (sized against the fresher, larger total) could carry more bytes than the caller's stale total accounted for, so windowed_envelope's `end >= total` could mis-answer complete/next_offset for content the module had already outgrown -- next_offset == offset, the reported symptom. read_module_window now returns (window, total) -- total FROM the same read that produced the window, not a second, independently-timed source of truth. get_capsule no longer stats separately; the other two callers (read_held_module_window, peer.rs's peer-facing module range serve) only ever needed the bytes and now discard the total. Regression test: total_tracks_growth_between_two_reads_of_the_same_module, proving a second read after the module grows reports the CURRENT size, not the first read's. lib.rs and peer.rs are in scope: dig-node#573 merged into develop (squash 93fb4528) before this commit, so their prior read-only status has lifted. --- crates/dig-node-core/src/lib.rs | 19 ++--- crates/dig-node-core/src/peer.rs | 1 + .../src/seams/dig_peer/module_serve.rs | 82 +++++++++++++++++-- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 9046f0f7..47010d7b 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -3081,6 +3081,7 @@ impl Node { seams::dig_peer::module_serve::read_module_window( &cache_dir, &store, &root, offset, length, ) + .map(|(window, _total)| window) }) .await .unwrap_or(None) @@ -3644,16 +3645,14 @@ impl Node { let cache_dir = self.cache_dir.clone(); let (read_root, echo_root) = (root_hex.clone(), root_hex); let read = tokio::task::spawn_blocking(move || { - let capsule = CapsuleKey::parse(&store_hex, &read_root)?; - // `total_length` comes from the file's METADATA, not from a buffer — the whole point is - // that no buffer of the whole module ever exists. - let total = std::fs::metadata(capsule.resolve_cached_path(&cache_dir)) - .ok()? - .len(); - if total == 0 { - return None; - } - let window = crate::seams::dig_peer::module_serve::read_module_window( + // `total` comes back from the SAME read that produced `window` (dig_ecosystem#2148), + // rather than a separate stat taken before it: a stat-then-read gap lets the module grow + // in between, so a window sized against the FRESHER on-disk length could carry more bytes + // than an earlier, staler `total` would account for — `end >= total` would then answer + // `complete`/`next_offset` one write ahead of what this window actually contains, or a + // `total_length` a client uses to size its reassembly buffer (#2071) could already be + // wrong on arrival. + let (window, total) = crate::seams::dig_peer::module_serve::read_module_window( &cache_dir, &store_hex, &read_root, diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 4a53e10d..e83127bc 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1645,6 +1645,7 @@ impl PeerRpcResponder for NodeResponder { async move { tokio::task::spawn_blocking(move || { module_serve::read_module_window(&cache, &s, &r, offset, length) + .map(|(window, _total)| window) }) .await .unwrap_or(None) diff --git a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs index aa74ef97..f2bc2c2a 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs @@ -178,20 +178,31 @@ pub fn describe_module(cache_dir: &Path, store_hex: &str, root_hex: &str) -> Opt Some(info) } -/// Read the `[offset, offset+length)` window of a locally-held module. +/// Read the `[offset, offset+length)` window of a locally-held module, returning `(window, total)` +/// where `total` is the module's size AT THE TIME OF THIS READ. /// /// Returns `None` when the module is not held. An offset at or past the end yields an EMPTY window /// rather than an error: the window is a byte range over a content-addressed blob, and the caller's own /// chunk-hash check is what decides whether what arrived is what it asked for. /// /// `length` is clamped to [`MAX_MODULE_WINDOW`] — a serve never lets one request size its own work. +/// +/// `total` is returned rather than left for the caller to stat separately (dig_ecosystem#2148): a +/// caller that stats the file BEFORE calling this, then builds `complete`/`next_offset`/ +/// `total_length` from that earlier number, can disagree with the window this function actually read +/// if the module grew in between — the window (sized against a FRESHER stat) can carry more bytes +/// than the caller's stale `total` allows for, so `end >= total` claims completion (or `next_offset` +/// bookkeeping otherwise drifts) one write ahead of what was actually served. This is the same class +/// of defect #2071 was: a client acting on a `total_length`/`complete` pair that does not describe +/// the bytes it was actually just handed. Handing back the stat this read itself used is what keeps +/// caller and window looking at the SAME number. pub fn read_module_window( cache_dir: &Path, store_hex: &str, root_hex: &str, offset: u64, length: u64, -) -> Option> { +) -> Option<(Vec, u64)> { use std::io::{Read, Seek, SeekFrom}; let capsule = CapsuleKey::parse(store_hex, root_hex)?; @@ -201,6 +212,9 @@ pub fn read_module_window( // actually asked for are ever pulled off disk. let mut file = std::fs::File::open(capsule.resolve_cached_path(cache_dir)).ok()?; let total = file.metadata().ok()?.len(); + if total == 0 { + return None; + } let start = offset.min(total); let want = length.min(MAX_MODULE_WINDOW).min(total - start); file.seek(SeekFrom::Start(start)).ok()?; @@ -208,7 +222,7 @@ pub fn read_module_window( file.read_exact(&mut window).ok()?; #[cfg(test)] record_module_bytes_read(root_hex, window.len() as u64); - Some(window) + Some((window, total)) } /// Test-only tally of bytes pulled off disk by [`read_module_window`], keyed by ROOT. @@ -492,9 +506,62 @@ mod tests { let (store, root) = (hex_id(5), hex_id(6)); let bytes: Vec = (0..300u32).map(|i| i as u8).collect(); let dir = cache_with(&bytes, &store, &root); + let (window, total) = read_module_window(dir.path(), &store, &root, 100, 50).expect("held"); + assert_eq!(window, bytes[100..150]); + assert_eq!( + total, + bytes.len() as u64, + "total is the module's real on-disk size" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **Proves:** `total` always describes the SAME read that produced `window` — the module can grow + /// between two calls, and each call's `total` tracks its OWN read, never a value left over from an + /// earlier stat (dig_ecosystem#2148). + /// + /// **Catches:** the shipped defect: `get_capsule` used to stat the file ONCE, up front, then call + /// `read_module_window` (which stats AGAIN, internally) and build `complete`/`next_offset` from the + /// stale, pre-read number. If the module grew in between, the window it actually served could + /// already reach past that stale `total`, so `end >= total` claimed completion (or otherwise + /// mis-set `next_offset`) for content the module had already outgrown — `next_offset == offset`, + /// the shipped symptom. Returning `total` FROM the read that used it removes the second, drifting + /// source of truth: there is no longer an earlier stat left to go stale. + #[test] + fn total_tracks_growth_between_two_reads_of_the_same_module() { + let (store, root) = (hex_id(15), hex_id(16)); + let first_bytes = vec![1u8; 50]; + let dir = cache_with(&first_bytes, &store, &root); + + let (_window, total_before) = + read_module_window(dir.path(), &store, &root, 0, 50).expect("held"); assert_eq!( - read_module_window(dir.path(), &store, &root, 100, 50).expect("held"), - bytes[100..150] + total_before, 50, + "first read sees the module as it was written" + ); + + // The module grows (a peer sync landed a larger generation) BETWEEN the two reads. + let path = module_path(dir.path(), &store, &root); + let grown_bytes = vec![2u8; 200]; + std::fs::write(&path, &grown_bytes).unwrap(); + + let (window_after, total_after) = + read_module_window(dir.path(), &store, &root, 0, 50).expect("held"); + assert_eq!( + total_after, 200, + "the second read's total tracks the module's CURRENT size, not the first read's" + ); + assert_eq!(window_after, grown_bytes[0..50]); + // The old defect: a caller that built `complete`/`next_offset` from `total_before` (50) against + // a window ending at `offset + window.len()` == 50 would read `end >= total_before` as + // complete, reporting `next_offset: null` for a module that is actually 200 bytes long. With + // `total` sourced from THIS read, a caller correctly sees `end (50) < total_after (200)` and + // keeps paging. + let start = 0u64; + let end = start + window_after.len() as u64; + assert!( + end < total_after, + "a fresh total must show more content remains, not falsely claim completion" ); let _ = std::fs::remove_dir_all(&dir); } @@ -510,11 +577,13 @@ mod tests { // Past the end: an empty window, not an error and not a wrapped read. assert!(read_module_window(dir.path(), &store, &root, 1_000, 10) .expect("held") + .0 .is_empty()); // Absurd length: clamped to what exists. assert_eq!( read_module_window(dir.path(), &store, &root, 0, u64::MAX) .expect("held") + .0 .len(), 100 ); @@ -535,7 +604,8 @@ mod tests { let offset = MAX_MODULE_WINDOW * 2 + 17; let want = 4096u64; - let window = read_module_window(dir.path(), &store, &root, offset, want).expect("held"); + let (window, _total) = + read_module_window(dir.path(), &store, &root, offset, want).expect("held"); assert_eq!(window.len(), want as usize); assert_eq!(window, bytes[offset as usize..(offset + want) as usize]); From dfca301e2b5128218e41ae77701cfe0dfadafe22 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 7 Sep 2026 02:40:45 -0700 Subject: [PATCH 10/10] chore(release): v0.255.0 (dig_ecosystem#3212) Single semver bump for the develop -> main batch: #3212 serve-path children (PR #586) + dig-node#570 daily mirror reconcile (PR #573). --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5346010a..0a7478c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.89" +version = "0.255.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index e785eeb0..1d44df4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # 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.254.89" +version = "0.255.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard.