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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project are documented here.
This project adheres to [Semantic Versioning](https://semver.org) and
[Conventional Commits](https://www.conventionalcommits.org).

## [Unreleased]

### Reward claim port hardening
- Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace
`disallowed-methods` clippy lint (phantom `LineageProof` on a chain-rebuilt distributor); allow
the one legitimate in-process test-fixture use (#3357)
- Refuse, by name, a distributor requiring payout approval in `submit_initiate_payout` before
building or broadcasting anything (#3362)
- Bound hinted launcher discovery candidates per cycle and report every drop via
`Discovery.candidates_dropped` / `ClaimStatus.discovery_candidates_dropped_this_cycle` (#3358)
- Fix a claim-port regression test to use a non-empty launcher index so it actually exercises the
failing chain source's discovery/submit paths (#3363)

## [0.255.0] - 2026-09-07

### Chores
Expand Down
18 changes: 18 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# DIG-Network/dig_ecosystem#3357: bans calling `RewardDistributor::created_slot_value_to_slot`
# from production code anywhere in this workspace.
#
# That method derives a `LineageProof` from the coin it is called ON. For a distributor rebuilt
# from chain (every production read path), that coin is the TIP -- so a `LineageProof` it derives
# for a slot an EARLIER generation created is a well-formed but PHANTOM proof. The entry slot for a
# real spend must come from a fresh, authenticated chain walk instead (see
# `dig_rewards_coin::ChainEntrySlotSource`, or `DistributorSnapshot::entry_slot` /
# `commitment_slots` / `reward_slots` for a read).
#
# This lint bans the CALL, not the receiver class: clippy cannot tell a chain-rebuilt receiver from
# an in-process one (a distributor built fresh in the same process this same generation, e.g. a
# test fixture reading back the reward slots ITS OWN spend just created -- that use is legitimate).
# Every `#[allow(clippy::disallowed_methods)]` against this entry must carry a comment stating WHY
# its receiver is in-process this generation, not chain-rebuilt.
disallowed-methods = [
{ path = "chia_sdk_driver::RewardDistributor::created_slot_value_to_slot", reason = "derives a LineageProof from the coin it is called on; on a distributor rebuilt from chain that coin is the TIP, so any earlier generation's slot is a PHANTOM (dig_ecosystem#3357). Use DistributorSnapshot::entry_slot / commitment_slots / reward_slots or ChainEntrySlotSource. This lint bans the CALL, not the receiver class: it cannot tell a chain-rebuilt receiver from an in-process one. Every #[allow] must state in a comment WHY its receiver is in-process." },
]
111 changes: 103 additions & 8 deletions crates/dig-node-service/src/rewards_claim/chain_port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@
//! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created
//! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE
//! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its
//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them --
//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the
//! resulting bundle is accepted without them.
//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them when
//! it proceeds -- the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the
//! proof the resulting bundle is accepted without them for `require_payout_approval = false`. When
//! the chain-curried `require_payout_approval` is `true` instead, dropping `conditions` would be
//! dropping the manager's approval assertion, not a no-op -- [`RealClaimChainPort::submit_initiate_payout`]
//! REFUSES by name in that case, before building anything, rather than broadcasting a bundle this
//! adapter cannot honestly satisfy (DIG-Network/dig_ecosystem#3362).
//!
//! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method
//! reports a NAMED [`ClaimPortError`], never a fabricated success.
Expand All @@ -38,13 +42,48 @@ use dig_wallet::sage::spend::Broadcaster;
use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError};

use super::port::{ClaimChainPort, ClaimPortError};
use super::types::{DiscoveredDistributor, OwnEntry};
use super::types::{DiscoveredDistributor, Discovery, OwnEntry};

/// The longest a chain port's own error text is allowed to carry before it is truncated -- the
/// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents,
/// applied here at the source so every producer of a bounded string agrees on the bound.
const MAX_ERROR_CHARS: usize = 200;

/// DIG-Network/dig_ecosystem#3358: the most hinted launcher candidates
/// [`RealClaimChainPort::discover_distributors`] will decode in one call.
///
/// # Where the number comes from
/// `dig_rewards_coin`'s own `DECODE_MAX_SERIALIZED_BYTES` bounds ONE candidate's decode at 64 KiB
/// (65_536 bytes); `256 * 65_536 = 16_777_216` bytes -- a 16 MiB decode ceiling for one
/// `discover_distributors` call -- plus 256 parent-spend chain reads, one per candidate.
///
/// # What this bound does NOT cover -- read this before assuming discovery is safe
/// 1. It does not bound gossip hints: `ClaimEngine::run_cycle`'s hint loop (`engine.rs`,
/// `self.hints.hints()`, feeding `resolve_launch_comment` one candidate at a time) is a
/// SEPARATE, unbounded path -- out of scope for this cap, named here so it is not mistaken for
/// covered.
/// 2. It does not choose WHICH candidates survive: this adapter decodes the index's first N in
/// WHATEVER ORDER the chain transport returned them, and that order is attacker-influenceable
/// (`HintedLauncherIndex` proposes every hinted coin its peers have seen) -- a flood of bogus
/// hinted coins ahead of a legitimate launcher in that order can push the legitimate one past
/// the cap and out of this cycle's candidate set.
/// 3. It does not persist "already decoded and rejected" across calls -- a dropped-for-real
/// candidate is re-attempted (and can be re-dropped) every cycle rather than being remembered
/// and skipped cheaply; left as a follow-up, not implemented here.
/// 4. It does not bound the COST of decoding one candidate -- that is
/// `DECODE_MAX_SERIALIZED_BYTES`'s job, not this cap's.
/// 5. It authenticates nothing -- every surviving candidate is still re-verified through the real
/// memo decode in [`resolve_via_chain`] exactly as before this cap existed; this cap only
/// decides how many candidates get that far.
///
/// A drop is never silent: [`RealClaimChainPort::discover_distributors`] reports how many
/// candidates it declined via [`Discovery::candidates_dropped`], and
/// [`super::engine::ClaimEngine::run_cycle`] copies that count into
/// [`super::types::ClaimStatus::discovery_candidates_dropped_this_cycle`] and logs a `warn!` when
/// it is nonzero -- a silent cap on discovery is the exact censorship-primitive shape this ticket
/// exists to avoid.
pub const MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE: usize = 256;

fn bounded(message: impl Into<String>) -> String {
let message = message.into();
if message.chars().count() <= MAX_ERROR_CHARS {
Expand Down Expand Up @@ -80,6 +119,11 @@ where
source: Arc<S>,
index: I,
broadcaster: Arc<dyn Broadcaster>,
/// DIG-Network/dig_ecosystem#3358: how many hinted candidates one `discover_distributors` call
/// will decode -- [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] in production;
/// [`Self::with_candidate_cap`] overrides it for a test that needs a small cap to exercise
/// dropping without decoding hundreds of candidates.
candidate_cap: usize,
}

impl<S, I> RealClaimChainPort<S, I>
Expand All @@ -89,13 +133,34 @@ where
{
/// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source
/// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read
/// clones it into a `spawn_blocking` closure on every call.
/// clones it into a `spawn_blocking` closure on every call. Uses the production
/// [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] cap -- see [`Self::with_candidate_cap`] to
/// override it.
#[must_use]
pub fn new(source: Arc<S>, index: I, broadcaster: Arc<dyn Broadcaster>) -> Self {
Self {
source,
index,
broadcaster,
candidate_cap: MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE,
}
}

/// Same as [`Self::new`] with an explicit candidate cap -- production never calls this; it
/// exists so a test can pin a small cap and prove the drop-and-report behaviour without
/// decoding hundreds of candidates.
#[must_use]
pub fn with_candidate_cap(
source: Arc<S>,
index: I,
broadcaster: Arc<dyn Broadcaster>,
candidate_cap: usize,
) -> Self {
Self {
source,
index,
broadcaster,
candidate_cap,
}
}
}
Expand Down Expand Up @@ -190,13 +255,20 @@ where
"real-corroborated"
}

async fn discover_distributors(&self) -> Result<Vec<DiscoveredDistributor>, ClaimPortError> {
async fn discover_distributors(&self) -> Result<Discovery, ClaimPortError> {
let candidate_ids = self.index.launcher_ids().await?;
let source = Arc::clone(&self.source);
let candidate_cap = self.candidate_cap;

tokio::task::spawn_blocking(move || {
// DIG-Network/dig_ecosystem#3358: bound how many candidates one call will decode --
// see MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE's doc for what this does and does not
// protect. The drop count is REPORTED, never silently absorbed.
let total = candidate_ids.len();
let candidates_dropped = total.saturating_sub(candidate_cap) as u32;

let mut discovered = Vec::new();
for launcher_id in candidate_ids {
for launcher_id in candidate_ids.into_iter().take(candidate_cap) {
// SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the
// real memo decode. An id the decode rejects (unknown to `source`, or a spend that
// is not a DIG rewards launch) is DROPPED, never echoed back.
Expand All @@ -207,7 +279,10 @@ where
Err(other) => return Err(other),
}
}
Ok(discovered)
Ok(Discovery {
distributors: discovered,
candidates_dropped,
})
})
.await
.map_err(|join_error| {
Expand Down Expand Up @@ -336,6 +411,26 @@ where
ClaimPortError::Other(bounded("not a distributor: launcher coin unspent"))
})?;

// DIG-Network/dig_ecosystem#3362: this distributor curries `require_payout_approval =
// true`, meaning `InitiatePayout` needs a manager-signed approval assertion in the same
// bundle. This adapter has no such assertion to attach and, per this module's doc,
// DROPS `initiate_payout`'s returned `conditions` unconditionally -- proceeding here
// would build a bundle the chain rejects, but only AFTER this adapter's caller had
// already reported `Paid` to whatever recorded the attempt. Refuse by name instead,
// before any spend is built.
if snapshot
.distributor()
.info
.constants
.require_payout_approval
{
return Err(ClaimPortError::Other(bounded(
"refused: distributor curries require_payout_approval = true; this adapter \
carries no approval message (it drops initiate_payout's returned conditions), \
so the bundle it would build is one the chain rejects after reporting Paid",
)));
}

// NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a
// well-formed but PHANTOM `LineageProof` for a slot an earlier generation created
// (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend
Expand Down
25 changes: 12 additions & 13 deletions crates/dig-node-service/src/rewards_claim/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ mod tests {

use super::super::cadence::CLAIM_JITTER_SECONDS_DEFAULT;
use super::super::port::ClaimPortError;
use super::super::types::{DiscoveredDistributor, OwnEntry};
use super::super::types::{DiscoveredDistributor, Discovery, OwnEntry};

// ---- decide_claim_driver / spawn_claim_driver_if (A3) ----------------------------------

Expand Down Expand Up @@ -988,10 +988,8 @@ mod tests {

#[async_trait]
impl ClaimChainPort for EmptyPort {
async fn discover_distributors(
&self,
) -> Result<Vec<DiscoveredDistributor>, ClaimPortError> {
Ok(Vec::new())
async fn discover_distributors(&self) -> Result<Discovery, ClaimPortError> {
Ok(Discovery::default())
}
async fn resolve_launch_comment(
&self,
Expand Down Expand Up @@ -1138,14 +1136,15 @@ mod tests {

#[async_trait]
impl ClaimChainPort for OneDistributorPort {
async fn discover_distributors(
&self,
) -> Result<Vec<DiscoveredDistributor>, ClaimPortError> {
Ok(vec![DiscoveredDistributor {
launcher_id: Bytes32::from([9u8; 32]),
store_id: Bytes32::from([0u8; 32]),
root: Bytes32::from([0u8; 32]),
}])
async fn discover_distributors(&self) -> Result<Discovery, ClaimPortError> {
Ok(Discovery {
distributors: vec![DiscoveredDistributor {
launcher_id: Bytes32::from([9u8; 32]),
store_id: Bytes32::from([0u8; 32]),
root: Bytes32::from([0u8; 32]),
}],
candidates_dropped: 0,
})
}
async fn resolve_launch_comment(
&self,
Expand Down
Loading
Loading