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.252.82"
version = "0.252.92"

# 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
24 changes: 24 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -9347,6 +9347,16 @@ at this operator's puzzle hash. The node MUST NOT treat an unauthenticated candi
* A candidate that fails authentication MUST be passed over rather than aborting the selection, MUST
be counted and reported, and MUST NOT occupy an input slot.

**The report of passed-over candidates MUST be one bounded message per selection, and MUST frame
any total the pass quotes as a FLOOR.** One message per candidate makes the log volume a figure the
attacker chooses, since the candidate count is set by whoever paid coins into the public address;
the aggregate MUST therefore also bound how many coin ids it names, or the volume moves from the
message count to the message length. The report MUST be reachable on the funding path the node
actually uses, including where the selection SUCCEEDS: the same skip covers a genuine lineage
defect, so a pass that funded while passing candidates over has established only a lower bound on
what the operator can spend, and a total quoted flat would understate their money.


**Authentication costs one chain read per candidate, so it MUST be bounded by a constant** that does
not depend on how many candidates exist. Without such a bound the reads one automated pass performs
are chosen by whoever paid coins into the address, on the pass timer, indefinitely.
Expand Down Expand Up @@ -9381,6 +9391,20 @@ corrected. The message MUST name the condition and an action the operator can ta
assert a remedy the observation does not establish — in particular a truncated walk MUST NOT tell
an operator to add $DIG, since adding it need not help.

**Whether an *unmeasured* pass is the SAME condition as the last one MUST be decided without
reference to any figure a stranger can move.** The repeat-suppression above is what keeps an
unattended pass timer from becoming a notification stream, so a condition whose identity includes an
attacker-chosen field is not suppressed at all: one coin paid into the publicly derivable operator
address changes the count, the condition compares unequal, and the operator is notified on every
pass indefinitely, for the price of one dust spend. A node MUST therefore exclude the truncated
walk's attempted and skipped counts from that comparison, while still stating them in the message
body.

Conversely a node MUST re-raise when a figure the operator must ACT on has changed and no stranger
can move it -- in particular the epoch collateral requirement, which is derived from the plan rather
than from the wallet. Suppressing that is not repeat-suppression but under-reporting: the operator
has been told a different amount is needed than the amount now needed.

A *short* observation's spendable total MUST be authenticated (§25.11). A pass that has no
authenticated total is *unmeasured*, never *short with the address total*.

