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.197.0"
version = "0.198.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
45 changes: 41 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8445,6 +8445,35 @@ A pass runs: at start-up (once the wallet and a chain source are available), on
the plan's create set until that entry resolves. The audit record is the in-flight ledger; the
disk and the chain remain the only steady-state truths.

7. **Resolves spends an EARLIER pass broadcast.** A mirror spend is broadcast in one pass and
confirms during a later one, so the outcome MUST be recorded by an id-keyed resolution over the
audit record rather than by the handle that opened it. Before the observation of step 2 is
planned against, every mirror-coin entry that is `submitted` or `unresolved` is resolved as
follows, and only as follows:

| operation | its positive key | how the height is obtained |
|---|---|---|
| reclaim | the `intended_coin_id` the submission recorded | a coin read on that id |
| create | the coin in step 2's observation matching `(store, root, epoch)` | a coin read on THAT coin's id |

A create records no `intended_coin_id` — the created coin's parent is whichever funding input the
builder drew from — so its key MUST be the coin's appearance in the chain observation. A coin id
MUST NOT be derived, guessed, or otherwise invented for this purpose.

The coin read has THREE outcomes and they MUST stay three: a height (resolve to `confirmed`); the
coin absent, or present with no height (resolve nothing); the source unable to answer (resolve
nothing, and NOT as an absence). A source that cannot be reached is not evidence about a coin in
either direction.

Disappearance MUST NOT be used as a key. The mirror puzzle hash is shared by every mirror coin,
so a coin leaving the owned set proves only that SOMEONE spent it, and a short scan is
indistinguishable from a spend.

Where more than one open entry claims one coin — two reclaim attempts of the same coin derive the
same child id, and step 6 deliberately does not suppress on `unresolved` — NONE of them is
resolved. At most one of those bundles created the coin, and this node cannot tell which; the
entries remain `unresolved`, which is what they are.

A confirmed create is `Confirmed { height, coin_id }` in the audit record, observed on the created
coin. The `intended_coin_id` is recorded at submission so §23.5's reconcile accounts for it.

Expand Down Expand Up @@ -8478,17 +8507,25 @@ cannot observe a half-written file the node produced itself.

### 25.6. The DHT pointer, and epoch rollover

> **PENDING — not yet implemented.** This subsection is normative and is NOT satisfied by
> code as of this section's introduction. Tracked as dig-node#377 step 7 (the dig-dht 0.12.1 → 0.15 bump and the
> announce-seam attach). Until it lands, a reader MUST NOT
> rely on the behaviour described here.
> **IMPLEMENTED.** The announce seam attaches the pointer
> (`dig_node_core::dht::announce_inventory_ids_with_pointers`), the rollover re-announce is
> `DhtHandle::reannounce_on_epoch_rollover`, and the node's production pointer source is
> `dig_node_service::mirror::pointers::SnapshotMirrorPointers`, which reads the observation the last
> pass published. A node with no observation, or with no coin for a capsule, publishes no pointer;
> that is an ordinary configuration and not a fault.

After a create confirms, the node attaches the coin id to its DHT provider record
(`dig_dht::ProviderRecord::unverified_mirror_coin_id`) for that content, and it MUST re-announce on
epoch rollover once the new epoch's coin confirms — dig-dht has no clock and republish re-attaches
whatever was recorded at announce time, so an un-refreshed pointer goes stale one epoch after
publication and a correctly-collateralised node reads as uncollateralised.

Only a coin bonding the CURRENT epoch may be published. A coin from a previous epoch advertises
nothing, and pointing at it makes a correctly-collateralised node read as uncollateralised — the
same failure the rollover re-announce exists to prevent, reached without any rollover. A whole-store
announce carries no pointer at all: a coin bonds one `(store, root, epoch)` tuple and cannot speak
for every generation of a store.

The pointer is an UNTRUSTED convenience (NC-12): it tells a verifier where to look, never what the
coin is. Its absence MUST NOT degrade discovery or be treated as a fault. A verifier — this node
when it checks others, and others when they check this node — accepts a coin as bonding
Expand Down
55 changes: 55 additions & 0 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ mod forwarded_ask_tests;
pub mod seams;
pub mod tier0_live;
pub mod tier0_prefetch;
/// `dig-dht` itself, re-exported so a consumer implementing [`dht::MirrorCoinPointers`] names
/// `ContentId` through THIS crate rather than declaring its own `dig-dht` dependency.
///
/// A second declaration is a second version constraint, and a consumer that resolved a different
/// `dig-dht` minor would be handed a `ContentId` that is a different type with the same name — the
/// split-line failure §2.4b exists to prevent, arriving through a trait nobody would think to check.
pub use dig_dht;
/// The `CapsuleStore` trait is seam 6's public surface (#1285 W1b-4) — bring it into scope to call
/// `cache_list_cached`/`cache_remove_cached`/`cache_fetch_and_cache`/`gap_fill_generation`/
/// `maybe_backfill_capsule`/`set_self_ref`/`arc_self` on a `Node`.
Expand Down Expand Up @@ -441,6 +448,19 @@ pub struct Node {
/// on the FFI/consumer path (no peer network, no inbound peer demand), where the gate reads `None`
/// and fails CLOSED (no peer-driven pull without a known identity to anchor the neighbourhood to).
node_peer_id: OnceLock<[u8; 32]>,
/// This node's UNTRUSTED mirror-coin pointer source (dig-node#422/#435), attached to every DHT
/// announce so a verifier is told WHERE TO LOOK instead of scanning the shared mirror puzzle
/// hash.
///
/// Installed by the service shell before [`peer::spawn_peer_network`], because the mirror
/// lifecycle that knows which coin bonds which capsule lives in `dig-node-service` and the DHT
/// lives here. A slot rather than a constructor argument for the same reason
/// [`Node::node_peer_id`] is one: the FFI/browser path has no mirror lifecycle and no peer
/// network, and must keep constructing a `Node` without either.
///
/// `None` is an ORDINARY configuration, never a degraded one — a node with no pointer source
/// announces exactly as it always did, and the verifier's fallback is the hint scan.
mirror_pointers: OnceLock<std::sync::Arc<dyn crate::dht::MirrorCoinPointers>>,
}

