Skip to content
Open
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
5 changes: 2 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ mimalloc = "0.1"
# Until then, the git pin tracks the matching saorsa-core lineage
# (the rc-2026.4.2 branch) so Cargo can unify the wire types here
# with ant-protocol's re-exports.
ant-protocol = "2.3.0"
ant-protocol = "3.0.0"

# Core (provides EVERYTHING: networking, DHT, security, trust, storage)
saorsa-core = "0.26.2"
Expand Down Expand Up @@ -196,3 +196,11 @@ unused_async = "allow"
cognitive_complexity = "allow"
# Allow non-const functions during initial development (may need runtime features later)
missing_const_for_fn = "allow"

# ADR-0005 coordinated cutover: pin ant-protocol to the immutable rev of the
# audit-report wire-types PR (github.com/WithAutonomi/ant-protocol/pull/19).
# An immutable rev (not a mutable branch) keeps the build reproducible; the
# committed Cargo.lock records the exact resolution. STRIP BEFORE MERGE —
# replace with the published ant-protocol 3.0.0 once it lands on crates.io.
[patch.crates-io]
ant-protocol = { git = "https://github.com/grumbach/ant-protocol", rev = "2015113270bbde8204a1740674bed2ada52d2ca6" }
249 changes: 249 additions & 0 deletions docs/adr/ADR-0005-earned-reward-eligibility.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ impl NodeBuilder {
let concrete = Arc::clone(engine.commitment_state());
let source: Arc<dyn crate::payment::quote::CommitmentSource> = concrete;
protocol.attach_commitment_source(source);
// ADR-0005: wire the engine's audit tally as the quote
// generator's report source so quote responses carry
// this node's signed audit report.
let report_source: Arc<dyn crate::payment::quote::AuditReportSource> =
Arc::new(crate::replication::audit_tally::TallyReportSource::new(
engine.audit_tally_handle(),
*identity.peer_id().as_bytes(),
));
protocol.attach_report_source(report_source);
// ADR-0004: share the engine's gossip commitment
// cache with the verifier so the cross-check can
// resolve quote pins against neighbours' commitments.
Expand Down
173 changes: 172 additions & 1 deletion src/payment/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! and will be fully integrated when the node is initialized.

use crate::error::{Error, Result};
use crate::logging::debug;
use crate::logging::{debug, warn};
use crate::payment::metrics::QuotingMetricsTracker;
use crate::payment::pricing::calculate_price;
use evmlib::merkle_payments::MerklePaymentCandidateNode;
Expand Down Expand Up @@ -66,6 +66,20 @@ pub trait CommitmentSource: Send + Sync {
fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option<Vec<u8>>;
}

/// Source of the node's audit-tally rows for the ADR-0005 report.
///
/// Shipped on quote responses. Implemented by the replication engine's tally;
/// decouples [`QuoteGenerator`] from replication internals the same way
/// [`CommitmentSource`] does.
pub trait AuditReportSource: Send + Sync {
/// This node's own peer id (the report's `reporter_peer_id`).
fn reporter_peer_id(&self) -> [u8; 32];

/// The tally rows as they stand right now (pruned to the window and capped
/// to the wire limits). Empty when this observer has no testimony yet.
fn report_rows(&self) -> Vec<ant_protocol::payment::AuditReportRow>;
}

/// Quote generator for creating payment quotes.
///
/// Uses the node's signing capabilities to sign quotes, which clients
Expand Down Expand Up @@ -93,6 +107,10 @@ pub struct QuoteGenerator {
sign_fn: Option<SignFn>,
/// Public key bytes for the quote.
pub_key: Vec<u8>,
/// ADR-0005 report source: the audit tally this node testifies from on
/// quote responses. `None` until [`Self::attach_report_source`] is called
/// (pre-wiring / unit tests), in which case responses carry no report.
report_source: RwLock<Option<Arc<dyn AuditReportSource>>>,
}

impl QuoteGenerator {
Expand All @@ -112,7 +130,70 @@ impl QuoteGenerator {
commitment_source: RwLock::new(None),
sign_fn: None,
pub_key: Vec::new(),
report_source: RwLock::new(None),
}
}

/// Attach the ADR-0005 report source so quote responses can ship this
/// node's signed audit report. Idempotent, interior mutability — same
/// contract as [`Self::attach_commitment_source`].
pub fn attach_report_source(&self, source: Arc<dyn AuditReportSource>) {
*self.report_source.write() = Some(source);
debug!("QuoteGenerator: ADR-0005 audit-report source attached");
}

/// Build this node's signed audit report for a quote response (ADR-0005).
///
/// `nonce` is the client's request nonce, echoed and signed so the report
/// is provably generated for this request. Returns the rmp-serialized
/// [`ant_protocol::payment::AuditReport`], or `None` when no source is
/// attached, the tally is empty (nothing to testify), or the node cannot
/// sign — a response without a report is valid, it just doesn't vouch.
#[must_use]
pub fn build_audit_report(&self, nonce: [u8; 32]) -> Option<Vec<u8>> {
use ant_protocol::payment::{MAX_AUDIT_REPORT_BYTES, MAX_REPORT_DAYS, MAX_REPORT_ROWS};
let source = self.report_source.read().as_ref().map(Arc::clone)?;
let sign_fn = self.sign_fn.as_ref()?;
let mut rows = source.report_rows();
if rows.is_empty() {
return None;
}
// Producer-side cap enforcement (ADR-0005): the source truncates, but the
// producer must NOT rely on that discipline — it enforces the structural
// caps itself so a malformed or oversized report is never signed or put on
// the wire. Verifiers reject an over-cap report wholesale, so emitting one
// would only cost this node its own vouch.
rows.truncate(MAX_REPORT_ROWS);
for row in &mut rows {
row.days.truncate(MAX_REPORT_DAYS);
}
let reporter_peer_id = source.reporter_peer_id();
let payload =
ant_protocol::payment::audit_report_signed_payload(&reporter_peer_id, &nonce, &rows);
Comment on lines +170 to +172
let signature = sign_fn(&payload);
if signature.is_empty() {
return None;
}
let report = ant_protocol::payment::AuditReport {
reporter_peer_id,
nonce,
rows,
signature,
};
let bytes = rmp_serde::to_vec(&report).ok()?;
// Enforce the serialized byte cap the verifier expects (a report over
// MAX_AUDIT_REPORT_BYTES is dropped whole on receipt): never put an
// over-cap report on the wire. With the row/day caps above this is
// unreachable for an honest tally, but the guard makes the invariant local.
if bytes.len() > MAX_AUDIT_REPORT_BYTES {
warn!(
"ADR-0005: built audit report {} B exceeds cap {} B; omitting",
bytes.len(),
MAX_AUDIT_REPORT_BYTES
);
return None;
}
Some(bytes)
}

/// Attach the ADR-0004 commitment source so quotes bind their price to the
Expand Down Expand Up @@ -472,6 +553,96 @@ mod tests {
generator
}

/// Fixed-rows [`AuditReportSource`] for tests.
struct FixedReportSource {
reporter_peer_id: [u8; 32],
rows: Vec<ant_protocol::payment::AuditReportRow>,
}
impl AuditReportSource for FixedReportSource {
fn reporter_peer_id(&self) -> [u8; 32] {
self.reporter_peer_id
}
fn report_rows(&self) -> Vec<ant_protocol::payment::AuditReportRow> {
self.rows.clone()
}
}

/// ADR-0005 report round-trip with REAL keys: the node-side
/// `build_audit_report` output must verify with the client-side
/// `verify_audit_report` against the same pubkey, reporter id, and nonce —
/// this is the guard against signer/verifier payload-framing drift. Also
/// asserts the nonce binding (wrong nonce fails) and the empty-tally and
/// no-source `None` paths.
#[test]
fn test_audit_report_round_trip_real_keys() {
use ant_protocol::payment::{verify_audit_report, AuditReport, AuditReportDay};

let ml_dsa = MlDsa65::new();
let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keypair generation");
let pub_key_bytes = public_key.as_bytes().to_vec();
// The reporter id is the peer binding the client already checked for
// the quote: BLAKE3(pub_key).
let reporter_peer_id = *blake3::hash(&pub_key_bytes).as_bytes();

let mut generator = QuoteGenerator::new(
RewardsAddress::new([3u8; 20]),
QuotingMetricsTracker::new(10),
);
let sk_bytes = secret_key.as_bytes().to_vec();
generator.set_signer(pub_key_bytes.clone(), move |msg| {
let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("secret key parse");
MlDsa65::new()
.sign(&sk, msg)
.expect("signing")
.as_bytes()
.to_vec()
});

let nonce = [9u8; 32];
assert!(
generator.build_audit_report(nonce).is_none(),
"no source attached -> no report"
);

generator.attach_report_source(Arc::new(FixedReportSource {
reporter_peer_id,
rows: Vec::new(),
}));
assert!(
generator.build_audit_report(nonce).is_none(),
"empty tally -> no report (nothing to testify)"
);

let rows = vec![ant_protocol::payment::AuditReportRow {
subject_peer_id: [5u8; 32],
days: vec![AuditReportDay {
age_days: 0,
passes: 4,
max_passed_key_count: 1234,
}],
fenced: false,
convicted: false,
}];
generator.attach_report_source(Arc::new(FixedReportSource {
reporter_peer_id,
rows: rows.clone(),
}));

let bytes = generator
.build_audit_report(nonce)
.expect("report with rows");
let report: AuditReport = rmp_serde::from_slice(&bytes).expect("report parses");
assert_eq!(report.rows, rows, "rows survive the wire encoding");
assert!(
verify_audit_report(&report, &pub_key_bytes, &reporter_peer_id, &nonce),
"node-signed report must verify client-side"
);
assert!(
!verify_audit_report(&report, &pub_key_bytes, &reporter_peer_id, &[0u8; 32]),
"a different nonce must not verify (no precompute/replay)"
);
}

/// Fixed-binding [`CommitmentSource`] for tests: always reports the same
/// `(key_count, pin)` so we can assert the forced-price wiring exactly.
struct FixedCommitmentSource {
Expand Down
7 changes: 7 additions & 0 deletions src/replication/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub enum AuditTickResult {
challenged_peer: PeerId,
/// Number of keys verified.
keys_checked: usize,
/// Key count of the audited storage commitment — `Some` only for the
/// subtree (commitment) audit lane, which is the only lane that feeds
/// the ADR-0005 audit tally ("audited clean at this size").
commitment_key_count: Option<u32>,
},
/// Audit found failures (after responsibility confirmation).
Failed {
Expand Down Expand Up @@ -577,6 +581,9 @@ async fn verify_digests(
return AuditTickResult::Passed {
challenged_peer: *challenged_peer,
keys_checked: keys.len(),
// Responsible-chunk audits challenge single chunks, not a
// commitment — they never feed the ADR-0005 tally.
commitment_key_count: None,
};
}

Expand Down
Loading
Loading