Expand Down
264 changes: 247 additions & 17 deletions crates/dig-node-service/src/mirror/funding.rs

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions crates/dig-node-service/src/mirror/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,13 +451,31 @@ impl<S: ChainSource> MirrorEffects for NodeMirrorEffects<'_, S> {
// compile error — so the scope is the guarantee, and it is deliberately narrow.
let dig_coins = {
let committed = committed.borrow();
funding::select_operator_dig_cats(
// `_detailed`, so the skips are CONSUMED rather than dropped on the floor. The plain
// wrapper discards `FundingSelection::skipped`, which made "a skip is counted and
// reported" true of the tests and false of the shipped node: the same path that passes
// over a stranger's coin passes over one of this node's own under a lineage bug, and
// the create then refuses with a confident understated total (dig-node#481).
let selection = funding::select_operator_dig_cats_detailed(
self.source,
self.owner_puzzle_hash,
amount_dig_base_units,
&committed,
)
.map_err(funding_refusal)?
.map_err(funding_refusal)?;
// Named with the store, which the selector cannot know. Even a FUNDED create says so:
// a pass that covered its requirement while passing over candidates has still only
// established a floor, and staying quiet about that on the success path is how the
// condition goes unnoticed until it is a shortfall.
if let Some(report) = funding::skip_report(&selection.skipped) {
tracing::warn!(
target: "mirror",
store_id = %bond.store_id,
skipped = selection.skipped.len(),
"{report}"
);
}
selection.cats
};

let signer = self
Expand Down
8 changes: 4 additions & 4 deletions crates/dig-node-service/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2833,21 +2833,21 @@ fn spawn_mirror_passes(
// This pass is the observation point because it already reads the operator wallet's
// own balance on a timer, so the latch costs no extra chain read and cannot drift from
// the figure the node acts on. `synced` gates ONLY the zero case (see
// `FundingObservation::should_latch`), so a stale or fallback answer showing money
// `EverFundedEvidence::should_latch`), so a stale or fallback answer showing money
// still latches immediately.
{
use crate::wallet_funded::FundingObservation;
use crate::wallet_funded::EverFundedEvidence;
let synced = wallet
.wallet_sync_status()
.await
.is_ok_and(|s| s.phase == dig_wallet::sage::sync_supervisor::SyncPhase::Synced);
let observation = match &dig_balance {
Ok(base_units) => {
FundingObservation::classify(u128::from(*base_units), 0, synced)
EverFundedEvidence::classify(u128::from(*base_units), 0, synced)
}
// An unreadable balance is not a zero balance. It says nothing, and the latch
// is monotonic, so the next pass that CAN read decides.
Err(_) => FundingObservation::CannotSay,
Err(_) => EverFundedEvidence::CannotSay,
};
crate::wallet_funded::observe(&paths, observation);
}
Expand Down
54 changes: 35 additions & 19 deletions crates/dig-node-service/src/wallet_funded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,29 @@

use dig_wallet::autoseed::{self, WalletPaths};

/// What a balance observation lets the node conclude about funding.
/// What a balance observation lets the node conclude about the wallet ever having held money.
///
/// # Not to be confused with [`crate::mirror::funding::FundingObservation`]
///
/// The two were both called `FundingObservation` and are different concepts, which is why this one
/// was renamed rather than merged (dig-node#481). Merging them would collapse the distinction
/// `mirror::funding` exists to protect:
///
/// | | this type | `mirror::funding::FundingObservation` |
/// |---|---|---|
/// | subject | the NODE-custodied wallet | the OPERATOR wallet |
/// | question | has it EVER held money | what is spendable THIS pass |
/// | decides | [`dig_wallet::autoseed::latch_ever_funded`] | the operator alert gate |
/// | lifetime | monotonic, permanent | per-pass |
///
/// The name says what it decides: this is evidence about ever having been funded, not a
/// measurement of funding.
///
/// The three variants exist because a balance read has THREE outcomes, not two, and collapsing
/// the middle one is the defect this whole batch is about: a zero from a node that cannot see is
/// not the same claim as a zero from a node that can.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FundingObservation {
pub enum EverFundedEvidence {
/// A non-zero figure was observed. The wallet holds, or has held, money.
Funded,
/// A CURRENT read from an authoritative tier reported zero. This is a real claim of
Expand All @@ -29,7 +45,7 @@ pub enum FundingObservation {
CannotSay,
}

impl FundingObservation {
impl EverFundedEvidence {
/// Classify a balance reading.
///
/// `balance`/`pending` are summed deliberately: value in flight is value the wallet has held.
Expand Down Expand Up @@ -82,7 +98,7 @@ impl FundingObservation {
/// A latch write that FAILS is logged and swallowed. This runs inside a periodic pass whose job is
/// something else, and a sidecar write failure must not take that pass down — the next observation
/// retries, and the flag defaults to the safe answer meanwhile.
pub fn observe(paths: &WalletPaths, observation: FundingObservation) {
pub fn observe(paths: &WalletPaths, observation: EverFundedEvidence) {
if !observation.should_latch() {
return;
}
Expand All @@ -106,27 +122,27 @@ mod tests {
#[test]
fn a_current_zero_is_evidence_of_emptiness_and_an_unsynced_zero_is_not() {
assert_eq!(
FundingObservation::classify(0, 0, true),
FundingObservation::ObservedEmpty
EverFundedEvidence::classify(0, 0, true),
EverFundedEvidence::ObservedEmpty
);
assert_eq!(
FundingObservation::classify(0, 0, false),
FundingObservation::CannotSay
EverFundedEvidence::classify(0, 0, false),
EverFundedEvidence::CannotSay
);
// The control that makes the pair load-bearing: a real figure classifies as funded from
// EITHER tier, so `synced` is a gate on the zero case only, never on the money case.
assert_eq!(
FundingObservation::classify(1, 0, true),
FundingObservation::Funded
EverFundedEvidence::classify(1, 0, true),
EverFundedEvidence::Funded
);
assert_eq!(
FundingObservation::classify(1, 0, false),
FundingObservation::Funded
EverFundedEvidence::classify(1, 0, false),
EverFundedEvidence::Funded
);
// Value in flight is value held.
assert_eq!(
FundingObservation::classify(0, 1, true),
FundingObservation::Funded
EverFundedEvidence::classify(0, 1, true),
EverFundedEvidence::Funded
);
}

Expand All @@ -136,14 +152,14 @@ mod tests {
/// unconditionally and one that never latched would each satisfy a single-direction test.
#[test]
fn only_evidence_of_money_latches() {
assert!(FundingObservation::Funded.should_latch());
assert!(EverFundedEvidence::Funded.should_latch());
assert!(
!FundingObservation::CannotSay.should_latch(),
!EverFundedEvidence::CannotSay.should_latch(),
"an unknown DEFERS: every node is in this state on its first pass, so latching here \
would make `is_disposable` vacuously false forever — see `should_latch`'s doc"
);
assert!(
!FundingObservation::ObservedEmpty.should_latch(),
!EverFundedEvidence::ObservedEmpty.should_latch(),
"a current zero is real evidence of emptiness and must not latch"
);
}
Expand All @@ -163,13 +179,13 @@ mod tests {
);

// A current zero must NOT latch, or the test below could not fail.
observe(&paths, FundingObservation::ObservedEmpty);
observe(&paths, EverFundedEvidence::ObservedEmpty);
assert!(
autoseed::is_disposable(&paths),
"a measured empty wallet stays disposable"
);

observe(&paths, FundingObservation::Funded);
observe(&paths, EverFundedEvidence::Funded);

// Re-read from the filesystem rather than from memory: this is the restart.
assert!(
Expand Down
86 changes: 84 additions & 2 deletions crates/dig-node-service/tests/mirror_operator_funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use chia_protocol::{Bytes32, CoinSpend};
use chia_sha2::Sha256;
use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage};
use dig_node_service::mirror::funding::{
dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, FundingError,
FundingObservation, FundingRemedy,
dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, skip_report,
FundingError, FundingObservation, FundingRemedy, SKIP_SAMPLE,
};
use support::{ordinary_dig_coins, wallet, Wallet};

Expand Down Expand Up @@ -607,3 +607,85 @@ fn coins_a_stranger_paid_in_cannot_turn_a_top_up_into_a_consolidation() {
)
);
}

/// **A FUNDED create still reports what it passed over, and reports it in O(1).**
///
/// Two properties, and the fixture is built so that the nearest wrong implementation of each fails
/// it.
///
/// * **The success path speaks.** `FundingSelection::skipped` was computed and had no production
/// consumer at all, so "a skip is counted and reported" was true of the tests and false of the
/// shipped node (dig-node#481). The selection here SUCCEEDS -- the operator's own coins cover the
/// requirement -- because a report emitted only on the refusal paths satisfies a fixture that
/// refuses, and this is the half that was invisible.
/// * **The line is bounded.** The skipped count is chosen by whoever plants the coins, and there is
/// no rate limit in this module, so a report that named every id would move an attacker-driven
/// volume from the line count to the line length rather than removing it. Two runs differing only
/// in how many coins the stranger planted must name the same number of ids.
///
/// The report must also frame the total as a floor: the same path that passes over a stranger's
/// coin passes over one of this node's own under a lineage bug, so "you have X" would be a
/// confident understatement of the operator's money.
#[test]
fn a_funded_selection_reports_what_it_passed_over_in_one_bounded_line() {
let report_for = |planted: u64| -> String {
let operator = operator();
let mut chain = Chain::default();
chain.fund(&operator, &[REQUIRED], salt(1));
// Every planted coin is larger than the honest one, so all of them are walked first.
let dust: Vec<u64> = (1..=planted).map(|n| REQUIRED * 10 + n).collect();
chain.fund_without_lineage(&operator, &dust, salt(5));

let selection = select_operator_dig_cats_detailed(
&chain,
operator.puzzle_hash,
REQUIRED,
&HashSet::new(),
)
.expect("the operator's own coin covers the requirement, so this pass FUNDS");
assert_eq!(
selection.skipped.len(),
planted as usize,
"every planted coin should have been walked and passed over"
);
skip_report(&selection.skipped).expect("a selection that passed candidates over must speak")
};

let few = report_for((SKIP_SAMPLE + 4) as u64);
let many = report_for((SKIP_SAMPLE * 5) as u64);

assert!(
few.contains(&format!("{} coin(s)", SKIP_SAMPLE + 4)),
"the report must state how many were passed over: {few}"
);
assert!(
few.contains("AT LEAST"),
concat!(
"a total from a walk that skipped candidates is a FLOOR; a report that does not say ",
"so leaves the operator acting on an understated figure as though it were exact: "
)
);

let ids = |report: &str| {
report.split("Coin ids: ").nth(1).map(|tail| {
tail.trim_end_matches(", ...")
.split(", ")
.filter(|id| !id.is_empty())
.count()
})
};
assert_eq!(
ids(&few),
Some(SKIP_SAMPLE),
"the id list is capped at the sample bound: {few}"
);
assert_eq!(
ids(&many),
ids(&few),
concat!(
"five times as many planted coins named five times as many ids, so the line length is ",
"still a figure the attacker chooses -- the aggregation moved the volume rather than ",
"bounding it"
)
);
}
Loading