/// A boxed async hook that reconciles the node's DHT provider records with its current cache
Expand Down Expand Up @@ -4515,6 +4535,7 @@ impl Node {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
})
}

Expand Down Expand Up @@ -4662,6 +4683,27 @@ impl Node {
pub(crate) fn set_node_peer_id(&self, peer_id: [u8; 32]) {
let _ = self.node_peer_id.set(peer_id);
}

/// Install the untrusted mirror-coin pointer source every DHT announce attaches
/// (dig-node#435). Called by the service shell BEFORE the peer network is spawned; a later call
/// is ignored, so the source a running node publishes from cannot be swapped underneath it.
///
/// Installing one is optional. What it must never do is fail an announce: the pointer is a hint
/// about where to look, and a node that could not produce one still holds and serves its
/// content.
pub fn set_mirror_coin_pointers(
&self,
pointers: std::sync::Arc<dyn crate::dht::MirrorCoinPointers>,
) {
let _ = self.mirror_pointers.set(pointers);
}

/// The installed pointer source, if any.
pub(crate) fn mirror_coin_pointers(
&self,
) -> Option<std::sync::Arc<dyn crate::dht::MirrorCoinPointers>> {
self.mirror_pointers.get().cloned()
}
}

/// The COMPOSITION-ROOT upcasts (#1285 W1c — the locked "Option A" shape). `Node` stays ONE
Expand Down Expand Up @@ -4806,6 +4848,7 @@ pub(crate) mod test_support {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
};
(Arc::new(node), td)
}
Expand Down Expand Up @@ -5594,6 +5637,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
};
(node, td)
}
Expand Down Expand Up @@ -5726,6 +5770,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
};

// Missing before the pull.
Expand Down Expand Up @@ -5792,6 +5837,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
});

// Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set.
Expand Down Expand Up @@ -5883,6 +5929,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
});

assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex()));
Expand Down Expand Up @@ -5956,6 +6003,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
});

assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex()));
Expand Down Expand Up @@ -8631,6 +8679,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
};

let before = handle_rpc(
Expand Down Expand Up @@ -15486,6 +15535,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};
// A holder for this EXACT content is known via the DHT.
Expand Down Expand Up @@ -15536,6 +15586,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};
// A P2P engine is attached but the DHT knows of NO holder for this content — the graceful
Expand Down Expand Up @@ -15585,6 +15636,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};

Expand Down Expand Up @@ -15616,6 +15668,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};
let cid = ContentId::resource(store.0, tip.0, rk);
Expand Down Expand Up @@ -15656,6 +15709,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};
let cid = ContentId::resource(store.0, tip.0, rk);
Expand Down Expand Up @@ -15698,6 +15752,7 @@ mod tests {
chat: chat::ChatState::new(),
inbound_demand: Arc::new(inbound_demand::InboundDemand::new()),
node_peer_id: OnceLock::new(),
mirror_pointers: OnceLock::new(),
..node
};
let cid = ContentId::resource(store.0, tip.0, rk);
Expand Down
13 changes: 12 additions & 1 deletion crates/dig-node-core/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3067,7 +3067,18 @@ async fn bring_up_dht(
initial_ids.len()
);

let dht = crate::dht::DhtHandle::new(service, initial_ids);
// The untrusted mirror-coin pointer source (dig-node#435), when the service shell installed
// one. Until it did, `DhtHandle::new` hard-coded `None` here and EVERY live announce published
// `unverified_mirror_coin_id = None` — the whole collateral-pointer mechanism was built, unit
// tested, and fed by nothing but a test double.
//
// `None` remains fully supported: the FFI path and any node without a mirror lifecycle announce
// exactly as before, and a verifier falls back to the hint scan.
let dht = crate::dht::DhtHandle::with_mirror_pointers(
service,
initial_ids,
node.mirror_coin_pointers(),
);

