#![cfg(feature = "anvil-e2e")]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
// Second-scale timeouts read more clearly as `from_secs` than `from_mins`.
clippy::duration_suboptimal_units
)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use alloy::network::{EthereumWallet, TransactionBuilder};
use alloy::primitives::{Address, B256, Bytes, U256, keccak256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::Filter;
use alloy::signers::SignerSync;
use alloy::signers::local::PrivateKeySigner;
use alloy::sol_types::SolEvent;
use anyhow::Context;
use decdn_client::buyer_pool::{SELF_CAPABILITY_CAP, issue_self_capability};
use decdn_client::sign_client_binding;
use decdn_client::{PoolContext, stream_fetch};
use decdn_incentive::payment_pool::PaymentPool;
use decdn_incentive::{
BuyerPoolStore, KeyedCheckpointStore, MemoryBuyerPoolStore, PoolStateStore,
bind_node_id_domain, register_node_signing_hash, slash_judge_domain, voucher_domain,
};
use decdn_node::buyer_pool::BuyerPoolService;
use decdn_node::chain_events::boot_retry::BootRetry;
use decdn_node::chain_events::shared_head::{HeadSource, SharedHead};
use decdn_node::metrics::Metrics;
use decdn_node::payment_settlement::PoolSettlementService;
use decdn_node::pool_store::{Deployment, PersistentPoolStateStore};
use decdn_protocol::ALPN_CLIENT;
use iroh::EndpointAddr;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
mod support;
use support::{
HandlerDomains, build_handler_full_configured, cache_with_blob, fresh_key, local_endpoint,
permissive_limiter, spawn_server,
};
const OPEN_BUDGET: Duration = Duration::from_secs(60);
const CHAIN_ID: u64 = 31_337_690;
const DEPLOYER_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const DEPLOYER_ADDR: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const ADMIN_KEY: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const EPOCH_LENGTH_SECS: u64 = 7 * 24 * 60 * 60; const RATE_PER_MB: u64 = 10; const MIB: usize = 1024 * 1024;
const REDEEM_THRESHOLD_MICRO_USDC: u64 = 10;
const DEPOSIT_MICRO_USDC: u64 = 10_000_000; const TOPUP_MICRO_USDC: u64 = 2_000_000; const GRACE_WINDOW_WARP_SECS: u64 = 60 * 24 * 60 * 60;
const FORGE_BUILD_TIMEOUT: Duration = Duration::from_secs(180);
const DEPLOY_TIMEOUT: Duration = Duration::from_secs(45);
const DEPLOY_ATTEMPTS: usize = 3;
const OVERALL_TIMEOUT: Duration = Duration::from_secs(780);
#[derive(Default)]
struct CapCountVisitor {
cap_count: Option<usize>,
tx: Option<B256>,
}
impl tracing::field::Visit for CapCountVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
match field.name() {
"cap_count" => self.cap_count = format!("{value:?}").parse().ok(),
"tx" => self.tx = format!("{value:?}").parse().ok(),
_ => {}
}
}
}
struct CapCountLayer {
captured: Arc<Mutex<HashMap<B256, usize>>>,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapCountLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = CapCountVisitor::default();
event.record(&mut visitor);
if let (Some(cap_count), Some(tx)) = (visitor.cap_count, visitor.tx) {
self.captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(tx, cap_count);
}
}
}
struct ReconcileFailureLayer {
count: Arc<AtomicUsize>,
}
#[derive(Default)]
struct ReconcileStageVisitor {
hit: bool,
}
impl ReconcileStageVisitor {
fn observe(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "stage" && value.trim_matches('"') == "reconcile" {
self.hit = true;
}
}
}
impl tracing::field::Visit for ReconcileStageVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.observe(field, value);
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "stage" {
self.observe(field, &format!("{value:?}"));
}
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for ReconcileFailureLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = ReconcileStageVisitor::default();
event.record(&mut visitor);
if visitor.hit {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
}
struct AnvilGuard {
child: Child,
manifest: PathBuf,
}
impl Drop for AnvilGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.manifest);
}
}
fn e2e_head<P>(provider: &P) -> Arc<dyn HeadSource>
where
P: Provider + Clone + 'static,
{
Arc::new(SharedHead::new(
provider.clone(),
Duration::from_millis(250),
))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn contracts_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../contracts")
.canonicalize()
.expect("contracts dir")
}
fn free_port() -> u16 {
let l = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind ephemeral");
l.local_addr().expect("local_addr").port()
}
async fn poll_until<T, F, Fut>(timeout: Duration, mut f: F) -> Option<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Option<T>>,
{
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(v) = f().await {
return Some(v);
}
if tokio::time::Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
async fn forge_output(
mut cmd: tokio::process::Command,
timeout: Duration,
what: &str,
) -> anyhow::Result<Result<std::process::Output, Duration>> {
cmd.kill_on_drop(true);
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(res) => res
.map(Ok)
.with_context(|| format!("spawn `{what}` (is foundry installed?)")),
Err(_) => Ok(Err(timeout)),
}
}
fn forge_script_body_completed(stdout: &[u8]) -> bool {
String::from_utf8_lossy(stdout).contains("Script ran successfully")
}
#[test]
fn broadcast_phase_failure_is_retryable() {
let stdout = b"No files changed, compilation skipped\nScript ran successfully.\n\n== Return ==\nd: struct BaseProtocolDeploy.Deployment Deployment({ token: 0x959, paymentPool: 0x4ed })\n\n## Setting up 1 EVM.";
assert!(forge_script_body_completed(stdout));
}
#[test]
fn genuine_revert_fails_fast() {
let stdout = b"Error: Simulated execution failed.\nReason: revert: ProviderNotActive\n";
assert!(!forge_script_body_completed(stdout));
}
#[test]
fn empty_output_is_not_retryable() {
assert!(!forge_script_body_completed(b""));
}
alloy::sol! {
#[sol(rpc)]
contract Erc20 {
function mint(address to, uint256 amount) external;
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
#[sol(rpc)]
contract CapacityBondWrite {
function bond(uint256 amount) external;
function currentTermsHash() external view returns (bytes32);
function registerNode(
bytes32 nodeId,
bytes multiaddrs,
string regionHint,
bytes32 termsHash,
bytes bindingSignature,
bytes ed25519Signature
) external;
function isActive(address operator) external view returns (bool);
}
#[sol(rpc)]
contract FeeRouterView {
function bytesPerEpoch(address operator, uint64 epoch) external view returns (uint256);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn e2e_onchain_payment_pool_settlement() -> anyhow::Result<()> {
tokio::time::timeout(OVERALL_TIMEOUT, Box::pin(run_e2e()))
.await
.with_context(|| format!("anvil-e2e exceeded the overall {OVERALL_TIMEOUT:?} timeout"))?
}
#[allow(
clippy::cognitive_complexity,
clippy::too_many_lines,
reason = "single end-to-end settlement journey: each step depends on the previous step's chain \
and runtime state, so decomposing it would thread a state bundle through helpers \
without reducing the journey's length or making it easier to follow"
)]
async fn run_e2e() -> anyhow::Result<()> {
let cap_count_log: Arc<Mutex<HashMap<B256, usize>>> = Arc::new(Mutex::new(HashMap::new()));
let reconcile_failures: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let level = || {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
};
let _ = tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(level()),
)
.with(
CapCountLayer {
captured: Arc::clone(&cap_count_log),
}
.with_filter(level()),
)
.with(
ReconcileFailureLayer {
count: Arc::clone(&reconcile_failures),
}
.with_filter(tracing_subscriber::filter::LevelFilter::WARN),
)
.try_init();
let contracts = contracts_dir();
let mut build_cmd = tokio::process::Command::new("forge");
build_cmd.current_dir(&contracts).args(["build"]);
let build = match forge_output(build_cmd, FORGE_BUILD_TIMEOUT, "forge build").await? {
Ok(out) => out,
Err(timeout) => anyhow::bail!("`forge build` timed out after {timeout:?}"),
};
assert!(
build.status.success(),
"forge build failed:\n{}\n{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr)
);
let port = free_port();
let rpc_url = format!("http://127.0.0.1:{port}");
let child = Command::new("anvil")
.args([
"--port",
&port.to_string(),
"--chain-id",
&CHAIN_ID.to_string(),
"--silent",
])
.spawn()
.expect("spawn anvil (is foundry installed?)");
let manifest = contracts.join(format!("deployments/{CHAIN_ID}.json"));
let _anvil = AnvilGuard {
child,
manifest: manifest.clone(),
};
let url: reqwest::Url = rpc_url.parse()?;
let admin_signer: PrivateKeySigner = ADMIN_KEY.parse()?;
decdn_common::tls::install_crypto_provider();
let admin = ProviderBuilder::new()
.wallet(EthereumWallet::from(admin_signer))
.connect_http(url.clone());
poll_until(Duration::from_secs(20), || async {
admin.get_chain_id().await.ok()
})
.await
.expect("anvil RPC never came up");
let node_iroh_sk = fresh_key();
let node_id = B256::from(*node_iroh_sk.public().as_bytes());
let node_pub = node_iroh_sk.public();
let node_signer = PrivateKeySigner::random();
let node_addr = node_signer.address();
let client_signer = Arc::new(PrivateKeySigner::random());
let client_addr = client_signer.address();
for who in [node_addr, client_addr] {
let _: serde_json::Value = admin
.raw_request(
"anvil_setBalance".into(),
(
who,
U256::from(100u64) * U256::from(10u64).pow(U256::from(18)),
),
)
.await?;
}
let usdc_addr = deploy_mock_usdc(&admin, &contracts).await?;
run_deploy_script(&contracts, &rpc_url, usdc_addr, node_addr).await?;
let (capacity_bond, payment_pool, fee_router, token, slash_judge) = read_manifest(&manifest)?;
let node_provider = ProviderBuilder::new()
.with_simple_nonce_management()
.wallet(EthereumWallet::from(node_signer.clone()))
.connect_http(url.clone());
let client_provider = ProviderBuilder::new()
.with_simple_nonce_management()
.wallet(EthereumWallet::from((*client_signer).clone()))
.connect_http(url.clone());
let bond = CapacityBondWrite::new(capacity_bond, node_provider.clone());
let token_erc20 = Erc20::new(token, node_provider.clone());
let usdc_client = Erc20::new(usdc_addr, client_provider.clone());
let usdc_admin = Erc20::new(usdc_addr, admin.clone());
let pool_client = PaymentPool::new(payment_pool, client_provider.clone());
let pool_read = PaymentPool::new(payment_pool, node_provider.clone());
let fee_view = FeeRouterView::new(fee_router, node_provider.clone());
let min_bond: U256 = "50000000000000000000000".parse()?; token_erc20
.approve(capacity_bond, min_bond)
.send()
.await?
.get_receipt()
.await?;
bond.bond(min_bond).send().await?.get_receipt().await?;
let terms_hash = bond.currentTermsHash().call().await?;
let bind_domain = bind_node_id_domain(CHAIN_ID, capacity_bond);
let binding_sig = node_signer
.sign_hash_sync(®ister_node_signing_hash(
node_id,
0,
terms_hash,
&bind_domain,
))?
.as_bytes()
.to_vec();
let mut msg = Vec::with_capacity(92);
msg.extend_from_slice(node_id.as_slice()); msg.extend_from_slice(node_addr.as_slice()); msg.extend_from_slice(&U256::from(CHAIN_ID).to_be_bytes::<32>()); msg.extend_from_slice(&0u64.to_be_bytes()); let ed_sig = node_iroh_sk
.sign(keccak256(&msg).as_slice())
.to_bytes()
.to_vec();
bond.registerNode(
node_id,
Bytes::from_static(b"/ip4/127.0.0.1/udp/4242/quic-v1"),
"us-east-1".to_string(),
terms_hash,
Bytes::from(binding_sig),
Bytes::from(ed_sig),
)
.send()
.await?
.get_receipt()
.await?;
anyhow::ensure!(
bond.isActive(node_addr).call().await?,
"node operator must be active after stake + registerNode"
);
let payload = vec![0xABu8; 3 * MIB / 2]; let (cache, hash, _cache_tmp) = cache_with_blob(&payload).await?;
let store_tmp = tempfile::tempdir()?;
#[cfg(unix)]
std::fs::set_permissions(
store_tmp.path(),
std::os::unix::fs::PermissionsExt::from_mode(0o700),
)?;
let concrete_store = Arc::new(PersistentPoolStateStore::open(
store_tmp.path(),
Deployment {
chain_id: CHAIN_ID,
payment_pool,
},
)?);
let store: Arc<dyn PoolStateStore> = concrete_store.clone();
let checkpoint_store: Arc<dyn KeyedCheckpointStore> = concrete_store.clone();
let node_eth = Arc::new(node_signer.clone());
let metrics = Arc::new(Metrics::new());
let voucher_dom = voucher_domain(CHAIN_ID, payment_pool);
let domains = HandlerDomains {
slash: slash_judge_domain(CHAIN_ID, slash_judge),
voucher: voucher_dom.clone(),
binding: bind_domain.clone(),
};
let (redeem_tx, redeem_rx) =
tokio::sync::mpsc::channel(decdn_node::payment_settlement::REDEEM_HINT_CAPACITY);
let pool_view = decdn_node::pool_view::PoolProjection::new();
let handler = {
let hint = redeem_tx.clone();
let pool_view = pool_view.clone();
build_handler_full_configured(
node_pub,
&node_eth,
&metrics,
permissive_limiter(&metrics),
cache,
Arc::clone(&store),
RATE_PER_MB,
&domains,
16,
move |deps| {
deps.redeem_hint = Some(hint);
deps.pool_view =
Some(Arc::new(pool_view) as Arc<dyn decdn_node::pool_view::PoolView>);
},
)?
};
let (service, settlement_route) = PoolSettlementService::bootstrap(
node_provider.clone(),
payment_pool,
node_addr,
Arc::clone(&store),
Arc::clone(&checkpoint_store),
Arc::clone(&handler),
U256::from(REDEEM_THRESHOLD_MICRO_USDC),
300,
Duration::from_secs(300),
Arc::clone(&metrics),
&BootRetry::single_attempt(Arc::clone(&metrics)),
pool_view.clone(),
redeem_tx,
redeem_rx,
)
.await?;
let settlement_poller = {
use decdn_node::chain_events::multiplexed_poller::{MultiplexedPollerBuilder, spawn};
let poller =
MultiplexedPollerBuilder::new(e2e_head(&node_provider), Duration::from_millis(250))
.route(settlement_route)
.build()?;
spawn(node_provider.clone(), poller)
};
let (server_ep, server_addr) = local_endpoint(node_iroh_sk, vec![ALPN_CLIENT.to_vec()]).await?;
let server_task = spawn_server(server_ep.clone(), Arc::clone(&handler));
let client_sk = fresh_key();
let client_node_id = B256::from(*client_sk.public().as_bytes());
let (client_ep, _) = local_endpoint(client_sk, vec![]).await?;
let target = EndpointAddr::new(node_pub).with_ip_addr(server_addr);
usdc_admin
.mint(client_addr, U256::from(1_000_000_000u64))
.send()
.await?
.get_receipt()
.await?;
usdc_client
.approve(
payment_pool,
U256::from(DEPOSIT_MICRO_USDC) * U256::from(4u64),
)
.send()
.await?
.get_receipt()
.await?;
let deposit = DEPOSIT_MICRO_USDC;
let open_receipt = pool_client
.openPool(deposit)
.send()
.await?
.get_receipt()
.await?;
let pool_id = open_receipt
.inner
.logs()
.iter()
.filter_map(|l| l.log_decode::<PaymentPool::PoolOpened>().ok())
.find(|d| d.inner.data.owner == client_addr)
.map(|d| d.inner.data.poolId)
.ok_or_else(|| anyhow::anyhow!("PoolOpened event missing from openPool receipt"))?;
let redeem_scan_from = node_provider.get_block_number().await?;
{
use decdn_node::pool_view::PoolView;
let view = pool_view.clone();
let owner = poll_until(Duration::from_secs(30), || {
let view = view.clone();
async move { view.status(pool_id).await.map(|s| s.owner) }
})
.await;
anyhow::ensure!(
owner == Some(client_addr),
"pool projection did not observe PoolOpened owner in time"
);
}
let capability = issue_self_capability(
client_signer.as_ref(),
pool_id,
SELF_CAPABILITY_CAP,
unix_now() + 1_000_000,
&voucher_dom,
)?;
let binding = sign_client_binding(client_signer.as_ref(), client_node_id, &bind_domain)?;
let ctx = PoolContext {
pool_id,
provider: node_addr,
deposit: U256::from(deposit),
client_signer: Arc::clone(&client_signer),
voucher_domain: voucher_dom.clone(),
prior_bytes_delivered: U256::ZERO,
prior_amount: U256::ZERO,
client_binding: Some(binding),
capability: Some(capability),
};
let got = stream_fetch(
&client_ep,
target.clone(),
&ctx,
&domains.slash,
node_addr,
*hash.as_bytes(),
0,
0x00c0_ffe1,
Duration::from_secs(30),
)
.await?;
anyhow::ensure!(
got.as_ref() == payload.as_slice(),
"seller delivery mismatch"
);
let watermark = poll_until(Duration::from_secs(60), || {
let pool = pool_read.clone();
async move {
pool.getWatermark(pool_id, client_addr, node_addr)
.call()
.await
.ok()
.filter(|lane| lane.bytesDelivered > 0)
}
})
.await;
anyhow::ensure!(
watermark.is_some(),
"on-chain redeem never landed (normalized v-byte not accepted, or capability lane not registered)"
);
let epoch = current_epoch(&node_provider).await?;
let routed = fee_view.bytesPerEpoch(node_addr, epoch).call().await?;
anyhow::ensure!(
routed > U256::ZERO,
"FeeRouter.bytesPerEpoch did not increment (settlement not routed): {routed}"
);
let auth_after_first = pool_read
.getAuthorization(pool_id, client_addr)
.call()
.await?;
anyhow::ensure!(
auth_after_first.cap != 0,
"first redeem must land an on-chain Authorization for the signer (cap == 0)"
);
let lane_key = decdn_incentive::LaneKey {
pool_id,
signer: client_addr,
provider: node_addr,
};
let lane_after_first = poll_until(Duration::from_secs(10), || {
let store = Arc::clone(&store);
async move {
let st = store.get(lane_key).ok().flatten()?;
(st.registered_until != 0).then_some(st)
}
})
.await
.ok_or_else(|| {
let snapshot = store.get(lane_key).ok().flatten();
anyhow::anyhow!(
"registered_until must be persisted once the first redeem's CapabilityReg lands; \
got lane={snapshot:?} auth_cap={} auth_expiry={}",
auth_after_first.cap,
auth_after_first.expiry
)
})?;
tokio::time::sleep(Duration::from_millis(100)).await;
let watermark_after_first = watermark
.as_ref()
.ok_or_else(|| anyhow::anyhow!("watermark checked above"))?
.clone();
anyhow::ensure!(
watermark_after_first.amount >= REDEEM_THRESHOLD_MICRO_USDC
&& watermark_after_first.amount < watermark_after_first.bytesDelivered,
"Lane.amount must be the micro-USDC paid (>= the redeem threshold {}), well below the \
byte count: amount={} bytesDelivered={}",
REDEEM_THRESHOLD_MICRO_USDC,
watermark_after_first.amount,
watermark_after_first.bytesDelivered
);
let second_ctx = PoolContext {
prior_bytes_delivered: lane_after_first.owed_bytes(),
prior_amount: lane_after_first.owed(),
capability: None,
..ctx.clone()
};
let got_again = stream_fetch(
&client_ep,
target.clone(),
&second_ctx,
&domains.slash,
node_addr,
*hash.as_bytes(),
0,
0x00c0_ffe2,
Duration::from_secs(30),
)
.await?;
anyhow::ensure!(
got_again.as_ref() == payload.as_slice(),
"second seller delivery mismatch"
);
let watermark_2 = poll_until(Duration::from_secs(60), || {
let pool = pool_read.clone();
let floor = watermark_after_first.bytesDelivered;
async move {
pool.getWatermark(pool_id, client_addr, node_addr)
.call()
.await
.ok()
.filter(|lane| lane.bytesDelivered > floor)
}
})
.await;
anyhow::ensure!(
watermark_2.is_some(),
"second redeem on an already-registered lane never landed"
);
let batch_lanes = pool_read
.getWatermarks(
vec![pool_id, pool_id],
vec![client_addr, client_addr],
vec![node_addr, Address::ZERO],
)
.call()
.await?;
anyhow::ensure!(
batch_lanes.len() == 2,
"getWatermarks must return one lane per input triple, got {}",
batch_lanes.len()
);
anyhow::ensure!(
batch_lanes
.get(1)
.is_some_and(|l| l.amount == 0 && l.bytesDelivered == 0),
"the never-redeemed triple must read back as the empty lane"
);
for (idx, (signer, provider)) in [(client_addr, node_addr), (client_addr, Address::ZERO)]
.into_iter()
.enumerate()
{
let single = pool_read
.getWatermark(pool_id, signer, provider)
.call()
.await?;
let batched = batch_lanes
.get(idx)
.ok_or_else(|| anyhow::anyhow!("getWatermarks entry {idx} missing"))?;
anyhow::ensure!(
batched.amount == single.amount && batched.bytesDelivered == single.bytesDelivered,
"getWatermarks entry {idx} disagrees with getWatermark: batched=({}, {}) \
single=({}, {})",
batched.amount,
batched.bytesDelivered,
single.amount,
single.bytesDelivered
);
}
let auth_after_second = pool_read
.getAuthorization(pool_id, client_addr)
.call()
.await?;
anyhow::ensure!(
auth_after_second.cap == auth_after_first.cap
&& auth_after_second.expiry == auth_after_first.expiry,
"Authorization must not change across the second, already-registered sweep"
);
let _lane_after_second = poll_until(Duration::from_secs(10), || {
let store = Arc::clone(&store);
async move {
let st = store.get(lane_key).ok().flatten()?;
(st.registered_until != 0).then_some(st)
}
})
.await
.ok_or_else(|| {
let snapshot = store.get(lane_key).ok().flatten();
anyhow::anyhow!(
"registered_until must remain persisted after the second sweep; got lane={snapshot:?}"
)
})?;
let (tx_first, block_first) = redeem_tx_for_lane(
&node_provider,
payment_pool,
pool_id,
node_addr,
redeem_scan_from,
)
.await?;
let cap_count_first = poll_until(Duration::from_secs(10), || {
let captured = Arc::clone(&cap_count_log);
let tx = tx_first;
async move { cap_count_for_tx(&captured, tx).ok() }
})
.await
.ok_or_else(|| anyhow::anyhow!("no captured cap_count log line for redeem tx {tx_first}"))?;
anyhow::ensure!(
cap_count_first == 1,
"first redeem must attach exactly one CapabilityReg (the signer registers on-chain \
for the first time): cap_count={cap_count_first}"
);
let (tx_second, _block_second) = redeem_tx_for_lane(
&node_provider,
payment_pool,
pool_id,
node_addr,
block_first + 1,
)
.await?;
let cap_count_second = poll_until(Duration::from_secs(10), || {
let captured = Arc::clone(&cap_count_log);
let tx = tx_second;
async move { cap_count_for_tx(&captured, tx).ok() }
})
.await
.ok_or_else(|| anyhow::anyhow!("no captured cap_count log line for redeem tx {tx_second}"))?;
anyhow::ensure!(
cap_count_second == 0,
"second sweep must attach NO CapabilityReg — registration is already known from the \
persisted registered_until watermark, so the batched-read skip must fire: \
cap_count={cap_count_second}"
);
let buyer_signer = Arc::new(PrivateKeySigner::random());
let buyer_addr = buyer_signer.address();
let _: serde_json::Value = admin
.raw_request(
"anvil_setBalance".into(),
(
buyer_addr,
U256::from(100u64) * U256::from(10u64).pow(U256::from(18)),
),
)
.await?;
let buyer_provider = ProviderBuilder::new()
.with_simple_nonce_management()
.wallet(EthereumWallet::from((*buyer_signer).clone()))
.connect_http(url.clone());
let usdc_buyer = Erc20::new(usdc_addr, buyer_provider.clone());
let buyer_pool = PaymentPool::new(payment_pool, buyer_provider.clone());
usdc_admin
.mint(
buyer_addr,
U256::from(DEPOSIT_MICRO_USDC) * U256::from(4u64),
)
.send()
.await?
.get_receipt()
.await?;
let buyer_store = Arc::new(MemoryBuyerPoolStore::new());
let buyer_store_dyn: Arc<dyn BuyerPoolStore> = buyer_store.clone();
let buyer_service = BuyerPoolService::bootstrap(
buyer_provider.clone(),
Deployment {
chain_id: CHAIN_ID,
payment_pool,
},
buyer_addr,
buyer_store_dyn,
Arc::clone(&buyer_signer),
U256::from(DEPOSIT_MICRO_USDC), true, Arc::new(Metrics::new()),
)
.await?;
let buyer_ctx = buyer_service
.open_or_reuse_pool(node_addr, OPEN_BUDGET)
.await?;
let buyer_pool_id = buyer_ctx.pool_id;
let on_chain = buyer_pool.getPool(buyer_pool_id).call().await?;
anyhow::ensure!(
on_chain.owner == buyer_addr,
"buyer pool opened with the wrong owner"
);
anyhow::ensure!(
buyer_store.len() == 1,
"buyer service should track exactly one pool after open"
);
let buyer_binding = sign_client_binding(buyer_signer.as_ref(), client_node_id, &bind_domain)?;
let buyer_ctx = buyer_ctx.with_client_binding(buyer_binding);
{
use decdn_node::pool_view::PoolView;
let view = pool_view.clone();
let owner = poll_until(Duration::from_secs(30), || {
let view = view.clone();
async move { view.status(buyer_pool_id).await.map(|s| s.owner) }
})
.await;
anyhow::ensure!(
owner == Some(buyer_addr),
"pool projection did not observe the buyer pool's PoolOpened owner in time"
);
}
let suffix = stream_fetch(
&client_ep,
target.clone(),
&buyer_ctx,
&domains.slash,
node_addr,
*hash.as_bytes(),
MIB as u64,
0x00c0_ffe3,
Duration::from_secs(30),
)
.await?;
anyhow::ensure!(
suffix.as_ref() == &payload[MIB..],
"buyer suffix delivery mismatch"
);
let deposit_before = U256::from(buyer_pool.getPool(buyer_pool_id).call().await?.deposit);
let landed = buyer_service
.top_up_pool_by(U256::from(TOPUP_MICRO_USDC))
.await?;
anyhow::ensure!(
U256::from(buyer_pool.getPool(buyer_pool_id).call().await?.deposit)
== deposit_before + U256::from(TOPUP_MICRO_USDC),
"topUp must raise the on-chain deposit by exactly the requested amount"
);
anyhow::ensure!(
landed.added == U256::from(TOPUP_MICRO_USDC),
"top_up_pool_by must report the requested amount as added, got {}",
landed.added
);
let deposit_before = U256::from(buyer_pool.getPool(buyer_pool_id).call().await?.deposit);
let (first, second) = tokio::join!(
buyer_service.top_up_pool_by(U256::from(TOPUP_MICRO_USDC)),
buyer_service.top_up_pool_by(U256::from(TOPUP_MICRO_USDC / 2)),
);
let (first, second) = (first?, second?);
anyhow::ensure!(
first.added == U256::from(TOPUP_MICRO_USDC)
&& second.added == U256::from(TOPUP_MICRO_USDC / 2),
"each concurrent recovery top-up must report its own amount as added: \
first {}, second {}",
first.added,
second.added
);
anyhow::ensure!(
U256::from(buyer_pool.getPool(buyer_pool_id).call().await?.deposit)
== deposit_before + U256::from(TOPUP_MICRO_USDC + TOPUP_MICRO_USDC / 2),
"concurrent recovery top-ups must escrow both amounts exactly once"
);
buyer_pool
.closePool(buyer_pool_id)
.send()
.await?
.get_receipt()
.await?;
let balance_before = usdc_buyer.balanceOf(buyer_addr).call().await?;
let _: serde_json::Value = node_provider
.raw_request("evm_increaseTime".into(), (GRACE_WINDOW_WARP_SECS,))
.await?;
let _: serde_json::Value = node_provider.raw_request("evm_mine".into(), ()).await?;
buyer_service.sweep_reclaimable_once().await;
anyhow::ensure!(
buyer_store.get_by_owner(buyer_addr)?.is_none(),
"reclaimed buyer pool record must be dropped"
);
let balance_after = usdc_buyer.balanceOf(buyer_addr).call().await?;
anyhow::ensure!(
balance_after > balance_before,
"reclaim must refund the residual deposit; got {balance_before} → {balance_after}"
);
let reclaimed = buyer_pool.getPool(buyer_pool_id).call().await?;
anyhow::ensure!(
matches!(reclaimed.status, PaymentPool::Status::Closed),
"reclaimed pool must be Closed on-chain"
);
settlement_poller.shutdown();
service.shutdown(Duration::from_secs(30)).await;
let reconcile_metrics = metrics.encode()?;
let reconcile_ok = reconcile_metrics
.lines()
.find_map(|l| l.strip_prefix("decdn_redemption_reconcile_ok_total "))
.and_then(|v| v.parse::<u64>().ok())
.ok_or_else(|| anyhow::anyhow!("decdn_redemption_reconcile_ok_total not exported"))?;
anyhow::ensure!(
reconcile_failures.load(Ordering::Relaxed) == 0,
"pre-redeem watermark reconciliation failed open {} time(s); the seller submitted \
redeemMany batches it never reconciled (#2076)",
reconcile_failures.load(Ordering::Relaxed)
);
anyhow::ensure!(
reconcile_ok > 0,
"no pre-redeem watermark batch ever landed; the reconciliation is not running at all, \
which the zero-failure assertion above cannot distinguish from healthy (#2076)"
);
client_ep.close().await;
server_ep.close().await;
let _ = server_task.await;
Ok(())
}
async fn redeem_tx_for_lane<P>(
provider: &P,
payment_pool: Address,
pool_id: B256,
provider_addr: Address,
from_block: u64,
) -> anyhow::Result<(B256, u64)>
where
P: Provider + Clone,
{
poll_until(Duration::from_secs(30), || {
let provider = provider.clone();
async move {
let filter = Filter::new()
.address(payment_pool)
.event_signature(PaymentPool::PoolRedeemed::SIGNATURE_HASH)
.from_block(from_block);
let logs = provider.get_logs(&filter).await.ok()?;
let hit = logs
.iter()
.filter_map(|l| l.log_decode::<PaymentPool::PoolRedeemed>().ok())
.find(|d| {
d.inner.data.poolId == pool_id && d.inner.data.provider == provider_addr
})?;
Some((hit.transaction_hash?, hit.block_number?))
}
})
.await
.ok_or_else(|| {
anyhow::anyhow!("no PoolRedeemed event for the lane found from block {from_block}")
})
}
fn cap_count_for_tx(
captured: &Mutex<HashMap<B256, usize>>,
tx_hash: B256,
) -> anyhow::Result<usize> {
captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&tx_hash)
.copied()
.ok_or_else(|| anyhow::anyhow!("no captured cap_count log line for redeem tx {tx_hash}"))
}
async fn current_epoch<P: Provider>(provider: &P) -> anyhow::Result<u64> {
let block: serde_json::Value = provider
.raw_request("eth_getBlockByNumber".into(), ("latest", false))
.await?;
let ts_hex = block["timestamp"].as_str().expect("block timestamp");
let ts = u64::from_str_radix(ts_hex.trim_start_matches("0x"), 16)?;
Ok(ts / EPOCH_LENGTH_SECS)
}
async fn deploy_mock_usdc<P: Provider>(provider: &P, contracts: &Path) -> anyhow::Result<Address> {
let artifact = contracts.join("out/MintableUSDC.sol/MintableUSDC.json");
let bytes = std::fs::read(&artifact).map_err(|e| {
anyhow::anyhow!("read MintableUSDC artifact at {}: {e}", artifact.display())
})?;
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
let code_hex = json["bytecode"]["object"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("MintableUSDC artifact missing bytecode.object"))?;
let code: Bytes = code_hex.parse()?;
let receipt = provider
.send_transaction(alloy::rpc::types::TransactionRequest::default().with_deploy_code(code))
.await?
.get_receipt()
.await?;
receipt
.contract_address
.ok_or_else(|| anyhow::anyhow!("MintableUSDC deploy produced no contract address"))
}
async fn run_deploy_script(
contracts: &Path,
rpc_url: &str,
usdc: Address,
initial_token_holder: Address,
) -> anyhow::Result<()> {
for attempt in 1..=DEPLOY_ATTEMPTS {
let mut cmd = tokio::process::Command::new("forge");
cmd.current_dir(contracts)
.args([
"script",
"script/DeployProtocol.s.sol:DeployProtocol",
"--rpc-url",
rpc_url,
"--broadcast",
"--private-key",
DEPLOYER_KEY,
"--sender",
DEPLOYER_ADDR,
])
.env("USDC_ADDRESS", usdc.to_string())
.env("INITIAL_TOKEN_HOLDER", initial_token_holder.to_string())
.env("EMERGENCY_MULTISIG", DEPLOYER_ADDR)
.env("INITIAL_VETTER", DEPLOYER_ADDR)
.env(
"CURRENT_TERMS_HASH",
"0x0000000000000000000000000000000000000000000000000000000000000001",
)
.env("FORCE_OVERWRITE_MANIFEST", "true");
match forge_output(cmd, DEPLOY_TIMEOUT, "forge script DeployProtocol").await? {
Ok(out) if out.status.success() => return Ok(()),
Ok(out) if forge_script_body_completed(&out.stdout) => {
if attempt == DEPLOY_ATTEMPTS {
anyhow::bail!(
"forge script DeployProtocol failed after {DEPLOY_ATTEMPTS} attempts; the final attempt completed the script body but exited non-zero during broadcast:\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
tracing::warn!(
"forge script DeployProtocol attempt {attempt}/{DEPLOY_ATTEMPTS} completed the script body but exited non-zero during broadcast (transient nonce/RPC hiccup), retrying immediately"
);
}
Ok(out) => {
anyhow::bail!(
"forge script DeployProtocol exited non-zero before the script body completed (revert or script bug):\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
Err(timeout) => {
if attempt == DEPLOY_ATTEMPTS {
anyhow::bail!(
"forge script DeployProtocol failed after {DEPLOY_ATTEMPTS} attempts; the final attempt stalled (timed out after {timeout:?})"
);
}
tracing::warn!(
"forge script DeployProtocol attempt {attempt}/{DEPLOY_ATTEMPTS} stalled (killed after {timeout:?}), retrying immediately"
);
}
}
}
anyhow::bail!("DEPLOY_ATTEMPTS must be >= 1 (was {DEPLOY_ATTEMPTS})")
}
fn read_manifest(path: &Path) -> anyhow::Result<(Address, Address, Address, Address, Address)> {
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
let c = &json["contracts"];
let get = |k: &str| -> anyhow::Result<Address> {
Ok(c[k]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing {k}"))?
.parse()?)
};
Ok((
get("CapacityBond")?,
get("PaymentPool")?,
get("FeeRouter")?,
get("Token")?,
get("SlashJudge")?,
))
}