Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4ebb0bb
chore(mirror): open the mirror-coin bond verifier lane (#466)
MichaelTaylor3d Aug 31, 2026
fe04b91
feat(mirror): rank located holders by their verified mirror-coin bond…
MichaelTaylor3d Aug 31, 2026
eceea18
feat(mirror): verify a claimed mirror coin against chain and install …
MichaelTaylor3d Aug 31, 2026
37c92a3
docs(spec): state how a node acts on another peer's mirror-coin claim…
MichaelTaylor3d Aug 31, 2026
f76c10b
style: cargo fmt the mirror-coin bond verifier (#466)
MichaelTaylor3d Aug 31, 2026
3b978d9
test(mirror): prove the bond ranking is live on the engine's own disc…
MichaelTaylor3d Aug 31, 2026
e16a6c4
Merge origin/main into loop/mc-verify (#466)
MichaelTaylor3d Aug 31, 2026
e21f617
fix(mirror): rank mirror-coin bonds by credit only, bound to the clai…
MichaelTaylor3d Aug 31, 2026
e59548a
fix(mirror): split the chain half from the peer gate so the conforman…
MichaelTaylor3d Sep 1, 2026
d3a6ae1
Merge origin/main into loop/mc-verify (#466)
MichaelTaylor3d Sep 1, 2026
426731d
Merge remote-tracking branch 'origin/main' into HEAD
MichaelTaylor3d Sep 1, 2026
54e31f2
fix(mirror): key the bond verdict on its claimant and stop paying for…
MichaelTaylor3d Sep 1, 2026
ece659a
chore: merge main and take 0.221.0
MichaelTaylor3d Sep 1, 2026
12bc553
Merge remote-tracking branch 'origin/loop/mc-verify' into loop/mc-verify
MichaelTaylor3d Sep 1, 2026
e8b9041
chore: merge main (#477) and hold 0.221.0
MichaelTaylor3d Sep 1, 2026
acfda06
chore: merge main (0.229.0), take 0.232.0, fix clippy let_and_return
MichaelTaylor3d Sep 1, 2026
11b3dd2
chore(lock): re-resolve the workspace after the 0.232.0 bump
MichaelTaylor3d Sep 1, 2026
32864a2
docs(mirror): say that bond verification is built but inert
MichaelTaylor3d Sep 1, 2026
18403cc
Merge remote-tracking branch 'origin/main' into loop/mc-verify
MichaelTaylor3d Sep 1, 2026
68c946d
chore: merge origin/main (0.235.0) into loop/mc-verify
MichaelTaylor3d Sep 2, 2026
90311cf
chore(release): dig-node 0.236.0 -- re-bump past #487 (0.235.0)
MichaelTaylor3d Sep 2, 2026
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
4 changes: 2 additions & 2 deletions 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.235.0"
version = "0.236.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
92 changes: 92 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8332,6 +8332,17 @@ itself (SYSTEM.md §4.1).
> by name BEFORE any chain read (dig-node#426). **RECLAIMS are implemented** and are supported at `fee = 0` with
> no fee coins, which is §25.4.4 — and are never gated on any funding read, including the
> committed-coin read.
> * **§25.10's verification of OTHER peers' claims is BUILT BUT INERT — no claim is verified on a
> running node today.** The mechanism is present and wired: `dig-node-core`'s `mirror_bond` (the
> three verdicts and the ranking locator, installed inside `NodeContent::new`) and
> `dig-node-service`'s `mirror/bond_verify.rs` (the chain read, installed on the running node by
> `spawn_bond_verifier_install`). But this node has no sound source for the coin-to-peer binding
> §25.6a requires, so `bonded` is unreachable for every input, the chain read is short-circuited
> before it is paid, and every holder receives the same verdict — the ranking is a no-op on every
> slate. **A reader must not take this bullet as saying collateral is enforced; it is not.** The
> binding is tracked as <https://github.com/DIG-Network/dig-node/issues/473>, and promotion
> becomes reachable the moment it lands, with no further change here. What is verified once it does
> is a peer's claim; this node still attaches no pointer of its own, per the next bullet.
> * **§25.6's DHT pointer is not attached.** `ProviderRecord::unverified_mirror_coin_id` lives in
> dig-dht 0.15, and `dig-download` 0.21.0 and `dig-peer-selector` 0.10.0 both require
> `dig-dht ^0.13` — semver-incompatible on a `0.x` line, so taking 0.15 here would resolve two
Expand Down Expand Up @@ -8726,6 +8737,87 @@ collateral, and `MirrorCoin::advertises(store, root, epoch)` passes — an exact
declared tuple plus a recomputed hint, which is what defeats the constructible additive-morph
collision (the epoch term is freely chosen, so hint equality alone proves nothing).

### 25.6a. Acting on another peer's claim

A node that LOCATES a holder verifies that holder's claimed bond and **promotes a proven one**. The
verdict has three states, which are never collapsed into two, but the ranking has exactly TWO tiers:

| verdict | established | ranking |
|---|---|---|
| bonded | the named coin passes every §25.6 check for this exact `(store, root, epoch)` AND declares the peer claiming it | promoted |
| unverified | no pointer was published, the chain could not answer, this node holds no censused requirement for the epoch, or the coin does not declare the claimant | baseline, position unchanged |
| unbonded | the chain answered and the claim is false | baseline, position unchanged |

**Ranking gives credit; it MUST NOT take credit away.** A provider record is hearsay — whoever
answers a lookup chooses every field of it, including a coin id it attributes to somebody else — so a
disproven pointer MUST NOT rank a holder below where no pointer at all would have put it. Otherwise
attaching a bogus coin id to an honest holder's record would be a demotion primitive available to any
stranger at no cost. Withholding credit has no such abuse: the most a liar achieves is the ranking
that would have existed had it said nothing.

**A coin id proves the bond, never the bearer.** A coin id is a public fact, so a coin that bonds the
content says nothing about WHO is offering it. Promotion therefore additionally requires the coin's
own owner-written declaration of the claiming `peer_id`, and a node that cannot read such a
declaration MUST NOT promote.

That declaration closes coin substitution — a stranger republishing another's coin id under its OWN
peer id earns nothing, because the coin does not name it. **It does NOT close address substitution,
and a node MUST NOT treat it as though it did.** A record may carry an honest holder's peer id, that
holder's real coin id, and the ATTACKER's addresses: the coin binds coin to `peer_id`, never `peer_id`
to an address, and a provider record's `peer_id`-to-address association is unauthenticated hearsay
that no chain read can settle. Such a record satisfies the declaration check and would be promoted on
the strength of somebody else's bond.

Closing that requires a separate restriction, which a node performing promotion MUST apply: promote
only from a record whose `peer_id`-to-address association is itself authoritative — a first-hand
announce from the peer being ranked, not a slate forwarded by a third party — or defer the credit
until the dialled identity has been checked against the claimed `peer_id`. A dialler is not by itself
a backstop, because peer ids are derived from the presented certificate rather than pinned against
the dialled identity; the residual an unrestricted implementation carries is traffic redirection, not
a stolen bond.

**One locate is bounded work.** The size of a located set is chosen by whoever answered the lookup,
so a node MUST bound the number of bonds it reads against a chain per locate, verifying in source
order and leaving the remainder at baseline.

The verification is performed in the ORDER §25.6 states, with one refinement that is normative: the
`advertises` binding is checked BEFORE the collateral magnitude. A node that has not censused the
epoch cannot price a bond, and checking magnitude first would make every verdict on such a node
`unverified` — including a holder pointing at a coin that plainly bonds a different store.

**A holder is never refused, dropped, or blocklisted on a verdict.** A chain outage, an epoch
rollover, a republished record carrying a pointer that has since gone stale, and a deliberate lie are
indistinguishable at the moment of reading, and only one of them is an attack; a node that refuses on
any of them converts its own partition into a rejection of honest peers. Promotion is the whole
remedy: a holder that proves its bond is served first, and every other holder keeps exactly the
standing its source gave it.

Absence of a pointer is the ORDINARY case and MUST cost no chain read at all. `unverified` for an
absent pointer is not a degraded answer — it is the honest state of a claim nobody looked at.

A verdict is cached only for the exact `(coin id, store, root, epoch, claiming peer id)` it answered.
Every component is load-bearing. One coin bonds one `(store, root, epoch)` tuple, so caching by coin
id alone would let a genuine bond answer for content the same coin does not bond — the substitution
`advertises` exists to refuse. And the verdict is peer-DEPENDENT: it is `bonded` only when the coin
declares the peer offering the record, so a key omitting the claiming peer id would serve one
holder's earned `bonded`, for the whole cache lifetime, to any stranger republishing the same
publicly-visible coin id — reinstating through the memo the substitution the ownership question
exists to refuse. The cache MUST also be probed under the node's TRUE current epoch rather than a
remembered one, or a probe taken after a rollover hits the entry stored under the previous epoch and
returns a verdict taken under the wrong one.

While the node has no sound source for the coin-to-peer binding, `bonded` is unreachable for every
input, and the verifier MUST then read no chain at all: the reads would be paid, at a third party, for
a verdict the credit-only ranking provably discards. That short-circuit MUST be conditioned on the
binding source itself, so that it lifts when the source arrives rather than needing a second switch
to be remembered.

Only DEFINITE verdicts are
cached: `unverified` records this node's own momentary inability to look, and holding it would keep
an outage in force after it had ended. The cache is keyed partly on attacker-chosen input, so it MUST
be bounded, and overflow MUST evict rather than clear — clearing would let a stranger discard every
verdict a node has earned by rotating coin ids.

### 25.7. Consent, the switch, and revocation

> **PARTIALLY PENDING.** The switch itself is real — it persists in `collateral.json`, defaults on,
Expand Down
2 changes: 1 addition & 1 deletion crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.65.0"
version = "0.66.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."
Expand Down
117 changes: 116 additions & 1 deletion crates/dig-node-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,9 @@ pub struct NodeContent {
/// [`SelfExcludingLocator`] (#1584), so discovery is already self-filtered — this node's own
/// `peer_id` never appears as a holder — but is otherwise unranked.
locator: Arc<dyn ProviderLocator>,
/// The mirror-coin bond verifier the discovery chain ranks by (dig-node#466), installed once by
/// the host binary through [`Self::set_bond_verifier`] when its chain source exists.
bond_verifier: crate::mirror_bond::BondVerifierSlot,
/// The self-optimizing peer selector (#178) — the decision + learning brain between discovery and
/// download. It ranks the download sources (bridged into dig-download's [`SourceSelector`] seam by
/// [`SelectorAdapter`], #1442) and learns from every range outcome dig-download reports back
Expand Down Expand Up @@ -1289,6 +1292,13 @@ impl NodeContent {
self_peer_id: Option<String>,
cache_dir: &Path,
) -> Arc<Self> {
// The mirror-coin bond layer (dig-node#466) sits OUTSIDE every other locator, so both the
// raw discovery leg kept on the engine and the download union built from it below inherit
// one ranking. Its verifier arrives later (the host binary owns the chain source), and until
// it does the layer is a pass-through.
let bond_verifier = crate::mirror_bond::bond_verifier_slot();
let locator: Arc<dyn ProviderLocator> =
crate::mirror_bond::BondRankingLocator::new(locator, bond_verifier.clone());
let downloads_dir = cache_dir.join("downloads");
let _ = std::fs::create_dir_all(&downloads_dir);
let state_store = Arc::new(CapturingStateStore::new(FileStateStore::new(
Expand Down Expand Up @@ -1373,6 +1383,7 @@ impl NodeContent {
let ask_routing = AskRoutingState::new(self_peer_id.as_deref());
Arc::new(NodeContent {
locator,
bond_verifier,
selector,
downloader,
state_store,
Expand Down Expand Up @@ -1575,6 +1586,20 @@ impl NodeContent {
content
}

/// Install the mirror-coin bond verifier the discovery chain ranks holders by (dig-node#466).
///
/// Idempotent and one-way: the first call wins and later ones are ignored, so a node's
/// verification posture cannot change under a running download. Before it is called the layer is
/// a pass-through, which is the shipped behaviour of every embedder that has no chain source.
///
/// Returns whether this call was the one that installed it.
pub fn set_bond_verifier(
&self,
verifier: Arc<dyn crate::mirror_bond::MirrorBondVerifier>,
) -> bool {
self.bond_verifier.set(verifier).is_ok()
}

/// The configured miss behavior (redirect by default; fetch-through when opted in).
pub fn miss_mode(&self) -> MissMode {
self.miss_mode
Expand Down Expand Up @@ -2561,7 +2586,7 @@ impl crate::Node {
}

/// The attached P2P content engine, if the peer network brought one up.
pub(crate) fn p2p_content(&self) -> Option<&Arc<NodeContent>> {
pub fn p2p_content(&self) -> Option<&Arc<NodeContent>> {
self.p2p_content.get()
}

Expand Down Expand Up @@ -4412,6 +4437,96 @@ pub(crate) mod tests {
.is_err());
}

/// **Proves (dig-node#466):** the bond ranking is live on the ENGINE's own discovery path —
/// `NodeContent::find_providers`, the source the redirect-on-miss handler names holders from —
/// and not merely inside a locator a test assembled for itself.
///
/// **Catches:** the exact state this ticket exists to end. `BondRankingLocator` can be perfect
/// and still be reachable from nothing; the whole point of #466 is that a verifier with no
/// consumer changes nothing. This test builds the engine through its real constructor and asks
/// it, so a wiring that silently dropped the layer fails here even with every unit test in
/// `mirror_bond` green.
///
/// The slate's input order is neither the expected answer nor its reverse, so an engine that
/// ignored the verdicts entirely cannot pass by coincidence.
#[tokio::test]
async fn the_engine_promotes_a_proven_bond_without_sinking_a_disproven_one() {
use crate::mirror_bond::{BondVerdict, MirrorBondVerifier};

struct ByFirstByte;

#[async_trait::async_trait]
impl MirrorBondVerifier for ByFirstByte {
async fn verify(
&self,
_c: &ContentId,
_claiming_peer_id: &str,
claimed: Option<[u8; 32]>,
) -> BondVerdict {
match claimed {
None => BondVerdict::Unverified,
Some(coin) if coin[0] == 0x01 => BondVerdict::Bonded,
Some(_) => BondVerdict::Unbonded,
}
}
}

let td = tempfile::tempdir().unwrap();
let cid = mock_content_id();
let claimed = |peer: u8, coin: Option<[u8; 32]>| {
let record = mock_provider(peer, &cid);
match coin {
Some(id) => record.with_unverified_mirror_coin_id(id),
None => record,
}
};

let pc = NodeContent::new(
Arc::new(MockProviderLocator::fixed(vec![
// The DISPROVEN record is placed ahead of the merely-unverified one on purpose.
// Credit-only keeps 8 before 7 (one baseline tier, stable sort); a three-tier
// lattice that sank `Unbonded` would answer 9,7,8. Without this ordering the two
// lattices give the identical answer and the assertion below proves nothing about
// which one is implemented.
claimed(8, Some([0x02; 32])), // a coin bonding something else -> Unbonded
claimed(7, None), // claims nothing -> Unverified
claimed(9, Some([0x01; 32])), // a coin that really bonds this -> Bonded
])),
Arc::new(MockRangeTransport::new(anchored_mock_content(30, 3))),
MissMode::Redirect,
None,
td.path(),
);
assert!(
pc.set_bond_verifier(Arc::new(ByFirstByte)),
"the engine accepts exactly one verifier"
);

let located = pc.find_providers(&cid).await.for_finding();
let peers: Vec<String> = located
.iter()
.map(|r| r.provider_peer_id[..2].to_string())
.collect();

assert_eq!(
peers,
vec![
mock_peer_hex(9)[..2].to_string(),
mock_peer_hex(8)[..2].to_string(),
mock_peer_hex(7)[..2].to_string(),
],
"the provable holder is promoted and the other two keep their located order, so the \
disproven holder STAYS AHEAD of the merely-unverified one. A three-tier lattice \
would answer 9,7,8 here; credit-only answers 9,8,7, because a disproven pointer \
withholds credit rather than demoting (dig-node#466)"
);
assert_eq!(
located.len(),
3,
"a disproven claim is still offered on the redirect path, never dropped from it"
);
}

/// A locator whose walk cannot be performed at all — the network is down, not the content absent.
struct UnreachableLocator;

Expand Down
1 change: 1 addition & 0 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub mod chat;
pub mod dht_sampling;
pub mod download;
pub mod inbound_demand;
pub mod mirror_bond;
mod module_tier_tag;
pub mod peer;
pub mod rate_limit;
Expand Down
Loading
Loading