// The real-time holdings layer (#1429): flood a signed opcode-222 announcement whenever this
// node's inventory changes, and fold every peer's verified announcement into our provider set.
Expand Down
90 changes: 87 additions & 3 deletions crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3730,6 +3730,29 @@ fn collateral_requirement(id: Value) -> Value {
/// `Withheld` is the one row that describes a capsule relayed on a stranger's behalf, deliberately
/// never advertised and therefore never bonded, so it locks nothing.
///
/// # `Disabled` IS counted, and `Reclaiming` is too (dig-node#429)
///
/// Both look excludable and neither is, for the same reason: this is FORWARD-LOOKING advice about
/// $DIG the operator must keep available, not a report of $DIG currently locked.
///
/// `Withheld` is a property of the CAPSULE - held on a stranger's behalf - and no setting makes a
/// relayed capsule bondable, so its pair will never consume collateral and advising for it is
/// advising for a spend that cannot occur.
///
/// `Disabled` is a node-wide SWITCH (SPEC 25.7), and every row reads `Disabled` while it is off.
/// Were it excluded, the buffer advice would fall to zero for the whole node the moment
/// collateralisation is switched off and leap back the moment it is switched on - telling an
/// operator who is about to enable it that they need nothing, and stranding them short on the very
/// next pass. Under-stating money the operator must hold is the reassuring direction, and it is the
/// one direction a figure like this must never be wrong in.
///
/// `Reclaiming` is money in motion whose capsule is still served: at an epoch rollover the coin
/// comes home and the SAME pair is bonded again next epoch, so the buffer it needs is unchanged.
///
/// So the exclusion is not "states where no coin exists right now" - `Unfunded` and `Deferred` have
/// no coin either and are plainly counted. It is "pairs this node will never bond", which today is
/// exactly `Withheld`.
///
/// Named and separated from [`collateral_buffer`] so the distinction is testable without a state
/// directory: the caller feeds this into an amount of money, and a count that quietly includes rows
/// locking nothing is a wrong figure on a money surface rather than a wrong figure about rows.
Expand Down Expand Up @@ -4071,21 +4094,82 @@ mod tests {
amount_dig_base_units: 1_000,
},
),
// dig-node#429: switched off node-wide, and STILL bondable. A switch is not a
// property of the capsule; flip it and this pair locks collateral on the next pass.
(
Bond::new("ff".repeat(32), "55".repeat(32)),
BondState::Disabled,
),
// Money in motion on a pair that is still served: the coin comes home at rollover
// and the same pair is bonded again, so the buffer it needs is unchanged.
(
Bond::new("ab".repeat(32), "66".repeat(32)),
BondState::Reclaiming {
coin_id: "cd".repeat(32),
epoch: 4,
amount_dig_base_units: 1_000,
},
),
],
locked_dig_base_units: 1_000,
epoch: 4,
};

assert_eq!(
observation.states.len(),
4,
6,
"the fixture must carry a row the answer EXCLUDES, or it cannot tell the contract from \
a plain row count"
);
assert_eq!(
bondable_pairs(&observation),
3,
"a `Withheld` row locks nothing and is not bondable; the other three are"
5,
"only `Withheld` is excluded: `Disabled` is a reversible node-wide switch and `Reclaiming` is a served pair whose coin is coming home, and both bond again next pass"
);
}

/// **Proves:** dig-node#429's contract question from the side that fails dangerously - a node
/// with collateralisation switched OFF still advises the buffer its pairs will need.
///
/// **Catches:** excluding `Disabled` alongside `Withheld`, which is the plausible reading of
/// the exclusion's own doc and the one this ticket was opened to settle. The mixed fixture
/// above does detect that mistake as a count, but it cannot show the CONSEQUENCE, which is why
/// this case exists separately: when the switch is off, EVERY row is `Disabled`, so excluding
/// the state answers **zero** and tells a funded operator who is about to re-enable that they
/// need no $DIG at all.
///
/// A `Withheld` row rides along as the control. Without it this fixture would also pass under
/// "count every row", which is not the contract either.
#[test]
fn a_node_with_collateralisation_switched_off_still_advises_for_the_pairs_it_will_bond() {
use crate::mirror::pass::BondState;
use crate::mirror::plan::Bond;
use crate::mirror::states::BondObservation;

let observation = BondObservation {
states: vec![
(
Bond::new("aa".repeat(32), "11".repeat(32)),
BondState::Disabled,
),
(
Bond::new("bb".repeat(32), "22".repeat(32)),
BondState::Disabled,
),
// The control: a relayed capsule is not bondable whatever the switch says.
(
Bond::new("cc".repeat(32), "33".repeat(32)),
BondState::Withheld,
),
],
locked_dig_base_units: 0,
epoch: 4,
};

assert_eq!(
bondable_pairs(&observation),
2,
"switching collateralisation off must not advise a zero buffer: the switch is reversible and these pairs lock $DIG on the pass after it is switched back on"
);
}

Expand Down
Loading
Loading