Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.94.0"
version = "0.96.0"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
112 changes: 112 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2922,6 +2922,118 @@ against the on-chain current root or fails closed — it NEVER trusts an upstrea
a NODE-side gate; clients still verify the returned proof against their own trust root regardless, so
the opt-out only relaxes the node's serve gate for local dev.

### 14.5. Store-melt propagation (receive → on-chain-verify → delete → rebroadcast, #1316)

When a store's CHIP-0035 singleton is MELTED (the store-lifecycle delete), the deletion PROPAGATES
across the peer network so every holder stops hosting the store's `.dig` content and reclaims disk.
The wire is dig-gossip opcode **221** (`STORE_MELTED`, a `StoreMeltedAnnounce`: `store_id`,
advisory `melt_height`, `sender_peer_id`, BLS signature). It is a **public all-peers broadcast**
(Plumtree flood, Bulk priority), **§5.4-EXEMPT** from recipient-sealing: a store deletion is
public-by-nature and addressed to everyone, like L2 consensus gossip. It is mTLS-authenticated +
signed, never recipient-sealed.

**The on-chain melt proof is the SOLE delete authority (NC-9, fail-closed).** A node MUST NEVER delete
unless the chain POSITIVELY confirms the melt — a forged/replayed announcement, or a chain the node
cannot reach, deletes NOTHING. The announcement's signature is attribution/anti-spam ONLY (never delete
authority), and `melt_height` is an advisory hint the receiver never trusts on its face.

A store is confirmed MELTED only by walking its singleton lineage along real COIN PARENTAGE:

1. **Identity + minted.** The launcher coin whose `coin_id == store_id` MUST exist and be SPENT.
`coin_id == store_id` is a 256-bit hash preimage an attacker cannot grind, so it pins the walk to
the real store — never to a look-alike singleton that merely *curries* `launcher_id == store_id`,
which IS forgeable. `spent` proves the store was ever minted (the launcher is spent exactly once,
to create the eve singleton). An UNSPENT launcher is `Live`: not minted yet is the opposite of
melted. This fact discriminates nothing on its own — it holds for every minted store — it anchors
the walk's starting point.
2. **Walk forward by parentage.** At each hop, read the children of the current coin, DISCARD any
returned coin whose `parent_coin_info` is not the current coin (the soundness argument is that
parentage is fixed by which coin was actually spent — taking the server's word for membership of a
"children of X" page would hand that argument back to whatever answered), and follow the
single ODD-amount child (the singleton, whose amount is invariant across generations). An UNSPENT
successor is `Live`. A spent coin with NO successor is `Melted` — the lineage terminated.

A coin's `parent_coin_info` is fixed by which coin was actually spent to create it, so **placing a
coin anywhere in this walk requires spending a generation of the store, which requires the owner's
authority.** The walk is unwritable by anyone but the owner.

Everything else is `Unknown` (fail-closed): any chain read that errors or times out — including
MID-WALK, which MUST NOT read as "the lineage ended here" — more than one odd child (ambiguous; never
guess which continues the lineage), an absent launcher coin, and exceeding the hop ceiling.

**Only a COMPLETELY EMPTY children page may conclude a melt, and never at hop 0.** Both halves are
load-bearing:

- *Hop 0.* A minted launcher always created the eve singleton, so an empty first hop means the answer
is untrustworthy — a non-datastore launcher, or a chain-read implementation that does not implement
the parent-ids query.
- *Completeness.* The children query honours a server-side limit, and truncation surfaces SPENT
records first. Measured on coinset against the sibling hint query: no limit returns 349 records of
which 243 are unspent, while `limit=5` returns 5 records of which **zero** are unspent. A truncated
page that kept an even change coin but dropped the odd successor would read exactly like a
terminated lineage. Requiring the page to be *entirely* empty is what asserts COMPLETENESS instead
of trusting a page — truncation cannot turn a non-empty result set into an empty one short of a
zero limit, which is never sent. Children present with no singleton among them is `Unknown`.

Two cheaper signals MUST NOT be used, both having shipped and been found unsound:

- **`anchored_root() == Ok(None)`** means *no confirmed generation* (fail-closed) everywhere else in
the node, and is produced for a store that is **not minted yet**. A genuine melt does not even
produce it — the lineage walk errors for a tip spent without a datastore child.
- **The `store_id` hint index.** A hint is an unauthenticated `CREATE_COIN` memo over an arbitrary
32-byte value, so ANY party can place a record under ANY store's hint for the price of a dust coin.
Measured against mainnet: of the 53 DataLayer stores, **30 live stores have a completely EMPTY
`store_id` hint index** — their generations are not hinted to `store_id` at all — so for each of
them one planted spent coin would make the index non-empty and entirely spent, indistinguishable
from a terminated lineage. `get_coin_records_by_hint` is also truncatable, and truncation surfaces
spent records first: the exact order that manufactures a false melt. The hint index MUST NOT carry
a delete decision.

**Operator kill switch.** Store-melt propagation MUST be disableable at runtime via
`DIG_NODE_STORE_MELT` (default ON; only an explicit `off`/`0`/`false`/`no` disables it), matching the
shape of `DIG_NODE_BACKFILL_ON_MISS`. This is the node's only path that irreversibly deletes content
in response to chain state, and it propagates, so a fault is correlated across holders rather than
isolated; an operator MUST be able to stop the deleting without downgrading the node. Disabling is
lossless — melted stores simply keep costing disk, and nothing else depends on melt propagation
having run.

**Conformance to real chain state (measured, not derived).** Across all 53 DataLayer launcher coins
on mainnet this rule yields 51 `Live` (deepest lineage 599 generations; 29 stores have their tip one
hop from the launcher) and 1 `Melted` — the one genuinely terminated store, which ends at hop 1. No
lineage produced an ambiguous fork. Because the walk costs one chain read per generation and the
receive path runs per inbound announcement, verdicts are memoised for a short TTL so a flood of
announcements for one held store cannot multiply into repeated walks; a stale verdict can only DELAY
a real melt, never cause a delete.

- **Holder path (the melting node).** For a store the node HOLDS whose singleton the chain confirms
closed, and which is not already tombstoned: delete EVERY held generation (the audited cache-remove
path — path-containment guarded, content-cache invalidating, idempotent), broadcast a signed
`StoreMeltedAnnounce`, and tombstone the store. Live/Unknown ⇒ no-op (retried next tick).
- **Receiver path (a peer).** Per inbound opcode-221 frame, in strictly-increasing cost order (so an
un-held flood is O(local) per message and can never amplify into chain work): (1) **held-check FIRST**
— if the store is not held, drop with NO chain read, NO signature verification, NO rebroadcast; (2)
**tombstone check** — a re-receipt drops with no chain read; (3) **NC-9 on-chain verify** — only a
confirmed melt proceeds (fail-closed on Live/Unknown); (4) **delete + rebroadcast ONCE** — gated on a
compare-and-set into the shared tombstone, so only the holding→deleted transition re-emits (excluding
the sender).
- **Termination.** Each node broadcasts at most once per store (the CAS admits one transition), the
tombstone is set-once (never cleared in-run), and dig-gossip's Plumtree seen-set dedups frames
network-wide — so the epidemic quiesces after every holder has deleted once.
- **§5.1 preserved.** Deleting a CACHED `.dig` is safe: the on-chain anchor is permanent and this
touches no history/anchor. Melt does not rewrite or break any older `.dig` format.

The receive/holder policy is unit-tested against spy seams (chain / cache / broadcast) with the eight
adversarial cases: forged-melt-for-live, chain-error-fail-closed, genuine-melt (delete-all +
rebroadcast-once), never-held (no chain call), already-tombstoned, verify-cost DoS (held-check before
chain), multi-node convergence-terminates, and holder fail-closed on transient error. The chain-fact →
verdict mapping is separately tested against the real chain-read trait with a crafted lineage, whose
mock PANICS if either hint query is touched: a terminated lineage is the ONLY shape that yields
`Melted`; an unspent tip at 1/2/7/60 hops, an unspent launcher, an absent launcher, an empty hop 0, an
even-amount child, an ambiguous two-odd-child fork, a transport failure on the launcher read OR
mid-walk, and an endless lineage all resolve to `Live` or `Unknown`. The composition that broke the
previous design — an empty hint index plus one planted spent coin — is covered explicitly and asserts
`Live`.

