Skip to content
Draft
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
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.206.0"
version = "0.207.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
57 changes: 57 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8204,6 +8204,11 @@ 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 implemented** — `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`). What is verified 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 @@ -8555,6 +8560,58 @@ 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; a record may carry an honest holder's peer id, that
holder's real coin id, and the attacker's addresses. 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. A dialler is not a backstop for this: peer ids are derived from the
presented certificate rather than pinned against the dialled identity.

**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)` it answered, because one coin
bonds one tuple; caching by coin id alone would let a genuine bond answer for content the same coin
does not bond, which is the substitution `advertises` exists to refuse. 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
110 changes: 109 additions & 1 deletion crates/dig-node-core/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,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 @@ -1250,6 +1253,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 @@ -1334,6 +1344,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 @@ -1536,6 +1547,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 @@ -2522,7 +2547,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 @@ -4373,6 +4398,89 @@ 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_ranks_a_disproven_bond_last_on_its_own_discovery_path() {
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![
claimed(7, None), // claims nothing -> Unverified
claimed(8, Some([0x02; 32])), // claims a coin bonding something else
claimed(9, Some([0x01; 32])), // claims a coin that really bonds this
])),
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(7)[..2].to_string(),
mock_peer_hex(8)[..2].to_string(),
],
"the provable holder is promoted; the other two keep their located order, because a \
disproven pointer withholds credit rather than demoting (dig-node#466)"
);
assert_eq!(
located.len(),
3,
"a disproven claim is demoted on the redirect path, never withheld 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