use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use alloy::primitives::{Address, B256, Bytes, Signature, TxHash, U256};
use alloy::providers::Provider;
use alloy::rpc::types::Log;
use alloy::sol_types::SolEvent;
use anyhow::{Context, Result};
use decdn_common::config::{REDEEM_LANDING_SLACK_SECS, capability_expiry_margin_secs};
use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
use decdn_incentive::payment_pool::{PaymentPool, to_pool_u64};
use decdn_incentive::sig_canon::is_high_s;
use decdn_incentive::{
CheckpointKey, KeyedCheckpointStore, LaneKey, LaneState, PoolId, PoolStateStore, StoreError,
};
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{Instrument as _, debug, error, info, warn};
use crate::chain_events::boot_retry::BootRetry;
use crate::chain_events::multiplexed_poller::{Route, SinkSource};
use crate::chain_events::resumable_watcher::{Checkpoint, ColdStart, CursorStart, LogSink};
use crate::chain_events::{REORG_MARGIN_BLOCKS, timed};
use crate::handlers::client::ClientHandler;
use crate::metrics::{Metrics, metric_hook};
use crate::onchain_tx::{TxOutcome, send_and_await_receipt};
use crate::pool_view::{Lifecycle, PoolProjection, PoolStatus};
use decdn_incentive::payment_pool::SignerAuthorization;
pub const REDEEM_HINT_CAPACITY: usize = 256;
const RESOLVE_VERDICT_TTL: Duration = Duration::from_mins(1);
const RESOLVE_FAULT_TTL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NegativeReason {
Verdict,
Fault,
}
impl NegativeReason {
const fn ttl(self) -> Duration {
match self {
Self::Verdict => RESOLVE_VERDICT_TTL,
Self::Fault => RESOLVE_FAULT_TTL,
}
}
}
const RESOLVE_NEGATIVE_CACHE_MAX: usize = 4096;
const UNREGISTERED_AUTH_TTL: Duration = Duration::from_mins(1);
const AUTH_CACHE_MAX: usize = 4096;
#[derive(Clone, Copy, Debug)]
struct AuthRead {
auth: SignerAuthorization,
folded_at_read: u64,
at: Instant,
}
#[derive(Debug, PartialEq, Eq)]
enum CachedAuth {
Fresh(SignerAuthorization),
Lapsed,
Absent,
}
fn cached_auth(read: Option<&AuthRead>, folded_now: u64) -> CachedAuth {
let Some(read) = read else {
return CachedAuth::Absent;
};
let folded_since = folded_now.saturating_sub(read.folded_at_read);
match read.auth {
SignerAuthorization::Registered { cap, expiry, spent } => {
CachedAuth::Fresh(SignerAuthorization::Registered {
cap,
expiry,
spent: spent.saturating_add(folded_since),
})
}
SignerAuthorization::Unregistered
if folded_since == 0 && read.at.elapsed() < UNREGISTERED_AUTH_TTL =>
{
CachedAuth::Fresh(SignerAuthorization::Unregistered)
}
SignerAuthorization::Unregistered => CachedAuth::Lapsed,
}
}
fn remember_auth(
cache: &mut HashMap<(B256, Address), AuthRead>,
key: (B256, Address),
read: AuthRead,
) {
if cache.len() >= AUTH_CACHE_MAX && !cache.contains_key(&key) {
cache.retain(|_, r| {
matches!(r.auth, SignerAuthorization::Registered { .. })
|| r.at.elapsed() < UNREGISTERED_AUTH_TTL
});
if cache.len() >= AUTH_CACHE_MAX
&& let Some(oldest) = cache.iter().min_by_key(|(_, r)| r.at).map(|(k, _)| *k)
{
cache.remove(&oldest);
}
}
cache.insert(key, read);
}
const REDEEM_RECEIPT_TIMEOUT: Duration = Duration::from_mins(3);
const MAX_SPLIT_DEPTH: u32 = 6;
const WATERMARK_READ_BATCH_MAX: usize = 512;
pub(crate) fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
const CHECKPOINT_FLUSH_BLOCKS: u64 = 512;
const CHECKPOINT_FLUSH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Clone, Default)]
pub struct PaidWatermarks {
inner: Arc<Mutex<HashMap<LaneKey, U256>>>,
}
impl std::fmt::Debug for PaidWatermarks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let len = self
.inner
.lock()
.map_or_else(|p| p.into_inner().len(), |m| m.len());
f.debug_struct("PaidWatermarks")
.field("lanes", &len)
.finish()
}
}
impl PaidWatermarks {
fn set(&self, key: LaneKey, paid: U256) {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, paid);
}
fn get(&self, key: &LaneKey) -> U256 {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(key)
.copied()
.unwrap_or(U256::ZERO)
}
fn forget(&self, key: &LaneKey) {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(key);
}
}
pub struct PoolSettlementService<P: Provider + Clone + 'static> {
contract: PaymentPool::PaymentPoolInstance<P>,
redeem_tx: mpsc::Sender<LaneKey>,
store: Arc<dyn PoolStateStore>,
paid: PaidWatermarks,
self_address: Address,
redeem_threshold: U256,
redeem_max_vouchers_per_tx: usize,
metrics: Arc<Metrics>,
pool_view: PoolProjection,
redeemer: std::sync::Mutex<Option<JoinHandle<()>>>,
}
async fn usdc_self_check<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
boot: &BootRetry,
) -> Result<Address> {
let payment_pool_addr = *contract.address();
boot.run("PaymentPool.usdc() self-check", || async {
timed(None, "PaymentPool.usdc()", contract.usdc().call())
.await
.with_context(|| format!("PaymentPool.usdc() self-check at {payment_pool_addr}"))
})
.await
}
impl<P: Provider + Clone + 'static> PoolSettlementService<P> {
#[allow(clippy::too_many_arguments)]
pub async fn bootstrap(
provider: P,
payment_pool_addr: Address,
self_address: Address,
store: Arc<dyn PoolStateStore>,
checkpoint_store: Arc<dyn KeyedCheckpointStore>,
handler: Arc<ClientHandler>,
redeem_threshold: U256,
redeem_max_vouchers_per_tx: usize,
redeem_interval: Duration,
metrics: Arc<Metrics>,
boot: &BootRetry,
pool_view: PoolProjection,
redeem_tx: mpsc::Sender<LaneKey>,
redeem_rx: mpsc::Receiver<LaneKey>,
) -> Result<(Self, Route)> {
let contract = PaymentPool::new(payment_pool_addr, provider);
let usdc_token = usdc_self_check(&contract, boot).await?;
info!(
%payment_pool_addr,
%usdc_token,
%self_address,
"PaymentPool settlement service bootstrap complete"
);
let paid = rehydrate_paid_watermarks(store.as_ref(), self_address);
let sink = PoolSettlementSink {
self_address,
store: Arc::clone(&store),
handler,
paid: paid.clone(),
redeem_tx: redeem_tx.clone(),
metrics: Arc::clone(&metrics),
pool_view: pool_view.clone(),
};
let route = Route {
addresses: vec![payment_pool_addr],
topic0s: settlement_route_topic0s(),
start: cursor_start(Arc::clone(&checkpoint_store)),
sink: SinkSource::Ready(Box::new(sink)),
label: "settlement",
on_established: Some(metric_hook(
&metrics,
Metrics::settlement_watcher_cycle_established,
)),
on_backoff: Some(metric_hook(
&metrics,
Metrics::settlement_watcher_backoff_started,
)),
on_tick_success: Some(metric_hook(&metrics, Metrics::settlement_watcher_tick)),
on_task_panic: Some(metric_hook(
&metrics,
Metrics::settlement_watcher_task_panicked,
)),
};
let redeemer = tokio::spawn(redeemer_loop(
contract.clone(),
Arc::clone(&store),
paid.clone(),
self_address,
redeem_threshold,
redeem_max_vouchers_per_tx,
redeem_interval,
capability_expiry_margin_secs(redeem_interval.as_secs()),
redeem_rx,
Arc::clone(&metrics),
pool_view.clone(),
));
Ok((
Self {
contract,
redeem_tx,
store,
paid,
self_address,
redeem_threshold,
redeem_max_vouchers_per_tx,
metrics,
pool_view,
redeemer: std::sync::Mutex::new(Some(redeemer)),
},
route,
))
}
#[must_use]
pub fn redeem_hint_sender(&self) -> mpsc::Sender<LaneKey> {
self.redeem_tx.clone()
}
pub async fn shutdown(&self, deadline: Duration) {
self.quiesce_redeemer().await;
if tokio::time::timeout(deadline, self.final_redeem_sweep())
.await
.is_err()
{
warn!(
deadline_secs = deadline.as_secs(),
"shutdown redeem deadline elapsed; some lanes left un-redeemed (redeem later)"
);
}
}
async fn final_redeem_sweep(&self) {
let Some(states) = load_lanes(self.store.as_ref()) else {
return;
};
let _chain_fault = redeem_sweep(
&self.contract,
&self.store,
states,
&self.paid,
self.self_address,
self.redeem_threshold,
self.redeem_max_vouchers_per_tx,
false,
&self.metrics,
&self.pool_view,
)
.await;
}
async fn quiesce_redeemer(&self) {
let handle = self
.redeemer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if let Some(handle) = handle {
handle.abort();
let _ = handle.await;
}
}
}
impl<P: Provider + Clone + 'static> std::fmt::Debug for PoolSettlementService<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PoolSettlementService")
.field("address", self.contract.address())
.finish_non_exhaustive()
}
}
impl<P: Provider + Clone + 'static> Drop for PoolSettlementService<P> {
fn drop(&mut self) {
if let Some(handle) = self
.redeemer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
handle.abort();
}
}
}
fn compact_voucher_signature(sig: &[u8]) -> Result<(B256, B256)> {
let raw: [u8; 65] = sig
.try_into()
.map_err(|_| anyhow::anyhow!("voucher signature is not 65 bytes (r‖s‖v)"))?;
let parsed = Signature::from_raw(&raw).context("voucher signature is malformed")?;
if is_high_s(&parsed) {
anyhow::bail!("voucher signature is non-canonical (high `s`) and would revert on-chain");
}
let r = B256::from(parsed.r().to_be_bytes::<32>());
let mut vs = parsed.s().to_be_bytes::<32>();
let Some(top) = vs.first_mut() else {
anyhow::bail!("voucher signature `s` is empty");
};
*top |= u8::from(parsed.v()) << 7;
Ok((r, B256::from(vs)))
}
fn cursor_start(store: Arc<dyn KeyedCheckpointStore>) -> CursorStart {
CursorStart::FromCheckpoint {
checkpoint: Checkpoint {
store,
key: CheckpointKey::PoolOpened,
},
reorg_margin: REORG_MARGIN_BLOCKS,
cold_start: ColdStart::Head,
}
}
fn settlement_route_topic0s() -> Vec<B256> {
vec![
PaymentPool::PoolOpened::SIGNATURE_HASH,
PaymentPool::PoolRedeemed::SIGNATURE_HASH,
PaymentPool::PoolToppedUp::SIGNATURE_HASH,
PaymentPool::PoolCloseInitiated::SIGNATURE_HASH,
PaymentPool::PoolReclaimed::SIGNATURE_HASH,
]
}
struct PoolSettlementSink {
self_address: Address,
store: Arc<dyn PoolStateStore>,
handler: Arc<ClientHandler>,
paid: PaidWatermarks,
redeem_tx: mpsc::Sender<LaneKey>,
metrics: Arc<Metrics>,
pool_view: PoolProjection,
}
impl LogSink for PoolSettlementSink {
#[allow(clippy::cognitive_complexity)]
async fn apply(&mut self, log: Log) -> Result<()> {
match log.topic0().copied() {
Some(sig) if sig == PaymentPool::PoolOpened::SIGNATURE_HASH => {
let event = match PaymentPool::PoolOpened::decode_log_data(&log.inner.data) {
Ok(event) => event,
Err(err) => {
warn!(error = %err, "skipping undecodable PoolOpened log");
return Ok(());
}
};
self.pool_view
.record_opened(event.poolId, event.owner, event.deposit);
debug!(pool_id = %event.poolId, owner = %event.owner, "projected PoolOpened");
}
Some(sig) if sig == PaymentPool::PoolRedeemed::SIGNATURE_HASH => {
let event = match PaymentPool::PoolRedeemed::decode_log_data(&log.inner.data) {
Ok(event) => event,
Err(err) => {
warn!(error = %err, "skipping undecodable PoolRedeemed log");
return Ok(());
}
};
self.pool_view
.record_redeemed(event.poolId, event.provider, &event.lanes);
if event.provider != self.self_address {
return Ok(());
}
for lane in &event.lanes {
let key = LaneKey {
pool_id: event.poolId,
signer: lane.signer,
provider: event.provider,
};
let paid_cumulative = U256::from(lane.newPaidCumulative);
self.paid.set(key, paid_cumulative);
if let Err(err) = self.store.set_paid_cumulative(key, paid_cumulative) {
self.metrics.watcher_persist_failure();
warn!(
error = %err,
pool_id = %event.poolId,
signer = %lane.signer,
"failed to persist lane paid watermark; in-memory cache updated, \
durable value lags until the next redemption"
);
}
debug!(
pool_id = %event.poolId,
signer = %lane.signer,
paid_cumulative = lane.newPaidCumulative,
"recorded lane paid watermark from PoolRedeemed"
);
}
}
Some(sig) if sig == PaymentPool::PoolToppedUp::SIGNATURE_HASH => {
let event = match PaymentPool::PoolToppedUp::decode_log_data(&log.inner.data) {
Ok(event) => event,
Err(err) => {
warn!(error = %err, "skipping undecodable PoolToppedUp log");
return Ok(());
}
};
self.pool_view.record_topup(event.poolId, event.newDeposit);
self.redrive_pool_lanes(event.poolId);
}
Some(sig) if sig == PaymentPool::PoolCloseInitiated::SIGNATURE_HASH => {
let event = match PaymentPool::PoolCloseInitiated::decode_log_data(&log.inner.data)
{
Ok(event) => event,
Err(err) => {
warn!(error = %err, "skipping undecodable PoolCloseInitiated log");
return Ok(());
}
};
self.pool_view.record_closing(
event.poolId,
u64::try_from(event.disputeDeadline).unwrap_or(u64::MAX),
);
if self.holds_unredeemed_on(event.poolId) {
self.metrics.pool_grace_close();
}
}
Some(sig) if sig == PaymentPool::PoolReclaimed::SIGNATURE_HASH => {
let event = match PaymentPool::PoolReclaimed::decode_log_data(&log.inner.data) {
Ok(event) => event,
Err(err) => {
warn!(error = %err, "skipping undecodable PoolReclaimed log");
return Ok(());
}
};
self.pool_view.forget(event.poolId);
self.forget_pool_lanes(event.poolId).await;
}
_ => {
debug!(topic0 = ?log.topic0(), "unmatched PaymentPool event in subscribed OR-set");
}
}
Ok(())
}
}
impl PoolSettlementSink {
fn holds_unredeemed_on(&self, pool_id: PoolId) -> bool {
match self.store.load_all() {
Ok(states) => holds_unredeemed(&states, pool_id, self.self_address),
Err(err) => {
warn!(error = %err, %pool_id, "grace-close check: failed to load lane state");
false
}
}
}
fn redrive_pool_lanes(&self, pool_id: PoolId) {
let states = match self.store.load_all() {
Ok(s) => s,
Err(err) => {
warn!(error = %err, %pool_id, "top-up re-drive: failed to load lane state");
return;
}
};
for st in states {
if st.pool_id == pool_id && st.provider == self.self_address {
let _ = self.redeem_tx.try_send(st.key());
}
}
}
#[allow(clippy::cognitive_complexity)]
async fn forget_pool_lanes(&self, pool_id: PoolId) {
let states = match self.store.load_all() {
Ok(s) => s,
Err(err) => {
warn!(error = %err, %pool_id, "reclaim cleanup: failed to load lane state");
return;
}
};
for st in states {
if st.pool_id != pool_id || st.provider != self.self_address {
continue;
}
let key = st.key();
if let Err(err) = self.handler.forget_lane(key).await {
self.metrics.watcher_persist_failure();
warn!(error = %err, pool_id = %pool_id, signer = %key.signer, "failed to forget reclaimed lane");
} else {
self.paid.forget(&key);
info!(pool_id = %pool_id, signer = %key.signer, "pool reclaimed; dropped tracked lane");
}
}
self.handler.forget_pool_floor(pool_id);
}
}
pub struct ResolvingPoolView<P: Provider + Clone> {
contract: PaymentPool::PaymentPoolInstance<P>,
projection: PoolProjection,
negative: Mutex<HashMap<B256, (NegativeReason, Instant)>>,
inflight: InflightReads<B256, Option<PoolStatus>>,
auth_cache: Mutex<HashMap<(B256, Address), AuthRead>>,
auth_inflight: InflightReads<(B256, Address), Option<SignerAuthorization>>,
metrics: Arc<Metrics>,
}
impl<P: Provider + Clone> std::fmt::Debug for ResolvingPoolView<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvingPoolView")
.field("address", self.contract.address())
.field("projection", &self.projection)
.finish_non_exhaustive()
}
}
impl<P: Provider + Clone> ResolvingPoolView<P> {
#[must_use]
pub fn new(
contract: PaymentPool::PaymentPoolInstance<P>,
projection: PoolProjection,
metrics: Arc<Metrics>,
) -> Self {
Self {
contract,
projection,
negative: Mutex::new(HashMap::new()),
inflight: Mutex::new(HashMap::new()),
auth_cache: Mutex::new(HashMap::new()),
auth_inflight: Mutex::new(HashMap::new()),
metrics,
}
}
fn lookup_auth(&self, pool_id: B256, signer: Address) -> CachedAuth {
let folded_now = self.projection.signer_spent(pool_id, signer);
let guard = self
.auth_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cached_auth(guard.get(&(pool_id, signer)), folded_now)
}
}
impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
async fn resolve(&self, pool_id: B256) -> Option<PoolStatus> {
let pool = match timed(None, "admit getPool", self.contract.getPool(pool_id).call()).await {
Ok(pool) => pool,
Err(err) => {
warn!(
error = %sanitize_err_chain(&err),
%pool_id,
suppressed_for = ?RESOLVE_FAULT_TTL,
"admit getPool failed; refusing this pool until the negative cache lapses"
);
let mut guard = self
.negative
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
remember_negative(&mut guard, pool_id, NegativeReason::Fault);
return None;
}
};
let Some(lifecycle) = resolved_lifecycle(&pool) else {
debug!(%pool_id, "admit getPool: pool absent or closed; refusing");
let mut guard = self
.negative
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
remember_negative(&mut guard, pool_id, NegativeReason::Verdict);
return None;
};
self.projection
.record_resolved(pool_id, pool.owner, U256::from(pool.deposit), lifecycle);
{
let mut guard = self
.negative
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.remove(&pool_id);
}
self.projection.snapshot(pool_id)
}
}
#[async_trait::async_trait]
impl<P: Provider + Clone + 'static> crate::pool_view::PoolView for ResolvingPoolView<P> {
async fn status(&self, pool_id: B256) -> Option<PoolStatus> {
loop {
if let Some(status) = self.projection.snapshot(pool_id) {
return Some(status);
}
{
let guard = self
.negative
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(reason) = negative_cache_hit(&guard, pool_id) {
debug!(
%pool_id,
?reason,
"admit getPool suppressed by the negative cache; refusing"
);
return None;
}
}
match claim_read(&self.inflight, pool_id) {
ReadSlot::Lead(read) => {
let status = self.resolve(pool_id).await;
read.finish(status);
return status;
}
ReadSlot::Wait(mut done) => {
if done.changed().await.is_ok() {
return *done.borrow();
}
}
}
}
}
async fn cached_status(&self, pool_id: B256) -> Option<PoolStatus> {
self.projection.snapshot(pool_id)
}
async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
Some(self.projection.signer_spent(pool_id, signer))
}
async fn signer_authorization(
&self,
pool_id: B256,
signer: Address,
) -> Option<SignerAuthorization> {
match self.lookup_auth(pool_id, signer) {
CachedAuth::Fresh(auth) => {
self.metrics.serve_signer_auth_cached();
return Some(auth);
}
CachedAuth::Absent => self.metrics.serve_signer_auth_first_read(),
CachedAuth::Lapsed => self.metrics.serve_signer_auth_reread(),
}
loop {
match claim_read(&self.auth_inflight, (pool_id, signer)) {
ReadSlot::Lead(read) => {
let auth = self.read_authorization(pool_id, signer).await;
read.finish(auth);
return auth;
}
ReadSlot::Wait(mut done) => {
if done.changed().await.is_ok() {
return *done.borrow();
}
if let CachedAuth::Fresh(auth) = self.lookup_auth(pool_id, signer) {
return Some(auth);
}
}
}
}
}
}
impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
async fn read_authorization(
&self,
pool_id: B256,
signer: Address,
) -> Option<SignerAuthorization> {
let folded_at_read = self.projection.signer_spent(pool_id, signer);
let auth = match timed(
None,
"admit getAuthorization",
self.contract.getAuthorization(pool_id, signer).call(),
)
.await
{
Ok(auth) => SignerAuthorization::from_onchain(&auth),
Err(err) => {
warn!(
error = %sanitize_err_chain(&err),
%pool_id,
%signer,
"admit getAuthorization failed; refusing this signer"
);
return None;
}
};
let read = AuthRead {
auth,
folded_at_read,
at: Instant::now(),
};
{
let mut guard = self
.auth_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
remember_auth(&mut guard, (pool_id, signer), read);
}
match cached_auth(Some(&read), self.projection.signer_spent(pool_id, signer)) {
CachedAuth::Fresh(fresh) => Some(fresh),
CachedAuth::Lapsed | CachedAuth::Absent => Some(auth),
}
}
}
fn negative_cache_hit(
cache: &HashMap<B256, (NegativeReason, Instant)>,
pool_id: B256,
) -> Option<NegativeReason> {
cache
.get(&pool_id)
.filter(|(reason, at)| at.elapsed() < reason.ttl())
.map(|(reason, _)| *reason)
}
fn remember_negative(
cache: &mut HashMap<B256, (NegativeReason, Instant)>,
pool_id: B256,
reason: NegativeReason,
) {
if cache.len() >= RESOLVE_NEGATIVE_CACHE_MAX {
cache.retain(|_, (reason, at)| at.elapsed() < reason.ttl());
}
if cache.len() < RESOLVE_NEGATIVE_CACHE_MAX || cache.contains_key(&pool_id) {
cache.insert(pool_id, (reason, Instant::now()));
}
}
type InflightReads<K, V> = Mutex<HashMap<K, watch::Receiver<V>>>;
enum ReadSlot<'a, K: Eq + Hash, V> {
Lead(InflightRead<'a, K, V>),
Wait(watch::Receiver<V>),
}
struct InflightRead<'a, K: Eq + Hash, V> {
inflight: &'a InflightReads<K, V>,
key: K,
done: watch::Sender<V>,
}
impl<K: Eq + Hash, V> InflightRead<'_, K, V> {
fn finish(self, answer: V) {
self.done.send_replace(answer);
}
}
impl<K: Eq + Hash, V> Drop for InflightRead<'_, K, V> {
fn drop(&mut self) {
self.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.key);
}
}
fn claim_read<K: Eq + Hash + Copy, V: Default>(
inflight: &InflightReads<K, V>,
key: K,
) -> ReadSlot<'_, K, V> {
let mut guard = inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(done) = guard.get(&key)
&& done.has_changed().is_ok()
{
return ReadSlot::Wait(done.clone());
}
let (done, waiters) = watch::channel(V::default());
guard.insert(key, waiters);
ReadSlot::Lead(InflightRead {
inflight,
key,
done,
})
}
fn resolved_lifecycle(pool: &PaymentPool::Pool) -> Option<Lifecycle> {
if pool.owner == Address::ZERO {
return None;
}
match pool.status {
PaymentPool::Status::Open => Some(Lifecycle::Open),
PaymentPool::Status::Closing => Some(Lifecycle::Closing {
deadline: pool.disputeDeadline,
}),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
async fn redeemer_loop<P: Provider + Clone>(
contract: PaymentPool::PaymentPoolInstance<P>,
store: Arc<dyn PoolStateStore>,
paid: PaidWatermarks,
self_address: Address,
redeem_threshold: U256,
max_vouchers: usize,
redeem_interval: Duration,
margin_secs: u64,
mut redeem_rx: mpsc::Receiver<LaneKey>,
metrics: Arc<Metrics>,
pool_view: PoolProjection,
) {
let mut ticker = tokio::time::interval(redeem_interval);
ticker.tick().await;
let mut swept_at = 0;
let mut parked: HashSet<LaneKey> = HashSet::new();
let mut next_cutoff = load_lanes(store.as_ref()).and_then(|states| {
next_serve_cutoff(
&states,
&paid,
self_address,
margin_secs,
swept_at,
unix_now(),
)
});
loop {
tokio::select! {
hint = redeem_rx.recv() => match hint {
Some(key) => {
if parked.contains(&key) {
metrics.redeem_hint_parked();
continue;
}
let outcome = redeem_one(
&contract, &store, &paid, self_address, margin_secs, swept_at,
redeem_threshold, max_vouchers, key, &metrics, &pool_view,
)
.await;
if outcome.chain_fault {
park_lane(&mut parked, key);
}
next_cutoff = next_cutoff.into_iter().chain(outcome.wake).min();
continue;
}
None => break,
},
_ = ticker.tick() => {}
() = sleep_until_unix(next_cutoff) => {}
}
let Some(states) = load_lanes(store.as_ref()) else {
next_cutoff = next_cutoff.filter(|wake| *wake > unix_now());
continue;
};
swept_at = unix_now();
next_cutoff = next_serve_cutoff(
&states,
&paid,
self_address,
margin_secs,
swept_at,
swept_at,
);
let chain_fault = redeem_sweep(
&contract,
&store,
states,
&paid,
self_address,
redeem_threshold,
max_vouchers,
true,
&metrics,
&pool_view,
)
.await;
if !chain_fault {
parked.clear();
}
}
debug!("PaymentPool redeemer loop ended (all hint senders dropped)");
}
struct PlannedLane {
pool_id: PoolId,
key: LaneKey,
owed: U256,
unredeemed: U256,
voucher: PaymentPool::LaneVoucher,
register: Option<PaymentPool::CapabilityReg>,
}
fn sum_unredeemed(plans: &[PlannedLane]) -> U256 {
plans
.iter()
.fold(U256::ZERO, |acc, plan| acc.saturating_add(plan.unredeemed))
}
fn sum_recoverable_deposit(plans: &[PlannedLane], pool_view: &PoolProjection) -> U256 {
let mut seen: HashSet<PoolId> = HashSet::new();
plans
.iter()
.filter(|plan| seen.insert(plan.pool_id))
.filter_map(|plan| pool_view.snapshot(plan.pool_id))
.fold(U256::ZERO, |acc, status| {
acc.saturating_add(status.remaining)
})
}
fn group_by_pool(lanes: &[PlannedLane]) -> Vec<PaymentPool::PoolBatch> {
let mut order: Vec<PoolId> = Vec::new();
let mut by_pool: HashMap<PoolId, PaymentPool::PoolBatch> = HashMap::new();
for lane in lanes {
let batch = by_pool.entry(lane.pool_id).or_insert_with(|| {
order.push(lane.pool_id);
PaymentPool::PoolBatch {
poolId: lane.pool_id,
capabilities: Vec::new(),
vouchers: Vec::new(),
}
});
if let Some(reg) = &lane.register {
batch.capabilities.push(reg.clone());
}
batch.vouchers.push(lane.voucher.clone());
}
order
.into_iter()
.filter_map(|pool_id| by_pool.remove(&pool_id))
.collect()
}
fn chunk_redemptions(
mut plans: Vec<PlannedLane>,
floor: U256,
max_vouchers: usize,
) -> Vec<Vec<PlannedLane>> {
if plans.is_empty() {
return Vec::new();
}
let cap = max_vouchers.max(1);
let k = plans.len().div_ceil(cap);
plans.sort_by_key(|plan| Reverse(plan.unredeemed));
let mut buckets: Vec<Vec<PlannedLane>> = (0..k).map(|_| Vec::new()).collect();
for (i, plan) in plans.into_iter().enumerate() {
if let Some(bucket) = buckets.get_mut(i % k) {
bucket.push(plan);
}
}
buckets
.into_iter()
.filter(|bucket| {
let sum = bucket
.iter()
.map(|l| l.unredeemed)
.fold(U256::ZERO, |a, b| a + b);
sum >= floor
})
.collect()
}
fn pool_is_redeemable(status: Option<PoolStatus>, now: u64) -> bool {
match status {
None => true,
Some(s) => {
if s.remaining.is_zero() {
return false;
}
match s.lifecycle {
Lifecycle::Open => true,
Lifecycle::Closing { deadline } => {
now.saturating_add(REDEEM_LANDING_SLACK_SECS) < deadline
}
}
}
}
}
fn partition_redeemable(
states: Vec<LaneState>,
snapshot: &HashMap<PoolId, Option<PoolStatus>>,
now: u64,
) -> (Vec<LaneState>, usize) {
let mut kept = Vec::with_capacity(states.len());
let mut skipped = 0usize;
for st in states {
let pool_status = snapshot.get(&st.pool_id).copied().flatten();
if pool_is_redeemable(pool_status, now) {
kept.push(st);
} else {
skipped += 1;
}
}
(kept, skipped)
}
const fn capability_expired(expiry: u64, now: u64) -> bool {
expiry != 0 && now.saturating_add(REDEEM_LANDING_SLACK_SECS) >= expiry
}
fn lane_cutoff_wake(
st: &LaneState,
paid: &PaidWatermarks,
self_address: Address,
margin_secs: u64,
) -> Option<u64> {
if st.provider != self_address || st.expiry == 0 || st.owed() <= paid.get(&st.key()) {
return None;
}
Some(st.expiry.saturating_sub(margin_secs).saturating_add(1))
}
fn serve_cutoff_wake(
st: &LaneState,
paid: &PaidWatermarks,
self_address: Address,
margin_secs: u64,
swept_at: u64,
now: u64,
) -> Option<u64> {
let wake = lane_cutoff_wake(st, paid, self_address, margin_secs)?;
(wake > swept_at && !capability_expired(st.expiry, now)).then_some(wake.max(now))
}
fn next_serve_cutoff(
states: &[LaneState],
paid: &PaidWatermarks,
self_address: Address,
margin_secs: u64,
swept_at: u64,
now: u64,
) -> Option<u64> {
states
.iter()
.filter_map(|st| serve_cutoff_wake(st, paid, self_address, margin_secs, swept_at, now))
.min()
}
async fn sleep_until_unix(wake: Option<u64>) {
match wake {
Some(wake) => {
let wait = Duration::from_secs(wake.saturating_sub(unix_now()));
tokio::time::sleep(wait).await;
}
None => std::future::pending().await,
}
}
const fn is_registered(registered_until: u64, now: u64) -> bool {
registered_until > now
}
enum RegistrationStatus {
Registered,
Unregistered,
}
fn rehydrate_paid_watermarks(store: &dyn PoolStateStore, self_address: Address) -> PaidWatermarks {
let paid = PaidWatermarks::default();
match store.load_all() {
Ok(states) => {
let mut restored = 0usize;
for st in &states {
if st.provider == self_address && !st.paid_cumulative.is_zero() {
paid.set(st.key(), st.paid_cumulative);
restored += 1;
}
}
if restored > 0 {
info!(
restored,
"rehydrated paid-watermark cache from durable lane store"
);
}
}
Err(err) => warn!(
error = %err,
"failed to rehydrate paid-watermark cache from lane store; pre-cursor \
redemptions may be re-submitted until re-observed on-chain"
),
}
paid
}
fn plan_lane(
st: &LaneState,
paid: &PaidWatermarks,
self_address: Address,
status: &RegistrationStatus,
) -> Result<Option<PlannedLane>> {
if st.provider != self_address {
return Ok(None);
}
let key = st.key();
let Some(claim) = st.live_claim() else {
return Ok(None);
};
let owed = claim.value();
if owed.is_zero() {
return Ok(None);
}
let unredeemed = owed.saturating_sub(paid.get(&key));
if unredeemed.is_zero() {
return Ok(None);
}
let register = match status {
RegistrationStatus::Registered => None,
RegistrationStatus::Unregistered => {
let Some(owner_sig) = st.owner_sig else {
error!(
pool_id = %key.pool_id,
signer = %key.signer,
"BUG: redeemable lane has no owner_sig, so its signer cannot be \
registered; skipping (node durability fault, not a client re-send)"
);
return Ok(None);
};
Some(PaymentPool::CapabilityReg {
signer: key.signer,
spendingCap: to_pool_u64(st.cap, "capability spending cap")?,
expiry: st.expiry,
ownerSig: Bytes::from(owner_sig.to_vec()),
})
}
};
let (r, vs) = compact_voucher_signature(&claim.signature)
.context("compact the lane's voucher signature")?;
let voucher = PaymentPool::LaneVoucher {
signer: key.signer,
cumulative: to_pool_u64(claim.amount, "voucher cumulative")?,
bytesDelivered: to_pool_u64(claim.bytes_delivered, "voucher bytes delivered")?,
r,
vs,
chainRoot: claim.chain.chain_root,
preimage: claim.chain.tip,
chainMeter: claim
.chain_meter()
.context("pack the lane's chainMeter word")?,
};
Ok(Some(PlannedLane {
pool_id: key.pool_id,
key,
owed,
unredeemed,
voucher,
register,
}))
}
#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
fn plan_lanes(
paid: &PaidWatermarks,
self_address: Address,
states: Vec<LaneState>,
metrics: &Arc<Metrics>,
pool_view: &PoolProjection,
now: u64,
) -> Vec<PlannedLane> {
let mut snapshot: HashMap<PoolId, Option<PoolStatus>> = HashMap::new();
for st in &states {
snapshot
.entry(st.pool_id)
.or_insert_with(|| pool_view.snapshot(st.pool_id));
}
let (states, skipped) = partition_redeemable(states, &snapshot, now);
if skipped > 0 {
metrics.redemption_skipped_insolvent_by(skipped as u64);
}
let mut plans: Vec<PlannedLane> = Vec::new();
for st in &states {
if st.provider != self_address {
continue;
}
let reg_status = if is_registered(st.registered_until, now) {
RegistrationStatus::Registered
} else {
RegistrationStatus::Unregistered
};
match plan_lane(st, paid, self_address, ®_status) {
Ok(Some(_)) if capability_expired(st.expiry, now) => {
metrics.redemption_skipped_expired();
debug!(
pool_id = %st.pool_id,
signer = %st.signer,
expiry = st.expiry,
"redeemer: skipping a lane with value owed whose capability has expired"
);
}
Ok(Some(lane)) => plans.push(lane),
Ok(None) => {}
Err(err) => {
metrics.redemption_failure();
warn!(error = %sanitize_err_chain(&err), pool_id = %st.pool_id, "redemption planning failed");
}
}
}
plans
}
fn park_lane(parked: &mut HashSet<LaneKey>, key: LaneKey) {
debug!(
pool_id = %key.pool_id,
signer = %key.signer,
provider = %key.provider,
"lane parked after a chain fault until a sweep finishes without one"
);
parked.insert(key);
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct HintOutcome {
wake: Option<u64>,
chain_fault: bool,
}
#[allow(clippy::too_many_arguments)]
async fn redeem_one<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
store: &Arc<dyn PoolStateStore>,
paid: &PaidWatermarks,
self_address: Address,
margin_secs: u64,
swept_at: u64,
floor: U256,
max_vouchers: usize,
key: LaneKey,
metrics: &Arc<Metrics>,
pool_view: &PoolProjection,
) -> HintOutcome {
let st = match store.get(key) {
Ok(Some(st)) => st,
Ok(None) => return HintOutcome::default(),
Err(err) => {
metrics.redemption_failure();
warn!(error = %err, pool_id = %key.pool_id, "redemption planning failed to load lane");
return HintOutcome::default();
}
};
let now = unix_now();
let wake = serve_cutoff_wake(&st, paid, self_address, margin_secs, swept_at, now);
let plans = plan_lanes(paid, self_address, vec![st], metrics, pool_view, now);
let chain_fault =
redeem_planned_lanes(contract, store, plans, floor, max_vouchers, true, metrics).await;
HintOutcome { wake, chain_fault }
}
fn load_lanes(store: &dyn PoolStateStore) -> Option<Vec<LaneState>> {
store
.load_all()
.inspect_err(|err| warn!(error = %err, "redeemer sweep: failed to load lane state"))
.ok()
}
#[allow(clippy::too_many_arguments)]
async fn redeem_sweep<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
store: &Arc<dyn PoolStateStore>,
states: Vec<LaneState>,
paid: &PaidWatermarks,
self_address: Address,
floor: U256,
max_vouchers: usize,
strict_flush: bool,
metrics: &Arc<Metrics>,
pool_view: &PoolProjection,
) -> bool {
let plans = plan_lanes(paid, self_address, states, metrics, pool_view, unix_now());
metrics.set_unredeemed_usdc(sum_unredeemed(&plans));
if !plans.is_empty() {
metrics.set_pool_deposit_usdc(sum_recoverable_deposit(&plans, pool_view));
}
redeem_planned_lanes(
contract,
store,
plans,
floor,
max_vouchers,
strict_flush,
metrics,
)
.await
}
#[allow(clippy::cognitive_complexity)]
#[tracing::instrument(
name = "onchain_tx",
skip_all,
fields(
op = "redeemMany",
depth = depth,
voucher_count = lanes.len(),
tx = tracing::field::Empty,
outcome = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
)
)]
async fn submit_chunk<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
store: &Arc<dyn PoolStateStore>,
mut lanes: Vec<PlannedLane>,
metrics: &Arc<Metrics>,
depth: u32,
) -> bool {
if lanes.is_empty() {
return false;
}
let span = tracing::Span::current();
let batches = group_by_pool(&lanes);
let cap_count: usize = batches.iter().map(|b| b.capabilities.len()).sum();
let voucher_count = lanes.len();
let pool_count = batches.len();
let sent = contract.redeemMany(batches).send().await;
let outcome = send_and_await_receipt(sent, Some(REDEEM_RECEIPT_TIMEOUT), metrics).await;
let failed = is_redemption_failure(&outcome);
let chain_fault = is_chain_fault(&outcome);
match outcome {
TxOutcome::Landed(receipt) => {
span.record("tx", tracing::field::display(receipt.transaction_hash));
span.record("outcome", "landed");
info!(
pool_count,
cap_count,
voucher_count,
tx = %receipt.transaction_hash,
"batched lane redemption landed (redeemMany)"
);
record_landed_chunk(contract, store, &lanes, metrics).await;
return false;
}
TxOutcome::SendErr(err)
if lanes.len() >= 2
&& depth < MAX_SPLIT_DEPTH
&& is_oversize_send_err(&err.to_string()) =>
{
span.record("outcome", "split");
let mid = lanes.len() / 2;
let right = lanes.split_off(mid);
warn!(
voucher_count,
depth, "redeemMany send rejected oversized; halving chunk and retrying"
);
let left_faulted =
Box::pin(submit_chunk(contract, store, lanes, metrics, depth + 1)).await;
let right_faulted =
Box::pin(submit_chunk(contract, store, right, metrics, depth + 1)).await;
return left_faulted || right_faulted;
}
TxOutcome::Reverted(receipt) => {
record_tx_failure(&span, "reverted", Some(receipt.transaction_hash));
warn!(voucher_count, tx = %receipt.transaction_hash, "redeemMany reverted on-chain; leaving claims for retry");
}
TxOutcome::SendErr(err) => {
record_tx_failure(&span, "send_failed", None);
warn!(error = %sanitize_error_sources(&err), voucher_count, "redeemMany send failed; leaving claims for retry");
}
TxOutcome::ReceiptErr {
error,
tx_hash,
last_lookup,
} => {
record_tx_failure(&span, "receipt_failed", Some(tx_hash));
warn!(error = %sanitize_error_sources(&error), %last_lookup, voucher_count, tx = %tx_hash, "redeemMany receipt wait failed and the by-hash lookup missed; unconfirmed, leaving claims for retry");
}
TxOutcome::Timeout {
tx_hash,
last_lookup,
} => {
record_tx_failure(&span, "timeout", Some(tx_hash));
warn!(%last_lookup, voucher_count, tx = %tx_hash, timeout = ?REDEEM_RECEIPT_TIMEOUT, "redeemMany receipt timed out and the by-hash lookup missed; unconfirmed, leaving claims for retry");
}
}
if failed {
metrics.redemption_failure();
}
chain_fault
}
const fn is_chain_fault(outcome: &TxOutcome) -> bool {
match outcome {
TxOutcome::Landed(_) => false,
TxOutcome::Reverted(_)
| TxOutcome::SendErr(_)
| TxOutcome::ReceiptErr { .. }
| TxOutcome::Timeout { .. } => true,
}
}
const fn is_redemption_failure(outcome: &TxOutcome) -> bool {
match outcome {
TxOutcome::Reverted(_) | TxOutcome::SendErr(_) => true,
TxOutcome::Landed(_) | TxOutcome::ReceiptErr { .. } | TxOutcome::Timeout { .. } => false,
}
}
async fn record_landed_chunk<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
store: &Arc<dyn PoolStateStore>,
lanes: &[PlannedLane],
metrics: &Metrics,
) {
metrics.pool_redemptions(u64::try_from(lanes.len()).unwrap_or(u64::MAX));
let registering: Vec<LaneKey> = lanes
.iter()
.filter(|lane| lane.register.is_some())
.map(|lane| lane.key)
.collect();
for chunk in registering.chunks(WATERMARK_READ_BATCH_MAX) {
let pool_ids = chunk.iter().map(|key| key.pool_id).collect();
let signers = chunk.iter().map(|key| key.signer).collect();
let builder = contract.getAuthorizations(pool_ids, signers);
match timed(None, "post-redeem getAuthorizations", builder.call()).await {
Ok(auths) => persist_registered_expiries(store, chunk, &auths),
Err(err) => {
warn!(
error = %sanitize_err_chain(&err),
lanes = chunk.len(),
"post-redeem registration read failed; the next sweep re-registers and re-reads"
);
break;
}
}
}
}
fn persist_registered_expiries(
store: &Arc<dyn PoolStateStore>,
keys: &[LaneKey],
auths: &[PaymentPool::Authorization],
) {
if auths.len() != keys.len() {
warn!(
expected = keys.len(),
got = auths.len(),
"post-redeem registration read returned a mismatched count"
);
}
for (key, auth) in keys.iter().zip(auths) {
let SignerAuthorization::Registered { expiry, .. } =
SignerAuthorization::from_onchain(auth)
else {
continue;
};
if let Err(err) = store.set_registered_until(*key, expiry) {
warn!(error = %err, pool_id = %key.pool_id, signer = %key.signer,
"failed to persist registered_until after a landed registration");
}
}
}
async fn flush_store_durable(
store: &Arc<dyn PoolStateStore>,
metrics: &Arc<Metrics>,
strict: bool,
) -> bool {
let store = Arc::clone(store);
match tokio::task::spawn_blocking(move || store.flush()).await {
Ok(Ok(())) => true,
Ok(Err(err)) => {
metrics.lane_flush_failure();
if strict {
warn!(error = %err, "pre-redeem lane store flush failed; deferring redeem");
} else {
warn!(
error = %err,
"pre-redeem lane store flush failed; the shutdown sweep proceeds \
with un-flushed lane and capability state"
);
}
false
}
Err(join_err) => {
metrics.lane_flush_failure();
warn!(%join_err, "pre-redeem lane store flush task join failed");
false
}
}
}
async fn reconcile_onchain_watermarks<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
plans: Vec<PlannedLane>,
metrics: &Arc<Metrics>,
) -> (Vec<PlannedLane>, bool) {
if plans.is_empty() {
return (plans, false);
}
let mut read_failed = false;
let mut onchain_paid: Vec<U256> = Vec::with_capacity(plans.len());
for chunk in plans.chunks(WATERMARK_READ_BATCH_MAX) {
let mut pool_ids = Vec::with_capacity(chunk.len());
let mut signers = Vec::with_capacity(chunk.len());
let mut providers = Vec::with_capacity(chunk.len());
for plan in chunk {
pool_ids.push(plan.pool_id);
signers.push(plan.key.signer);
providers.push(plan.key.provider);
}
let builder = contract.getWatermarks(pool_ids, signers, providers);
let lanes = match timed(None, "pre-redeem getWatermarks", builder.call()).await {
Ok(lanes) => lanes,
Err(err) => {
warn!(
stage = "reconcile",
error = %sanitize_err_chain(&err),
lanes = chunk.len(),
"pre-redeem watermark reconciliation failed; submitting on the contract's own no-op guard"
);
metrics.redemption_reconcile_failure();
read_failed = true;
break;
}
};
if lanes.len() != chunk.len() {
warn!(
stage = "reconcile",
expected = chunk.len(),
got = lanes.len(),
"watermark reconciliation returned a mismatched count; submitting the \
unreconciled lanes unchanged"
);
metrics.redemption_reconcile_failure();
read_failed = true;
if lanes.len() < chunk.len() {
onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
}
break;
}
metrics.redemption_reconcile_ok();
onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
}
let (kept, skipped) = reconcile_plans(plans, &onchain_paid);
if skipped > 0 {
metrics.redemption_reconciled_skip_by(skipped);
}
(kept, read_failed)
}
fn reconcile_plans(plans: Vec<PlannedLane>, onchain_paid: &[U256]) -> (Vec<PlannedLane>, u64) {
let mut kept = Vec::with_capacity(plans.len());
let mut skipped = 0u64;
for (idx, mut plan) in plans.into_iter().enumerate() {
let Some(&onchain) = onchain_paid.get(idx) else {
kept.push(plan);
continue;
};
if onchain >= plan.owed {
skipped += 1;
debug!(
pool_id = %plan.pool_id,
signer = %plan.key.signer,
onchain_paid = %onchain,
owed = %plan.owed,
"reconciliation: lane already settled on-chain, dropping from redeem batch"
);
continue;
}
plan.unredeemed = plan.owed.saturating_sub(onchain);
kept.push(plan);
}
(kept, skipped)
}
fn record_tx_failure(span: &tracing::Span, outcome: &'static str, tx: Option<TxHash>) {
if let Some(tx) = tx {
span.record("tx", tracing::field::display(tx));
}
span.record("outcome", outcome);
span.record("otel.status_code", "ERROR");
}
fn holds_unredeemed(states: &[LaneState], pool_id: PoolId, provider: Address) -> bool {
states.iter().any(|st| {
st.pool_id == pool_id && st.provider == provider && st.owed() > st.paid_cumulative
})
}
async fn redeem_planned_lanes<P: Provider + Clone>(
contract: &PaymentPool::PaymentPoolInstance<P>,
store: &Arc<dyn PoolStateStore>,
plans: Vec<PlannedLane>,
floor: U256,
max_vouchers: usize,
strict_flush: bool,
metrics: &Arc<Metrics>,
) -> bool {
if plans.is_empty() || sum_unredeemed(&plans) < floor {
return false;
}
let (plans, read_failed) = reconcile_onchain_watermarks(contract, plans, metrics).await;
let chunks = chunk_redemptions(plans, floor, max_vouchers);
if chunks.is_empty() {
return read_failed;
}
let span = tracing::info_span!("redeem_cycle", chunks = chunks.len(), strict_flush);
async move {
if !flush_store_durable(store, metrics, strict_flush).await && strict_flush {
return read_failed;
}
let mut faulted = read_failed;
for chunk in chunks {
faulted |= submit_chunk(contract, store, chunk, metrics, 0).await;
}
faulted
}
.instrument(span)
.await
}
struct DebounceState {
last_persisted: Option<u64>,
last_persist_at: Instant,
pending: Option<u64>,
seeded: bool,
}
pub struct DebouncedCheckpointStore {
inner: Arc<dyn KeyedCheckpointStore>,
flush_blocks: u64,
flush_interval: Duration,
clock: Box<dyn Fn() -> Instant + Send + Sync>,
state: std::sync::Mutex<HashMap<CheckpointKey, DebounceState>>,
}
impl std::fmt::Debug for DebouncedCheckpointStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tracked: Vec<(CheckpointKey, Option<u64>, Option<u64>)> = {
let map = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.iter()
.map(|(k, s)| (*k, s.pending, s.last_persisted))
.collect()
};
f.debug_struct("DebouncedCheckpointStore")
.field("flush_blocks", &self.flush_blocks)
.field("flush_interval", &self.flush_interval)
.field("tracked", &tracked)
.finish_non_exhaustive()
}
}
impl DebouncedCheckpointStore {
#[must_use]
pub fn new(inner: Arc<dyn KeyedCheckpointStore>) -> Self {
Self::with_params(
inner,
CHECKPOINT_FLUSH_BLOCKS,
CHECKPOINT_FLUSH_INTERVAL,
Box::new(Instant::now),
)
}
fn with_params(
inner: Arc<dyn KeyedCheckpointStore>,
flush_blocks: u64,
flush_interval: Duration,
clock: Box<dyn Fn() -> Instant + Send + Sync>,
) -> Self {
Self {
inner,
flush_blocks,
flush_interval,
clock,
state: std::sync::Mutex::new(HashMap::new()),
}
}
fn persist_locked(
&self,
key: CheckpointKey,
state: &mut DebounceState,
block: u64,
) -> Result<(), StoreError> {
self.inner.record_checkpoint(key, block)?;
state.last_persisted = Some(block);
state.last_persist_at = (self.clock)();
state.pending = None;
Ok(())
}
}
impl KeyedCheckpointStore for DebouncedCheckpointStore {
fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError> {
let persisted = self.inner.load_checkpoint(key)?;
let pending = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
.and_then(|s| s.pending);
Ok(match (persisted, pending) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
})
}
fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError> {
let mut map = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let clock = &self.clock;
let state = map.entry(key).or_insert_with(|| DebounceState {
last_persisted: None,
last_persist_at: clock(),
pending: None,
seeded: false,
});
let first_record = !state.seeded;
if first_record {
state.last_persisted = self.inner.load_checkpoint(key)?;
state.seeded = true;
}
let highest = state
.pending
.max(state.last_persisted)
.unwrap_or(0)
.max(block);
state.pending = Some(highest);
if state.last_persisted == Some(highest) {
return Ok(());
}
let blocks_ahead = highest.saturating_sub(state.last_persisted.unwrap_or(0));
let elapsed = (self.clock)().saturating_duration_since(state.last_persist_at);
let due =
first_record || blocks_ahead >= self.flush_blocks || elapsed >= self.flush_interval;
if due {
self.persist_locked(key, state, highest)
} else {
Ok(())
}
}
fn flush_checkpoint(&self, key: CheckpointKey) -> Result<(), StoreError> {
let mut map = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(state) = map.get_mut(&key) else {
return Ok(());
};
match state.pending {
Some(block) if state.last_persisted != Some(block) => {
self.persist_locked(key, state, block)
}
_ => Ok(()),
}
}
}
fn is_oversize_send_err(msg: &str) -> bool {
const MARKERS: [&str; 5] = [
"gas required exceeds",
"exceeds block gas limit",
"oversized data",
"transaction too large",
"request entity too large",
];
let m = msg.to_ascii_lowercase();
MARKERS.iter().any(|marker| m.contains(marker))
}
#[cfg(test)]
mod tests;