---

## 15. FFI — dig-runtime C-ABI (in-process host)
Expand Down
80 changes: 80 additions & 0 deletions crates/dig-node-core/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2877,6 +2877,86 @@ async fn bring_up_dht(
}
};

// Store-melt propagation (#1316, custody-critical): a SECOND inbound receiver drives the
// receive → on-chain-verify → delete → rebroadcast-once handler (piece #3), and a holder watch
// loop deletes + announces THIS node's own on-chain-melted stores (piece #4). Both share ONE
// tombstone, so each store is propagated at most once and the epidemic quiesces. Every delete is
// fail-closed on the chain (NC-9): a forged/replayed announcement or an unreachable chain deletes
// nothing (see `store_melted`).
//
// Both loops are behind ONE operator kill switch (`DIG_NODE_STORE_MELT`, default ON). This is the
// node's only path that irreversibly deletes content in response to chain state, and it
// propagates — so an operator who suspects a fault needs to stop the deleting without downgrading
// the node. Off means melted stores keep costing disk; nothing else depends on this having run.
if !crate::seams::dig_peer::store_melted::store_melt_enabled() {
println!(
"dig-node peer network: store-melt propagation DISABLED (DIG_NODE_STORE_MELT) — this \
node will not delete or relay melted stores"
);
} else {
use crate::seams::dig_peer::store_melted as melt;
let tombstone = melt::TombstoneSet::new();
let chain: Arc<dyn melt::MeltChain> = Arc::new(melt::CoinsetMeltChain::new());
let cache: Arc<dyn melt::MeltCache> = Arc::new(Arc::clone(node));
let broadcaster: Arc<dyn melt::MeltBroadcast> = Arc::new(pool.clone());
match pool.inbound_receiver() {
Ok(inbound) => {
tokio::spawn(melt::run_store_melted_ingest(
inbound,
Arc::clone(&chain),
Arc::clone(&cache),
Arc::clone(&broadcaster),
tombstone.clone(),
));
println!("dig-node peer network: store-melt propagation (opcode 221) up");
}
Err(e) => tracing::warn!(
error = %e,
"store-melt ingest: no inbound receiver; this node still deletes + announces its OWN \
melted stores, but will not relay peers' melts"
),
}
// The holder watch: periodically re-check every held store's singleton and, on a stable
// on-chain melt, delete + announce once. Signs with the node's identity; a node that cannot
// sign still deletes on the receive path, it just cannot originate a melt announcement.
match melt::signer_from_node(node) {
Some(signer) => {
let interval = crate::chainwatch::watch_interval_from_env();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
// A panic inside one tick is CONTAINED (#2067) so the watch survives to the
// next tick instead of silently dying for the process's lifetime. `tick()`
// stays outside the guard, so a persistently-panicking tick paces on the
// interval rather than hot-spinning. Abandoning a tick is fail-closed: the
// store is not tombstoned, so the next tick re-evaluates it from scratch.
//
// `melt_height` is an advisory hint only (receivers verify on-chain, never
// trust it); 0 is a safe placeholder until a peak observer feeds a real height.
let _ = crate::shared::catch_iteration(
"store_melt_holder_watch",
melt::run_melt_tick(
&*chain,
&*cache,
&*broadcaster,
&tombstone,
&signer,
0,
),
)
.await;
}
});
println!("dig-node peer network: store-melt holder watch up");
}
None => tracing::warn!(
"store-melt holder watch disabled: no signable node identity (receive path still deletes)"
),
}
}

// Spawn the maintenance loop: republish (records never lapse) + refresh buckets + gc, well inside
// the provider TTL.
{
Expand Down
1 change: 1 addition & 0 deletions crates/dig-node-core/src/seams/dig_peer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod selector_adapter;
pub mod self_excluding_locator;
pub mod serve_log;
pub mod session;
pub mod store_melted;
pub mod union_locator;

pub(crate) use capsule_fallback::CapsuleFallbackLocator;
Expand Down
Loading
Loading