use bao_tree::io::outboard::PreOrderMemOutboard;
use decdn_bao_range::{IROH_BLOCK_SIZE, align_range, encode_verified_range};
use bytes::Bytes;
use super::{
Cumulative, HashMismatch, Healed, LocalPullFault, PoolContext, PoolLedger, U256,
UpstreamVoucherRejected, Voucher, VoucherRejectReason, WatermarkBundle, aligned_wire_len,
decode_to_vec, heal_watermark_desync, rejection_watermark, resumable_watermark,
};
use std::assert_matches;
#[test]
fn a_cap_that_cannot_outlast_its_stages_is_refused() {
use super::{DeadlineError, PullDeadlines};
use std::time::Duration;
let open = Duration::from_secs(5);
let window = Duration::from_secs(5);
let floor = 4096;
for cap in [Duration::from_secs(1), window, open + window] {
assert_matches!(
PullDeadlines::capped(open, window, floor, cap),
Err(DeadlineError::CapCannotOutlastItsStages { .. }),
"a cap of {cap:?} against open {open:?} + window {window:?} leaves the floor \
unable to fire, and must not be constructible"
);
}
assert!(
PullDeadlines::capped(
open,
window,
floor,
open + window + Duration::from_millis(1)
)
.is_ok(),
"past open + window the floor can fire, so this is a legitimate pull"
);
assert_matches!(
PullDeadlines::capped(Duration::ZERO, window, floor, Duration::from_mins(1)),
Err(DeadlineError::ZeroBudget)
);
assert_matches!(
PullDeadlines::capped(open, Duration::ZERO, floor, Duration::from_mins(1)),
Err(DeadlineError::ZeroBudget)
);
}
#[test]
fn new_refuses_a_zero_budget_on_the_path_every_production_pull_takes() {
use super::{DeadlineError, PullDeadlines};
use std::time::Duration;
assert_matches!(
PullDeadlines::new(Duration::ZERO, Duration::from_secs(20), 4096),
Err(DeadlineError::ZeroBudget),
"a zero open bound means the open stage can never complete"
);
assert_matches!(
PullDeadlines::new(Duration::from_secs(20), Duration::ZERO, 4096),
Err(DeadlineError::ZeroBudget),
"a zero window makes the throughput floor unsatisfiable on every read"
);
assert!(PullDeadlines::new(Duration::from_secs(20), Duration::from_secs(20), 4096).is_ok());
assert!(
PullDeadlines::new(Duration::from_secs(20), Duration::from_secs(20), 0).is_ok(),
"a zero floor rate is idle-detection mode, not an invalid budget"
);
}
#[test]
fn aligned_wire_len_is_bounded_by_the_requested_byte_len() {
let total = 10 * decdn_bao_range::CHUNK_GROUP_BYTES;
let whole_tail = aligned_wire_len(0, 0, total).unwrap_or(0);
let one_group = aligned_wire_len(0, decdn_bao_range::CHUNK_GROUP_BYTES, total).unwrap_or(0);
assert!(
one_group > 0 && one_group < whole_tail,
"a one-group byte_len must quote less than the whole 10-group tail: \
one_group={one_group}, whole_tail={whole_tail}"
);
let via_align_range =
align_range(0, decdn_bao_range::CHUNK_GROUP_BYTES, total).map_or(0, |r| r.wire_len());
assert_eq!(
one_group, via_align_range,
"aligned_wire_len must reproduce align_range's own wire_len for the same \
bounded span, or the two can silently drift"
);
}
#[test]
fn a_range_the_signed_size_cannot_hold_is_a_source_fault() {
for (offset, len, total) in [(8192, 0, 4096), (0, 16_384, 0)] {
let aligned = aligned_wire_len(offset, len, total).err();
assert!(
aligned.as_ref().is_some_and(|e| {
e.downcast_ref::<LocalPullFault>().is_none()
&& crate::classify(e) == crate::Fault::Source
}),
"({offset}, {len}) against a signed {total} is the source's fault: {aligned:?}"
);
assert!(aligned.as_ref().is_some_and(super::is_range_past_end));
}
assert!(super::is_range_past_end(&anyhow::Error::new(
super::ResumeOffsetPastEnd {
total_bytes: 4096,
byte_offset: 8192,
}
)));
assert!(!super::is_range_past_end(&anyhow::Error::new(
super::UpstreamRefused::mid_stream(decdn_protocol::client::StreamError::NotFound)
)));
assert!(!super::is_range_past_end(
&anyhow::anyhow!("dial timed out").context(LocalPullFault)
));
}
#[test]
fn a_zero_size_signed_for_a_bounded_open_is_refused_as_the_sources_fault() {
let err = super::served_wire_len(0, 16_384, 0).err();
assert!(
err.as_ref().is_some_and(|e| {
e.downcast_ref::<super::ResumeOffsetPastEnd>().is_some()
&& crate::classify(e) == crate::Fault::Source
}),
"a zero size for a bounded open is the source's fault: {err:?}"
);
assert!(
super::served_wire_len(8192, 0, 4096)
.err()
.is_some_and(|e| e.downcast_ref::<super::ResumeOffsetPastEnd>().is_some()),
"an offset past the signed end is refused the same way"
);
assert!(
super::served_wire_len(0, 0, 0).is_ok(),
"the empty blob opens"
);
assert!(
super::served_wire_len(0, 16_384, 4096).is_ok(),
"an end past the size clamps"
);
}
#[test]
fn aligned_wire_len_clamps_when_the_response_total_is_smaller_than_the_requested_end()
-> anyhow::Result<()> {
let total = 4096;
let requested_end_past_total = total + 8192;
let clamped = aligned_wire_len(0, requested_end_past_total, total);
assert!(
!clamped
.as_ref()
.is_err_and(|e| e.downcast_ref::<LocalPullFault>().is_some()),
"a clamped end must never be marked LocalPullFault: {clamped:?}"
);
let whole = aligned_wire_len(0, 0, total)?;
assert_eq!(
clamped?, whole,
"the clamped wire length must equal the whole blob's"
);
Ok(())
}
#[test]
fn client_binding_ext_reflects_binding_presence() -> anyhow::Result<()> {
use std::sync::Arc;
use alloy::primitives::{Address, B256, U256};
use alloy::signers::local::PrivateKeySigner;
use super::{PoolContext, client_binding_ext, sign_client_binding};
let signer = PrivateKeySigner::random();
let domain = decdn_incentive::bind_node_id_domain(1, Address::ZERO);
let ctx = PoolContext {
pool_id: B256::ZERO,
provider: Address::ZERO,
deposit: U256::ZERO,
client_signer: Arc::new(signer.clone()),
voucher_domain: domain.clone(),
prior_bytes_delivered: U256::ZERO,
prior_amount: U256::ZERO,
client_binding: None,
capability: None,
};
anyhow::ensure!(
client_binding_ext(&ctx).is_none(),
"unbound ctx must yield no ext"
);
let binding = sign_client_binding(&signer, B256::repeat_byte(0xAB), &domain)?;
let ctx = ctx.with_client_binding(binding.clone());
let ext =
client_binding_ext(&ctx).ok_or_else(|| anyhow::anyhow!("bound ctx must yield an ext"))?;
anyhow::ensure!(
ext.binding == Some(binding),
"ext must carry the exact binding"
);
anyhow::ensure!(
ext.capability.is_none(),
"no capability attached ⇒ ext must carry none"
);
let owner = PrivateKeySigner::random();
let capability = decdn_incentive::Capability {
signer: signer.address(),
spending_cap: 10_000_000u64,
pool_id: B256::ZERO,
expiry: 1_900_000_000,
}
.sign(&owner, &domain)?;
let ctx = ctx.with_capability(capability.clone());
let ext = client_binding_ext(&ctx)
.ok_or_else(|| anyhow::anyhow!("ctx with capability must yield an ext"))?;
let wire_cap = ext
.capability
.ok_or_else(|| anyhow::anyhow!("ext must carry the capability"))?;
anyhow::ensure!(
wire_cap.spending_cap == capability.capability.spending_cap,
"spending_cap must round-trip to wire form"
);
anyhow::ensure!(
wire_cap.expiry == capability.capability.expiry,
"expiry must round-trip unchanged"
);
anyhow::ensure!(
wire_cap.owner_signature == capability.signature.as_bytes().to_vec(),
"owner_signature must round-trip to wire bytes"
);
Ok(())
}
fn resume_test_ctx(
pool_id: alloy::primitives::B256,
provider: alloy::primitives::Address,
signer: &std::sync::Arc<alloy::signers::local::PrivateKeySigner>,
domain: &alloy::dyn_abi::Eip712Domain,
) -> PoolContext {
PoolContext {
pool_id,
provider,
deposit: U256::ZERO,
client_signer: std::sync::Arc::clone(signer),
voucher_domain: domain.clone(),
prior_bytes_delivered: U256::ZERO,
prior_amount: U256::ZERO,
client_binding: None,
capability: None,
}
}
fn signed_bundle(
pool_id: alloy::primitives::B256,
provider: alloy::primitives::Address,
signer: &alloy::signers::local::PrivateKeySigner,
domain: &alloy::dyn_abi::Eip712Domain,
amount: U256,
bytes_delivered: U256,
) -> anyhow::Result<WatermarkBundle> {
let voucher_signature = Voucher {
pool_id,
signer: signer.address(),
provider,
amount,
bytes_delivered,
chain_root: alloy::primitives::B256::ZERO,
chunk_price: U256::ZERO,
}
.sign(signer, domain)
.map_err(|e| anyhow::anyhow!("voucher signing failed: {e}"))?;
Ok(WatermarkBundle {
amount: u64::try_from(amount)?,
bytes_delivered: u64::try_from(bytes_delivered)?,
chain_root: [0u8; 32],
verified_index: 0,
tip: [0u8; 32],
chunk_price: 0,
last_signature: voucher_signature.signature.as_bytes().to_vec(),
})
}
fn metered_bundle(
pool_id: alloy::primitives::B256,
provider: alloy::primitives::Address,
signer: &alloy::signers::local::PrivateKeySigner,
domain: &alloy::dyn_abi::Eip712Domain,
seed: alloy::primitives::B256,
released: u8,
claimed: u8,
) -> anyhow::Result<WatermarkBundle> {
let chain_root = decdn_incentive::chain::root_from_seed(seed);
let amount = U256::from(500u64);
let bytes_delivered = U256::from(4096u64);
let voucher_signature = Voucher {
pool_id,
signer: signer.address(),
provider,
amount,
bytes_delivered,
chain_root,
chunk_price: U256::from(10u64),
}
.sign(signer, domain)
.map_err(|e| anyhow::anyhow!("voucher signing failed: {e}"))?;
Ok(WatermarkBundle {
amount: u64::try_from(amount)?,
bytes_delivered: u64::try_from(bytes_delivered)?,
chain_root: chain_root.into(),
verified_index: claimed,
tip: decdn_incentive::chain::preimage_at(seed, released).into(),
chunk_price: 10,
last_signature: voucher_signature.signature.as_bytes().to_vec(),
})
}
#[test]
fn resumable_watermark_rejects_a_frontier_the_tip_does_not_prove() -> anyhow::Result<()> {
use alloy::primitives::{Address, B256};
use alloy::signers::local::PrivateKeySigner;
let pool_id = B256::repeat_byte(0x11);
let token = Address::repeat_byte(0x22);
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let our_signer = std::sync::Arc::new(PrivateKeySigner::random());
let ctx = resume_test_ctx(pool_id, token, &our_signer, &domain);
let bundle = metered_bundle(
pool_id,
token,
&our_signer,
&domain,
B256::repeat_byte(0x5E),
3,
255,
)?;
let err = anyhow::Error::new(UpstreamVoucherRejected {
reason: VoucherRejectReason::AmountRegression,
bundle: Some(bundle),
proof_generation: None,
});
anyhow::ensure!(
resumable_watermark(&err, &ctx).is_none(),
"a bundle claiming a depth its tip does not reach must never be folded into money"
);
Ok(())
}
#[test]
fn resumable_watermark_accepts_a_frontier_the_tip_proves() -> anyhow::Result<()> {
use alloy::primitives::{Address, B256};
use alloy::signers::local::PrivateKeySigner;
let pool_id = B256::repeat_byte(0x11);
let token = Address::repeat_byte(0x22);
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let our_signer = std::sync::Arc::new(PrivateKeySigner::random());
let ctx = resume_test_ctx(pool_id, token, &our_signer, &domain);
let bundle = metered_bundle(
pool_id,
token,
&our_signer,
&domain,
B256::repeat_byte(0x5E),
3,
3,
)?;
let err = anyhow::Error::new(UpstreamVoucherRejected {
reason: VoucherRejectReason::AmountRegression,
bundle: Some(bundle),
proof_generation: None,
});
let got = resumable_watermark(&err, &ctx)
.ok_or_else(|| anyhow::anyhow!("a genuinely proved frontier must be resumable"))?;
anyhow::ensure!(Cumulative::from(got).amount == U256::from(530u64));
Ok(())
}
#[test]
fn resumable_watermark_rejects_a_bundle_not_signed_by_our_own_key() -> anyhow::Result<()> {
use alloy::primitives::{Address, B256};
use alloy::signers::local::PrivateKeySigner;
let pool_id = B256::repeat_byte(0x11);
let token = Address::repeat_byte(0x22);
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let our_signer = std::sync::Arc::new(PrivateKeySigner::random());
let attacker_signer = PrivateKeySigner::random();
let ctx = resume_test_ctx(pool_id, token, &our_signer, &domain);
let bundle = signed_bundle(
pool_id,
token,
&attacker_signer,
&domain,
U256::from(1_000_000u64), U256::from(4096u64),
)?;
let err = anyhow::Error::new(UpstreamVoucherRejected {
reason: VoucherRejectReason::AmountRegression,
bundle: Some(bundle),
proof_generation: None,
});
anyhow::ensure!(
resumable_watermark(&err, &ctx).is_none(),
"a bundle signed by a key other than ours must never be treated as resumable"
);
Ok(())
}
#[test]
fn resumable_watermark_rejects_a_bundle_with_a_tampered_amount() -> anyhow::Result<()> {
use alloy::primitives::{Address, B256};
use alloy::signers::local::PrivateKeySigner;
let pool_id = B256::repeat_byte(0x11);
let token = Address::repeat_byte(0x22);
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let our_signer = std::sync::Arc::new(PrivateKeySigner::random());
let ctx = resume_test_ctx(pool_id, token, &our_signer, &domain);
let mut bundle = signed_bundle(
pool_id,
token,
&our_signer,
&domain,
U256::from(100u64),
U256::from(4096u64),
)?;
bundle.amount = 1_000_000u64;
let err = anyhow::Error::new(UpstreamVoucherRejected {
reason: VoucherRejectReason::AmountRegression,
bundle: Some(bundle),
proof_generation: None,
});
anyhow::ensure!(
resumable_watermark(&err, &ctx).is_none(),
"a bundle whose signature does not cover the claimed amount must never be treated \
as resumable"
);
Ok(())
}
#[test]
fn resumable_watermark_accepts_a_bundle_genuinely_signed_by_our_own_key() -> anyhow::Result<()> {
use alloy::primitives::{Address, B256};
use alloy::signers::local::PrivateKeySigner;
let pool_id = B256::repeat_byte(0x11);
let token = Address::repeat_byte(0x22);
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let our_signer = std::sync::Arc::new(PrivateKeySigner::random());
let ctx = resume_test_ctx(pool_id, token, &our_signer, &domain);
let bundle = signed_bundle(
pool_id,
token,
&our_signer,
&domain,
U256::from(500u64),
U256::from(4096u64),
)?;
let expected_bytes = bundle.bytes_delivered;
let err = anyhow::Error::new(UpstreamVoucherRejected {
reason: VoucherRejectReason::AmountRegression,
bundle: Some(bundle),
proof_generation: None,
});
let got = resumable_watermark(&err, &ctx).ok_or_else(|| {
anyhow::anyhow!(
"a bundle genuinely signed by our own key over the claimed tuple must resolve"
)
})?;
assert_eq!(got.bytes_delivered, expected_bytes);
Ok(())
}
fn rejected_with_bundle(
reason: VoucherRejectReason,
ctx: &PoolContext,
signer: &alloy::signers::local::PrivateKeySigner,
(amount, bytes): (u64, u64),
proof_generation: Option<u64>,
) -> anyhow::Result<anyhow::Error> {
let bundle = signed_bundle(
ctx.pool_id,
ctx.provider,
signer,
&ctx.voucher_domain,
U256::from(amount),
U256::from(bytes),
)?;
Ok(anyhow::Error::new(UpstreamVoucherRejected {
reason,
bundle: Some(bundle),
proof_generation,
}))
}
fn heal_test_ctx() -> (
PoolContext,
std::sync::Arc<alloy::signers::local::PrivateKeySigner>,
) {
use alloy::primitives::{Address, B256};
let domain = decdn_incentive::voucher_domain(1, Address::repeat_byte(0x33));
let signer = std::sync::Arc::new(alloy::signers::local::PrivateKeySigner::random());
let ctx = resume_test_ctx(
B256::repeat_byte(0x11),
Address::repeat_byte(0x22),
&signer,
&domain,
);
(ctx, signer)
}
async fn heal(err: &anyhow::Error, ctx: &PoolContext, ledger: &PoolLedger) -> Option<Healed> {
let watermark = rejection_watermark(err, ctx)?;
heal_watermark_desync(err, watermark, ledger).await
}
#[tokio::test]
async fn heal_rebases_on_an_underpaid_bundle_behind_the_ledger() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
});
let err = rejected_with_bundle(
VoucherRejectReason::Underpaid,
&ctx,
&signer,
(60, 5_000),
Some(0),
)?;
assert_eq!(heal(&err, &ctx, &ledger).await, Some(Healed::Rebased));
assert_eq!(
ledger.committed(),
Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(60u64),
}
);
let stale = rejected_with_bundle(
VoucherRejectReason::Underpaid,
&ctx,
&signer,
(50, 4_000),
Some(0),
)?;
assert_eq!(heal(&stale, &ctx, &ledger).await, Some(Healed::Stale));
assert_eq!(ledger.committed().amount, U256::from(60u64));
Ok(())
}
#[tokio::test]
async fn an_amount_regression_behind_the_ledger_retries_without_a_rebase() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let seed = Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
};
let ledger = PoolLedger::new(seed);
let err = rejected_with_bundle(
VoucherRejectReason::AmountRegression,
&ctx,
&signer,
(60, 5_000),
Some(0),
)?;
assert_eq!(heal(&err, &ctx, &ledger).await, Some(Healed::Stale));
assert_eq!(ledger.generation(), 0);
assert_eq!(ledger.committed(), seed);
Ok(())
}
#[tokio::test]
async fn heal_reseeds_on_an_under_fold_bundle_ahead_of_the_ledger() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(60u64),
});
let err = rejected_with_bundle(
VoucherRejectReason::UnderFold,
&ctx,
&signer,
(90, 9_000),
Some(0),
)?;
assert_eq!(heal(&err, &ctx, &ledger).await, Some(Healed::Reseeded));
assert_eq!(ledger.committed().amount, U256::from(90u64));
Ok(())
}
#[tokio::test]
async fn a_sibling_under_fold_after_the_reseed_is_stale() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(60u64),
});
let first = rejected_with_bundle(
VoucherRejectReason::UnderFold,
&ctx,
&signer,
(90, 9_000),
Some(0),
)?;
let second = rejected_with_bundle(
VoucherRejectReason::UnderFold,
&ctx,
&signer,
(90, 9_000),
Some(0),
)?;
assert_eq!(heal(&first, &ctx, &ledger).await, Some(Healed::Reseeded));
assert_eq!(heal(&second, &ctx, &ledger).await, Some(Healed::Stale));
assert_eq!(
ledger.committed(),
Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
}
);
Ok(())
}
#[tokio::test]
async fn a_sibling_amount_regression_after_the_reseed_is_stale() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative::default());
let rejected = || {
rejected_with_bundle(
VoucherRejectReason::AmountRegression,
&ctx,
&signer,
(90, 9_000),
None,
)
};
assert_eq!(
heal(&rejected()?, &ctx, &ledger).await,
Some(Healed::Reseeded)
);
assert_eq!(heal(&rejected()?, &ctx, &ledger).await, Some(Healed::Stale));
assert_eq!(
ledger.committed(),
Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
}
);
Ok(())
}
#[tokio::test]
async fn heal_leaves_a_bytes_only_under_fold_terminal() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let seed = Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(90u64),
};
let ledger = PoolLedger::new(seed);
let err = rejected_with_bundle(
VoucherRejectReason::UnderFold,
&ctx,
&signer,
(90, 9_000),
Some(0),
)?;
assert_eq!(heal(&err, &ctx, &ledger).await, None);
assert_eq!(ledger.committed(), seed);
assert_eq!(ledger.generation(), 0);
Ok(())
}
#[tokio::test]
async fn heal_rebases_on_a_bytes_regression_bundle_ahead_on_bytes() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(90u64),
});
let err = rejected_with_bundle(
VoucherRejectReason::BytesRegression,
&ctx,
&signer,
(80, 9_000),
Some(0),
)?;
let anchor = Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(80u64),
};
assert_eq!(heal(&err, &ctx, &ledger).await, Some(Healed::Rebased));
assert_eq!(ledger.committed(), anchor);
assert_eq!(ledger.generation(), 1);
assert_eq!(ledger.take_unsaved_rebase(), Some(anchor));
Ok(())
}
#[tokio::test]
async fn a_sibling_bytes_regression_after_the_rebase_is_stale() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(90u64),
});
let rejected = |generation| {
rejected_with_bundle(
VoucherRejectReason::BytesRegression,
&ctx,
&signer,
(80, 9_000),
generation,
)
};
assert_eq!(
heal(&rejected(Some(0))?, &ctx, &ledger).await,
Some(Healed::Rebased)
);
assert_eq!(
heal(&rejected(Some(0))?, &ctx, &ledger).await,
Some(Healed::Stale)
);
assert_eq!(
heal(&rejected(None)?, &ctx, &ledger).await,
Some(Healed::Stale)
);
assert_eq!(ledger.generation(), 1);
Ok(())
}
#[tokio::test]
async fn a_bytes_regression_the_ledger_covers_retries_without_a_rebase() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let seed = Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
};
for bundle in [(80, 5_000), (80, 9_000)] {
let ledger = PoolLedger::new(seed);
let err = rejected_with_bundle(
VoucherRejectReason::BytesRegression,
&ctx,
&signer,
bundle,
Some(0),
)?;
assert_eq!(
heal(&err, &ctx, &ledger).await,
Some(Healed::Stale),
"{bundle:?}"
);
assert_eq!(ledger.committed(), seed, "{bundle:?}");
assert_eq!(ledger.generation(), 0, "{bundle:?}");
assert_eq!(ledger.take_unsaved_rebase(), None, "{bundle:?}");
}
Ok(())
}
#[tokio::test]
async fn a_bytes_regression_from_an_earlier_generation_is_stale() -> anyhow::Result<()> {
let (ctx, signer) = heal_test_ctx();
let ledger = PoolLedger::new(Cumulative {
bytes: U256::from(5_000u64),
amount: U256::from(90u64),
});
let rejected = |bundle, generation| {
rejected_with_bundle(
VoucherRejectReason::BytesRegression,
&ctx,
&signer,
bundle,
generation,
)
};
assert_eq!(
heal(&rejected((80, 9_000), Some(0))?, &ctx, &ledger).await,
Some(Healed::Rebased)
);
let progressed = Cumulative {
bytes: U256::from(9_500u64),
amount: U256::from(100u64),
};
assert!(ledger.reseed(progressed));
assert_eq!(
heal(&rejected((95, 9_800), Some(0))?, &ctx, &ledger).await,
Some(Healed::Stale)
);
assert_eq!(ledger.committed(), progressed);
assert_eq!(ledger.generation(), 1);
assert_eq!(
heal(&rejected((95, 9_800), Some(1))?, &ctx, &ledger).await,
Some(Healed::Rebased)
);
assert_eq!(ledger.generation(), 2);
assert_eq!(
ledger.committed(),
Cumulative {
bytes: U256::from(9_800u64),
amount: U256::from(95u64),
}
);
Ok(())
}
#[tokio::test]
async fn heal_refuses_a_rebase_bundle_we_did_not_sign() -> anyhow::Result<()> {
let (ctx, _signer) = heal_test_ctx();
let stranger = alloy::signers::local::PrivateKeySigner::random();
let seed = Cumulative {
bytes: U256::from(9_000u64),
amount: U256::from(90u64),
};
for (reason, bundle) in [
(VoucherRejectReason::Underpaid, (60, 5_000)),
(VoucherRejectReason::BytesRegression, (80, 12_000)),
] {
let ledger = PoolLedger::new(seed);
let err = rejected_with_bundle(reason, &ctx, &stranger, bundle, Some(0))?;
assert_eq!(heal(&err, &ctx, &ledger).await, None, "{reason:?}");
assert_eq!(ledger.committed(), seed, "{reason:?}");
}
Ok(())
}
fn make_blob(len: usize) -> Vec<u8> {
let mut v = vec![0u8; len];
let mut x: u32 = 0x9e37_79b9;
for b in &mut v {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes().first().copied().unwrap_or(0);
}
v
}
fn sub(data: &[u8], start: u64, end: u64) -> anyhow::Result<Vec<u8>> {
let s = usize::try_from(start)?;
let e = usize::try_from(end)?;
data.get(s..e)
.map(<[u8]>::to_vec)
.ok_or_else(|| anyhow::anyhow!("range [{start}, {end}) out of bounds"))
}
fn wire_for(blob: &[u8], byte_offset: u64, byte_len: u64) -> anyhow::Result<([u8; 32], Vec<u8>)> {
let ob = PreOrderMemOutboard::create(blob, IROH_BLOCK_SIZE);
let root = *ob.root.as_bytes();
let blob_size = u64::try_from(blob.len())?;
let aligned = align_range(byte_offset, byte_len, blob_size)?;
let data = sub(blob, aligned.fetch_start(), aligned.fetch_end())?;
let combined = encode_verified_range(root, &aligned, &data, ob.data.clone().into())?;
let wire = combined
.get(8..)
.ok_or_else(|| anyhow::anyhow!("combined shorter than 8-byte header"))?
.to_vec();
Ok((root, wire))
}
async fn decode_wire(
root: [u8; 32],
total_bytes: u64,
byte_offset: u64,
wire: &[u8],
) -> anyhow::Result<Vec<u8>> {
let aligned = align_range(byte_offset, 0, total_bytes)?;
let (out, _reader) = decode_to_vec(
root,
total_bytes,
&aligned,
Bytes::copy_from_slice(wire),
None,
)
.await?;
let lead = usize::try_from(byte_offset.saturating_sub(aligned.fetch_start()))?;
out.get(lead..)
.map(<[u8]>::to_vec)
.ok_or_else(|| anyhow::anyhow!("decoded range shorter than requested span"))
}
#[tokio::test]
async fn decode_to_vec_round_trips_whole_blob() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let (root, wire) = wire_for(&blob, 0, 0)?;
let out = decode_wire(root, u64::try_from(blob.len())?, 0, &wire).await?;
anyhow::ensure!(out == blob, "whole-blob round-trip");
Ok(())
}
#[tokio::test]
async fn decode_to_vec_resumed_group_aligned_offset() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let off = 64 * 1024; let (root, wire) = wire_for(&blob, off, 0)?;
let out = decode_wire(root, u64::try_from(blob.len())?, off, &wire).await?;
let want = sub(&blob, off, u64::try_from(blob.len())?)?;
anyhow::ensure!(out == want, "resumed tail self-verifies");
Ok(())
}
#[tokio::test]
async fn decode_to_vec_trims_non_aligned_offset() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let off = 70 * 1024; let (root, wire) = wire_for(&blob, off, 0)?;
let out = decode_wire(root, u64::try_from(blob.len())?, off, &wire).await?;
let want = sub(&blob, off, u64::try_from(blob.len())?)?;
anyhow::ensure!(out == want, "trimmed to requested offset");
Ok(())
}
#[tokio::test]
async fn decode_to_vec_rejects_corrupt_tail() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let off = 64 * 1024;
let (root, mut wire) = wire_for(&blob, off, 0)?;
let last = wire
.len()
.checked_sub(8)
.ok_or_else(|| anyhow::anyhow!("wire too short"))?;
if let Some(b) = wire.get_mut(last) {
*b ^= 0xff;
}
let err = decode_wire(root, u64::try_from(blob.len())?, off, &wire)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected corrupt tail to be rejected"))?;
anyhow::ensure!(
err.downcast_ref::<HashMismatch>().is_some(),
"corrupt tail must surface HashMismatch, got: {err}"
);
Ok(())
}
#[tokio::test]
async fn decode_to_vec_rejects_corrupt_middle_group_without_tail() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let (root, mut wire) = wire_for(&blob, 0, 0)?;
let corrupt_at = wire.len() * 55 / 100;
let truncate_at = wire.len() * 70 / 100;
if let Some(b) = wire.get_mut(corrupt_at) {
*b ^= 0xff;
}
wire.truncate(truncate_at);
let err = decode_wire(root, u64::try_from(blob.len())?, 0, &wire)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected corrupt middle group to be rejected"))?;
anyhow::ensure!(
err.downcast_ref::<HashMismatch>().is_some(),
"corrupt middle group must surface HashMismatch (early rejection), got: {err}"
);
Ok(())
}
#[tokio::test]
async fn decode_to_vec_truncation_is_not_hash_mismatch() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let (root, mut wire) = wire_for(&blob, 0, 0)?;
wire.truncate(wire.len() * 60 / 100);
let err = decode_wire(root, u64::try_from(blob.len())?, 0, &wire)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected truncated stream to be rejected"))?;
anyhow::ensure!(
err.downcast_ref::<HashMismatch>().is_none(),
"clean truncation must NOT be classified as corruption, got HashMismatch: {err}"
);
Ok(())
}
#[tokio::test]
async fn decode_to_vec_rejects_wrong_root() -> anyhow::Result<()> {
let blob = make_blob(200 * 1024 + 777);
let (_root, wire) = wire_for(&blob, 0, 0)?;
let wrong = [0xABu8; 32];
let err = decode_wire(wrong, u64::try_from(blob.len())?, 0, &wire)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected wrong-root rejection"))?;
anyhow::ensure!(
err.downcast_ref::<HashMismatch>().is_some(),
"wrong root must surface HashMismatch, got: {err}"
);
Ok(())
}
#[test]
fn an_open_stage_refusal_preserves_the_signed_stream_response() -> anyhow::Result<()> {
use alloy::signers::local::PrivateKeySigner;
use decdn_incentive::slash_judge_domain;
use decdn_incentive::stream_sig::StreamSlashData;
use decdn_protocol::client::{StreamError, StreamResponse, StreamResponseBody};
use super::UpstreamRefused;
let operator = PrivateKeySigner::random();
let domain = slash_judge_domain(31_337, alloy::primitives::Address::repeat_byte(0x11));
let body = StreamResponseBody {
hash: [0x5Au8; 32],
ok: false,
rate_per_mb: 10,
total_bytes: 0,
pool_id: [0x77u8; 32],
timestamp_us: 1_700_000_000_000_000,
};
let sig = StreamSlashData::from_response_body(&body).sign(&operator, &domain)?;
let response = StreamResponse {
body: body.clone(),
slash_sig: sig.as_bytes().to_vec(),
};
let response_ext = decdn_protocol::StreamResponseExt {
error: Some(StreamError::Declined),
};
response.validate()?;
response_ext.validate(response.body.ok)?;
let err = UpstreamRefused::open(response, &response_ext);
let refused = err
.downcast_ref::<UpstreamRefused>()
.ok_or_else(|| anyhow::anyhow!("open() must stay a typed UpstreamRefused: {err:#}"))?;
anyhow::ensure!(
*refused.error() == StreamError::Declined,
"the wire code must survive unchanged, got {:?}",
refused.error()
);
let preserved = refused.evidence().ok_or_else(|| {
anyhow::anyhow!("the signed StreamResponse must survive on the refusal (#1042)")
})?;
anyhow::ensure!(
preserved.body == body,
"the preserved body must be the signed body verbatim"
);
anyhow::ensure!(
response_ext.error.as_ref() == Some(refused.error()),
"the derived wire code {:?} must match the code the extension carried {:?}",
refused.error(),
response_ext.error,
);
let recovered = alloy::primitives::Signature::try_from(preserved.slash_sig.as_slice())?;
StreamSlashData::from_response_body(&preserved.body).verify_signer(
&recovered,
operator.address(),
&domain,
)?;
Ok(())
}
#[test]
fn open_on_a_response_without_an_error_code_is_not_a_typed_refusal() {
use decdn_protocol::client::{StreamResponse, StreamResponseBody};
use super::UpstreamRefused;
let response = StreamResponse {
body: StreamResponseBody {
hash: [0x5Au8; 32],
ok: false,
rate_per_mb: 10,
total_bytes: 0,
pool_id: [0x77u8; 32],
timestamp_us: 1_700_000_000_000_000,
},
slash_sig: vec![0u8; decdn_protocol::message::SLASH_SIG_LEN],
};
let err = UpstreamRefused::open(response, &decdn_protocol::StreamResponseExt::default());
assert!(
err.downcast_ref::<UpstreamRefused>().is_none(),
"a response with no error code must not become a typed refusal"
);
}
#[test]
fn effective_rate_ceiling_is_min_with_zero_as_unbounded() {
use super::effective_rate_ceiling;
assert_eq!(
effective_rate_ceiling(0, 0),
0,
"both unbounded => unbounded"
);
assert_eq!(
effective_rate_ceiling(10, 0),
10,
"config unbounded => probe"
);
assert_eq!(
effective_rate_ceiling(0, 900),
900,
"probe unbounded => config"
);
assert_eq!(effective_rate_ceiling(10, 900), 10, "min: probe binds");
assert_eq!(effective_rate_ceiling(900, 10), 10, "min: config binds");
assert_eq!(effective_rate_ceiling(42, 42), 42, "equal bounds");
}
#[test]
fn a_mid_stream_refusal_never_carries_a_response() {
use decdn_protocol::client::StreamError;
use super::UpstreamRefused;
let refused = UpstreamRefused::mid_stream(StreamError::Declined);
assert!(
refused.evidence().is_none(),
"a mid-stream refusal has no signed response to carry"
);
assert_eq!(
*refused.error(),
StreamError::Declined,
"the mid-stream wire code must survive unchanged"
);
}