Skip to main content

decdn_node/
payment_settlement.rs

1//! On-chain `PaymentPool` seller-settlement service (ADR 003).
2//!
3//! The node is the *provider* (payee) on its voucher lanes. This service closes
4//! the on-chain half of the paid-delivery loop that the off-chain voucher layer
5//! ([`decdn_incentive`]) and the `cdn/client/v1` handler leave open. A pool is
6//! owner-owned and owner-closed: the node NEVER closes, disputes, or settles a
7//! pool. Its only settlement primitive is redemption — `redeem` for a single
8//! lane, `redeemMany` for a batch — plus register-once of each signer's
9//! capability on that signer's first redemption.
10//!
11//! - **Paid-watermark watcher.** The paid side of every lane is driven by
12//!   consuming `PoolRedeemed` events filtered on this node's own `provider`
13//!   address (ADR 003 § Tracking owed vs. paid): each event sets the lane's paid
14//!   cumulative to `newPaidCumulative` and mirrors it onto the durable
15//!   `LaneState::paid_cumulative` row. `PoolToppedUp` re-drives a pool's lanes
16//!   (a dry pool may have left `owed > paid`), and `PoolReclaimed` forgets the
17//!   pool's lanes. The watcher reconciles like every other chain watcher —
18//!   enumerate `PoolRedeemed` from a pinned block, then tail live, resyncing on a
19//!   missed range — so paid is rebuilt from the event log, never guessed. That
20//!   forward rebuild starts at the poller's persisted cursor, so bootstrap first
21//!   rehydrates the paid cache from the durable lane rows: a restart carries
22//!   forward every redemption behind the cursor instead of re-submitting those
23//!   lanes for silent on-chain no-ops (#2052). The same
24//!   scan also folds the serve path's pool solvency/funder projection
25//!   ([`crate::pool_view::PoolProjection`]): `PoolOpened`/`PoolToppedUp` set a
26//!   pool's `{owner, deposit}`, `PoolRedeemed` for every provider draws down its
27//!   pool-wide `totalRedeemed`, and `PoolReclaimed` drops it — so a serve request
28//!   reads `{owner, remaining}` in-memory instead of through a `getPool` `eth_call`.
29//! - **Redemption (per-chunk floor + on-shutdown).** On a redeem hint (a
30//!   [`LaneKey`]) emitted by the voucher-accept path, the node reads the lane's
31//!   owed voucher and its cached paid watermark and plans the lane for
32//!   redemption — with no chain read. A signer this node has not yet registered
33//!   attaches a `CapabilityReg` from its held `owner_sig`; on-chain registration
34//!   is idempotent, and the first landed redemption persists `registered_until`
35//!   so later passes attach nothing. A low-frequency
36//!   self-tick sweeps every persisted lane, packs the planned lanes into
37//!   chunks, and submits a chunk — one `redeemMany` — only once the aggregate
38//!   unredeemed value across that chunk's lanes clears a configurable floor,
39//!   so a dropped hint never strands a lane whose chunk has cleared the floor.
40//!   The same sweep also runs at each serve cutoff: one second after the voucher
41//!   and preimage paths start to refuse a lane whose capability expiry is near.
42//!   The lane's claim is final there, and the sweep has about one redeem interval
43//!   to start the redemption and the landing slack to land it before the
44//!   contract stops paying the capability. On a restart, a lane whose cutoff
45//!   passed while the node was down is swept at once. A value that stays below
46//!   the floor is not redeemed by the cutoff sweep; a later sweep before the
47//!   landing slack can still pack it with other lanes.
48//!   The node flushes the lane store durable after planning a chunk and before
49//!   submitting it, so post-crash on-disk `owed ≥ submitted`.
50//! - **Redeem before reclaim.** A pool is owner-owned and owner-closed; the node
51//!   never closes, disputes, or settles. An owner's `closePool` only starts the
52//!   grace window (ADR 003 § Owner reclaims before a node redeems). The node runs
53//!   no force-redeem on close: the self-tick sweep runs far inside the 48h grace
54//!   floor (`redeem_interval_secs` is capped well below it at config load), so it
55//!   redeems every lane worth the gas before the owner can `reclaim`. A lane below
56//!   the per-chunk floor is left for the owner to reclaim — spending more gas than
57//!   it recovers is not worth it — and a node offline for the whole grace window
58//!   forfeits its unredeemed vouchers, a node-ops failure, not a protocol gap.
59//!   The sink does fold `PoolCloseInitiated` into the projection (marking the pool
60//!   `Closing` with its deadline) so the redeemer's solvency gate drops a drained
61//!   lane, or one whose deadline falls within the landing slack, instead of
62//!   submitting a `redeemMany` that reverts `PoolClosed`.
63//!
64//! Buyer-side `openPool`/`topUp`/`reclaim` (node→node cache-miss pulls) is out of
65//! scope here. The paid-watermark watcher is a [`Route`] the runtime registers on
66//! the shared multiplexed poller (which owns the loop, cursor, and shutdown); the
67//! service itself owns only the redeemer [`JoinHandle`], aborted on shutdown or
68//! drop.
69
70use std::cmp::Reverse;
71use std::collections::{HashMap, HashSet};
72use std::hash::Hash;
73use std::sync::{Arc, Mutex};
74use std::time::{Duration, Instant};
75
76use alloy::primitives::{Address, B256, Bytes, Signature, TxHash, U256};
77use alloy::providers::Provider;
78use alloy::rpc::types::Log;
79use alloy::sol_types::SolEvent;
80use anyhow::{Context, Result};
81use decdn_common::config::{REDEEM_LANDING_SLACK_SECS, capability_expiry_margin_secs};
82use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
83use decdn_incentive::payment_pool::{PaymentPool, to_pool_u64};
84use decdn_incentive::sig_canon::is_high_s;
85use decdn_incentive::{
86    CheckpointKey, KeyedCheckpointStore, LaneKey, LaneState, PoolId, PoolStateStore, StoreError,
87};
88use tokio::sync::{mpsc, watch};
89use tokio::task::JoinHandle;
90use tracing::{Instrument as _, debug, error, info, warn};
91
92use crate::chain_events::boot_retry::BootRetry;
93use crate::chain_events::multiplexed_poller::{Route, SinkSource};
94use crate::chain_events::resumable_watcher::{Checkpoint, ColdStart, CursorStart, LogSink};
95use crate::chain_events::{REORG_MARGIN_BLOCKS, timed};
96use crate::handlers::client::ClientHandler;
97use crate::metrics::{Metrics, metric_hook};
98use crate::onchain_tx::{TxOutcome, send_and_await_receipt};
99use crate::pool_view::{Lifecycle, PoolProjection, PoolStatus};
100use decdn_incentive::payment_pool::SignerAuthorization;
101
102/// Capacity of the redeem-hint channel. Hints are advisory (a missed hint only
103/// delays a redemption until the next voucher or self-tick sweep), so a bounded
104/// channel that drops on overflow is acceptable — sized for a burst of concurrent
105/// lanes without backpressuring the voucher-accept path.
106pub const REDEEM_HINT_CAPACITY: usize = 256;
107
108/// How long the admit path suppresses a repeat `getPool` for a pool it just
109/// found not-servable (nonexistent / `Closed`). A not-servable pool never folds
110/// into the projection, so its `snapshot` stays `None`; without this a client
111/// re-sending its capability on every request would drive one `getPool` per
112/// request against the same dead pool. On expiry the pool is re-checked once
113/// — the window is short enough that a pool opened after a negative result still
114/// becomes servable within it, long enough to collapse a request flood to ~one
115/// call per pool per window.
116const RESOLVE_VERDICT_TTL: Duration = Duration::from_mins(1);
117
118/// How long the admit path suppresses a repeat `getPool` for a pool whose read
119/// faulted (an RPC error or a `timed` timeout). A fault says nothing about the
120/// pool, so the window is far shorter than [`RESOLVE_VERDICT_TTL`]: a live pool
121/// is admitted again a few seconds after the RPC recovers. It is not zero: in an
122/// outage a request inside the window refuses at once instead of waiting out a
123/// read of its own. The window starts when the read ends; while a read is in
124/// flight, other requests for that pool wait on it (see [`ReadSlot`]), so an
125/// outage costs one read per pool at a time.
126const RESOLVE_FAULT_TTL: Duration = Duration::from_secs(5);
127
128/// Why a pool sits in the admit-path negative cache. The reason picks the
129/// suppression window.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum NegativeReason {
132    /// `getPool` answered: the pool is absent or `Closed`.
133    Verdict,
134    /// `getPool` failed or timed out; nothing is known about the pool.
135    Fault,
136}
137
138impl NegativeReason {
139    /// How long an entry with this reason suppresses a repeat `getPool`.
140    const fn ttl(self) -> Duration {
141        match self {
142            Self::Verdict => RESOLVE_VERDICT_TTL,
143            Self::Fault => RESOLVE_FAULT_TTL,
144        }
145    }
146}
147
148/// Cap on the admit-path negative cache, bounding its memory against a flood of
149/// distinct nonexistent pool ids. At the cap an insert first prunes lapsed
150/// entries; if the cache is still full it skips the insert, so a flood of
151/// distinct in-window negatives past the cap pays one `getPool` per admit rather
152/// than growing the cache without limit.
153const RESOLVE_NEGATIVE_CACHE_MAX: usize = 4096;
154
155/// How long an admit-path `Unregistered` read stays fresh in the signer-auth
156/// cache. Any provider's next redemption registers the signer, and a
157/// registration that pays no lane emits no event the projection can fold, so
158/// only a re-read sees it. Inside the window the node admits the signer on its
159/// presented capability; the on-chain `redeemMany` pays `min(desired, cap −
160/// spent)` and never over-cashes. A `Registered` read has no window: see
161/// [`AuthRead`].
162const UNREGISTERED_AUTH_TTL: Duration = Duration::from_mins(1);
163
164/// Cap on the admit-path signer-auth cache, bounding its memory against a flood of
165/// distinct `(pool, signer)` pairs. At the cap an insert first prunes lapsed
166/// `Unregistered` reads, then evicts the oldest read, so the map never grows
167/// without bound and an evicted signer pays one `getAuthorization` on its next
168/// admit.
169const AUTH_CACHE_MAX: usize = 4096;
170
171/// One admit-path `getAuthorization` read held in the signer-auth cache.
172///
173/// A `Registered` read never goes stale. Its `cap` and `expiry` are write-once,
174/// and its `spent` moves only by redemptions, which the projection folds from
175/// every provider's `PoolRedeemed`. The live `spent` is the read's `spent` plus
176/// what the projection folded for the signer since the read. The baseline is
177/// taken before the read is sent, so a redemption folded while the read is in
178/// flight may count twice: the error makes `spent` high, which refuses near the
179/// cap rather than over-admits.
180#[derive(Clone, Copy, Debug)]
181struct AuthRead {
182    /// What the chain answered.
183    auth: SignerAuthorization,
184    /// The projection's folded `spent` for the signer when the read was sent.
185    folded_at_read: u64,
186    /// When the read landed.
187    at: Instant,
188}
189
190/// What the signer-auth cache holds for one `(pool, signer)` at an admit.
191#[derive(Debug, PartialEq, Eq)]
192enum CachedAuth {
193    /// A usable answer, with a registered `spent` brought up to the fold.
194    Fresh(SignerAuthorization),
195    /// An `Unregistered` read past [`UNREGISTERED_AUTH_TTL`], or one the
196    /// projection has since seen the signer redeem: the signer may be
197    /// registered now.
198    Lapsed,
199    /// No read is held for the pair.
200    Absent,
201}
202
203/// Classify a held read against the projection's current fold for the signer.
204/// Pure, so the freshness rules are unit-testable without a provider.
205fn cached_auth(read: Option<&AuthRead>, folded_now: u64) -> CachedAuth {
206    let Some(read) = read else {
207        return CachedAuth::Absent;
208    };
209    let folded_since = folded_now.saturating_sub(read.folded_at_read);
210    match read.auth {
211        SignerAuthorization::Registered { cap, expiry, spent } => {
212            CachedAuth::Fresh(SignerAuthorization::Registered {
213                cap,
214                expiry,
215                spent: spent.saturating_add(folded_since),
216            })
217        }
218        SignerAuthorization::Unregistered
219            if folded_since == 0 && read.at.elapsed() < UNREGISTERED_AUTH_TTL =>
220        {
221            CachedAuth::Fresh(SignerAuthorization::Unregistered)
222        }
223        SignerAuthorization::Unregistered => CachedAuth::Lapsed,
224    }
225}
226
227/// Store `read` for `key`. At [`AUTH_CACHE_MAX`], prune lapsed `Unregistered`
228/// reads first; if the cache is still full, evict the oldest read. Pure, so the
229/// bound is unit-testable.
230fn remember_auth(
231    cache: &mut HashMap<(B256, Address), AuthRead>,
232    key: (B256, Address),
233    read: AuthRead,
234) {
235    if cache.len() >= AUTH_CACHE_MAX && !cache.contains_key(&key) {
236        cache.retain(|_, r| {
237            matches!(r.auth, SignerAuthorization::Registered { .. })
238                || r.at.elapsed() < UNREGISTERED_AUTH_TTL
239        });
240        if cache.len() >= AUTH_CACHE_MAX
241            && let Some(oldest) = cache.iter().min_by_key(|(_, r)| r.at).map(|(k, _)| *k)
242        {
243            cache.remove(&oldest);
244        }
245    }
246    cache.insert(key, read);
247}
248
249/// Bounded receipt wait for a redemption transaction. A stuck/dropped/replaced tx
250/// must not wedge a background tick. A lapse first fetches the receipt by hash,
251/// which adds at most 37 s (`RESOLVE_*` in `onchain_tx`); when that finds nothing
252/// it yields [`TxOutcome::Timeout`], a non-fatal outcome (the tx may still mine
253/// later; the claim is at worst deferred, never double-spent, because the
254/// on-chain lane watermark is monotone).
255const REDEEM_RECEIPT_TIMEOUT: Duration = Duration::from_mins(3);
256
257/// Bound on the reactive halve-and-retry when a chunk fails to send oversized.
258/// A default-sized chunk (300) sits far under the block-gas ceiling, so a real
259/// oversize needs at most one or two halvings; this caps the worst-case fan-out
260/// (a transient error mis-flagged as oversize) at 2^6 doomed sub-sends.
261const MAX_SPLIT_DEPTH: u32 = 6;
262
263/// Lanes per pre-redeem `getWatermarks` read ([`reconcile_onchain_watermarks`]).
264/// [`plan_lanes`] plans every persisted lane owed more than it is paid, so the
265/// count is bounded by nothing the redeemer controls — the per-transaction
266/// voucher cap chunks the *submit*, after this read. 512 triples is ~49 KB of
267/// calldata and ~32 KB of return, and under 3M gas — `test_getWatermarks_fullSizeBatch`
268/// measures a batch this size and the gas snapshot tracks it, so a change that
269/// makes the read too expensive shows up as a snapshot diff. That is well inside
270/// the `eth_call` ceilings providers commonly set. An endpoint that rejects a
271/// batch anyway surfaces as `redemption_reconcile_failures`, not as a wrong
272/// answer. A plain const, not a config knob: no operator knowledge makes a
273/// better choice here, and reusing the per-tx voucher cap would refragment the
274/// read whenever an operator lowered it.
275const WATERMARK_READ_BATCH_MAX: usize = 512;
276
277/// Current Unix time in seconds, compared against a lane's cached
278/// `registered_until` to skip the on-chain authorization read while the
279/// registration is still live (`registered_until > now`). A broken system clock
280/// (time before the epoch) yields `0`, so any non-zero `registered_until` looks
281/// live and the read is skipped. This is benign: if the capability has actually
282/// expired on-chain, the redemption simply no-ops as transient-empty at the
283/// contract, which stays the authoritative backstop.
284pub(crate) fn unix_now() -> u64 {
285    std::time::SystemTime::now()
286        .duration_since(std::time::UNIX_EPOCH)
287        .map_or(0, |d| d.as_secs())
288}
289
290/// Debounce thresholds for the watcher scan checkpoint (#784). The persisted
291/// checkpoint is only a *floor* for the resume backfill: the resumable watcher's
292/// `resolve_persisted_start` rounds it down by `REORG_MARGIN_BLOCKS` and every
293/// sink is idempotent, so a checkpoint that lags the true scan position by a
294/// bounded amount only ever *widens* the next rescan, never narrows it. `512` is
295/// chosen so a backlog drain fsyncs ~once per 512 blocks instead of per block.
296const CHECKPOINT_FLUSH_BLOCKS: u64 = 512;
297
298/// Time-based companion to [`CHECKPOINT_FLUSH_BLOCKS`]: even a slow trickle of
299/// events persists at least this often, bounding how many blocks a crash
300/// re-scans when block-cadence alone would defer the write indefinitely.
301const CHECKPOINT_FLUSH_INTERVAL: Duration = Duration::from_secs(30);
302
303/// In-memory index of every lane's paid-cumulative watermark, keyed by
304/// [`LaneKey`], read by the redeemer to compute `unredeemed = owed − paid`
305/// (ADR 003 § Tracking owed vs. paid). Written by the `PoolRedeemed` watcher,
306/// which mirrors every update onto the durable `LaneState::paid_cumulative` row
307/// in the same breath. The durable field is the source of truth; this cache is
308/// rehydrated from it at bootstrap ([`PoolSettlementService::bootstrap`]) so a
309/// restart carries forward redemptions that landed before the log-poller's
310/// persisted cursor, instead of re-submitting those lanes for silent on-chain
311/// no-ops (#2052).
312///
313/// A `std::sync::Mutex`: the guard is only ever held to read/insert a single
314/// entry, never across an `.await`.
315#[derive(Clone, Default)]
316pub struct PaidWatermarks {
317    inner: Arc<Mutex<HashMap<LaneKey, U256>>>,
318}
319
320impl std::fmt::Debug for PaidWatermarks {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        let len = self
323            .inner
324            .lock()
325            .map_or_else(|p| p.into_inner().len(), |m| m.len());
326        f.debug_struct("PaidWatermarks")
327            .field("lanes", &len)
328            .finish()
329    }
330}
331
332impl PaidWatermarks {
333    /// Set a lane's paid cumulative to the `PoolRedeemed` event's
334    /// `newPaidCumulative`. Monotone on-chain, so this only ever advances.
335    fn set(&self, key: LaneKey, paid: U256) {
336        self.inner
337            .lock()
338            .unwrap_or_else(std::sync::PoisonError::into_inner)
339            .insert(key, paid);
340    }
341
342    /// A lane's paid cumulative, or `U256::ZERO` if none is cached — no
343    /// `PoolRedeemed` has landed for it this run and the bootstrap rehydration
344    /// found no durable watermark (a never-redeemed lane). `ZERO` is the safe
345    /// over-estimate of `unredeemed`: the on-chain redeem caps the increment and
346    /// the event then corrects both the cache and the durable row.
347    fn get(&self, key: &LaneKey) -> U256 {
348        self.inner
349            .lock()
350            .unwrap_or_else(std::sync::PoisonError::into_inner)
351            .get(key)
352            .copied()
353            .unwrap_or(U256::ZERO)
354    }
355
356    /// Drop a reclaimed pool's lane from the cache.
357    fn forget(&self, key: &LaneKey) {
358        self.inner
359            .lock()
360            .unwrap_or_else(std::sync::PoisonError::into_inner)
361            .remove(key);
362    }
363}
364
365/// Seller-side `PaymentPool` settlement service. Generic over the alloy
366/// [`Provider`] (a wallet-filled provider is required for the `redeem` /
367/// `redeemMany` write path). Cheap to construct; owns its background tasks.
368pub struct PoolSettlementService<P: Provider + Clone + 'static> {
369    contract: PaymentPool::PaymentPoolInstance<P>,
370    redeem_tx: mpsc::Sender<LaneKey>,
371    store: Arc<dyn PoolStateStore>,
372    paid: PaidWatermarks,
373    self_address: Address,
374    redeem_threshold: U256,
375    redeem_max_vouchers_per_tx: usize,
376    metrics: Arc<Metrics>,
377    pool_view: PoolProjection,
378    /// The redemption task handle. Held so shutdown can abort+await it before a
379    /// final redeem sweep. `take()`n by [`Self::quiesce_redeemer`]; the [`Drop`]
380    /// impl aborts whatever remains. A `std::sync::Mutex` (not `tokio`): the guard
381    /// is only ever held to `take()` the handle, never across an `.await`.
382    redeemer: std::sync::Mutex<Option<JoinHandle<()>>>,
383}
384
385/// Startup self-check: a cheap immutable view confirms the configured address
386/// actually hosts the `PaymentPool` contract. Transient errors retry on
387/// `boot`'s budget; no contract at the address decodes as `ZeroData`, which is
388/// permanent and fails at once.
389async fn usdc_self_check<P: Provider + Clone>(
390    contract: &PaymentPool::PaymentPoolInstance<P>,
391    boot: &BootRetry,
392) -> Result<Address> {
393    let payment_pool_addr = *contract.address();
394    boot.run("PaymentPool.usdc() self-check", || async {
395        timed(None, "PaymentPool.usdc()", contract.usdc().call())
396            .await
397            .with_context(|| format!("PaymentPool.usdc() self-check at {payment_pool_addr}"))
398    })
399    .await
400}
401
402impl<P: Provider + Clone + 'static> PoolSettlementService<P> {
403    /// Bootstrap the service: self-check the contract, spawn the redemption
404    /// task, and return the service alongside the paid-watermark [`Route`] the
405    /// runtime registers on the shared multiplexed poller.
406    ///
407    /// # Errors
408    ///
409    /// Returns an error if the `usdc()` self-check fails deterministically — a
410    /// bad `payment_pool_address` is fatal at bring-up — or if `boot`'s budget
411    /// runs out on transient RPC failures.
412    #[allow(clippy::too_many_arguments)]
413    pub async fn bootstrap(
414        provider: P,
415        payment_pool_addr: Address,
416        self_address: Address,
417        store: Arc<dyn PoolStateStore>,
418        checkpoint_store: Arc<dyn KeyedCheckpointStore>,
419        handler: Arc<ClientHandler>,
420        redeem_threshold: U256,
421        redeem_max_vouchers_per_tx: usize,
422        redeem_interval: Duration,
423        metrics: Arc<Metrics>,
424        boot: &BootRetry,
425        pool_view: PoolProjection,
426        redeem_tx: mpsc::Sender<LaneKey>,
427        redeem_rx: mpsc::Receiver<LaneKey>,
428    ) -> Result<(Self, Route)> {
429        let contract = PaymentPool::new(payment_pool_addr, provider);
430
431        let usdc_token = usdc_self_check(&contract, boot).await?;
432        info!(
433            %payment_pool_addr,
434            %usdc_token,
435            %self_address,
436            "PaymentPool settlement service bootstrap complete"
437        );
438
439        // Rehydrate the cache from the durable lane records BEFORE the redeemer
440        // starts, so a restart does not forget redemptions that landed before the
441        // log-poller's persisted cursor (#2052).
442        let paid = rehydrate_paid_watermarks(store.as_ref(), self_address);
443
444        // Paid-watermark watcher on the resumable `eth_getLogs` poller. The
445        // backfill floor and downtime-gap resume are the cursor start's job: a
446        // persisted `PoolRedeemed` checkpoint resumes across restarts; a
447        // first-ever boot (cold store) anchors at head. `PoolRedeemed` is the
448        // single write path for the paid side, so a drained-pool partial pay is
449        // recorded exactly — the node never assumes its voucher cleared.
450        let sink = PoolSettlementSink {
451            self_address,
452            store: Arc::clone(&store),
453            handler,
454            paid: paid.clone(),
455            redeem_tx: redeem_tx.clone(),
456            metrics: Arc::clone(&metrics),
457            pool_view: pool_view.clone(),
458        };
459        let route = Route {
460            addresses: vec![payment_pool_addr],
461            topic0s: settlement_route_topic0s(),
462            // The durable `PoolOpened` checkpoint resumes across restarts; a
463            // first-ever boot (cold store) anchors at head. The poller flushes
464            // this checkpoint on shutdown (via `CursorStart::flush`).
465            start: cursor_start(Arc::clone(&checkpoint_store)),
466            // This sink observes no shutdown token, so it registers a `Ready`
467            // sink; its own follow-up reads/writes ride the wallet contract it
468            // holds, independent of the poller's read-only get_logs provider.
469            sink: SinkSource::Ready(Box::new(sink)),
470            label: "settlement",
471            on_established: Some(metric_hook(
472                &metrics,
473                Metrics::settlement_watcher_cycle_established,
474            )),
475            on_backoff: Some(metric_hook(
476                &metrics,
477                Metrics::settlement_watcher_backoff_started,
478            )),
479            on_tick_success: Some(metric_hook(&metrics, Metrics::settlement_watcher_tick)),
480            on_task_panic: Some(metric_hook(
481                &metrics,
482                Metrics::settlement_watcher_task_panicked,
483            )),
484        };
485
486        let redeemer = tokio::spawn(redeemer_loop(
487            contract.clone(),
488            Arc::clone(&store),
489            paid.clone(),
490            self_address,
491            redeem_threshold,
492            redeem_max_vouchers_per_tx,
493            redeem_interval,
494            capability_expiry_margin_secs(redeem_interval.as_secs()),
495            redeem_rx,
496            Arc::clone(&metrics),
497            pool_view.clone(),
498        ));
499
500        Ok((
501            Self {
502                contract,
503                redeem_tx,
504                store,
505                paid,
506                self_address,
507                redeem_threshold,
508                redeem_max_vouchers_per_tx,
509                metrics,
510                pool_view,
511                redeemer: std::sync::Mutex::new(Some(redeemer)),
512            },
513            route,
514        ))
515    }
516
517    /// Sender the voucher-accept path uses to hint that a lane's accrued claim
518    /// may be ready to plan into a chunk whose aggregate clears the redemption
519    /// floor. Cloneable; dropping all senders simply ends the redemption task
520    /// cleanly.
521    #[must_use]
522    pub fn redeem_hint_sender(&self) -> mpsc::Sender<LaneKey> {
523        self.redeem_tx.clone()
524    }
525
526    /// Graceful shutdown: quiesce the redeemer, then run one final best-effort
527    /// redeem sweep bounded by `deadline` so a lane whose chunk has cleared the
528    /// floor is not left un-redeemed across the stop. A pool is owner-closed only,
529    /// so there is nothing to close here — only redeem.
530    ///
531    /// The paid-watermark watcher is now the shared multiplexed poller's route,
532    /// not a service-owned task: the runtime cancels the poller (which triggers an
533    /// async, best-effort flush of this route's `PoolOpened` scan checkpoint via
534    /// `CursorStart::flush`) before calling this, but only cancels the token — it
535    /// does not await the poller's exit — so that flush is not guaranteed to
536    /// complete before the final sweep below runs. This is fine: the scan
537    /// checkpoint is independent of the lane-state redeem sweep and idempotent to
538    /// re-scan, so the two can race without a correctness impact.
539    pub async fn shutdown(&self, deadline: Duration) {
540        self.quiesce_redeemer().await;
541        if tokio::time::timeout(deadline, self.final_redeem_sweep())
542            .await
543            .is_err()
544        {
545            warn!(
546                deadline_secs = deadline.as_secs(),
547                "shutdown redeem deadline elapsed; some lanes left un-redeemed (redeem later)"
548            );
549        }
550    }
551
552    /// One last pass of `redeemMany` chunks over every lane whose chunk clears the
553    /// floor, so shutdown secures earnings the next boot would otherwise wait a
554    /// hint/sweep to collect.
555    async fn final_redeem_sweep(&self) {
556        // Shutdown redeems regardless of a flush failure (`strict_flush` false):
557        // forfeiting the claim across the stop is worse than a bounded re-serve
558        // risk. It still applies the configured per-chunk floor, so sub-floor
559        // dust stays unredeemed across the stop.
560        let Some(states) = load_lanes(self.store.as_ref()) else {
561            return;
562        };
563        // No later sweep follows and no lane is parked, so the chain-fault flag
564        // has no use here.
565        let _chain_fault = redeem_sweep(
566            &self.contract,
567            &self.store,
568            states,
569            &self.paid,
570            self.self_address,
571            self.redeem_threshold,
572            self.redeem_max_vouchers_per_tx,
573            false,
574            &self.metrics,
575            &self.pool_view,
576        )
577        .await;
578    }
579
580    /// Abort and await the redemption task so it issues no *further* `redeem` into
581    /// the shutdown sweep. Idempotent: once the handle is taken, later calls — and
582    /// the [`Drop`] safety net — find `None` and no-op.
583    async fn quiesce_redeemer(&self) {
584        let handle = self
585            .redeemer
586            .lock()
587            .unwrap_or_else(std::sync::PoisonError::into_inner)
588            .take();
589        if let Some(handle) = handle {
590            handle.abort();
591            let _ = handle.await;
592        }
593    }
594}
595
596impl<P: Provider + Clone + 'static> std::fmt::Debug for PoolSettlementService<P> {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        f.debug_struct("PoolSettlementService")
599            .field("address", self.contract.address())
600            .finish_non_exhaustive()
601    }
602}
603
604impl<P: Provider + Clone + 'static> Drop for PoolSettlementService<P> {
605    fn drop(&mut self) {
606        if let Some(handle) = self
607            .redeemer
608            .lock()
609            .unwrap_or_else(std::sync::PoisonError::into_inner)
610            .take()
611        {
612            handle.abort();
613        }
614    }
615}
616
617/// Fold a stored voucher signature (`r‖s‖v`, 65 bytes) into the EIP-2098
618/// compact pair `(r, vs)` the contract's `LaneVoucher` carries: `vs` is `s`
619/// with the recovery bit in its top bit. Halving the signature is what makes
620/// the voucher a static ABI struct, and so what makes a lane 160 calldata
621/// bytes instead of 288.
622///
623/// # Errors
624///
625/// Errors on a signature that is not 65 bytes, on a malformed `r`/`s`/`v`, or
626/// on a non-canonical high-`s` signature.
627///
628/// The canonicality check is [`is_high_s`], not "is the top bit of `s` free".
629/// Those are not the same test: `secp256k1n / 2` sits below `2^255`, so about
630/// `2^128` values have a clear top bit and are still high-`s`. Compaction would
631/// happily fold the recovery bit into such a signature and the contract's
632/// `ECDSA.tryRecover` would then reject it — reverting the whole `redeemMany`,
633/// so one hostile lane would sink every honest lane batched with it. The node's
634/// voucher-accept path already refuses high-`s` (#836), so this is the second
635/// gate on a value that should never have been stored; a lane it rejects is
636/// dropped from the batch rather than allowed to sink it.
637fn compact_voucher_signature(sig: &[u8]) -> Result<(B256, B256)> {
638    let raw: [u8; 65] = sig
639        .try_into()
640        .map_err(|_| anyhow::anyhow!("voucher signature is not 65 bytes (r‖s‖v)"))?;
641    let parsed = Signature::from_raw(&raw).context("voucher signature is malformed")?;
642    if is_high_s(&parsed) {
643        anyhow::bail!("voucher signature is non-canonical (high `s`) and would revert on-chain");
644    }
645
646    let r = B256::from(parsed.r().to_be_bytes::<32>());
647    let mut vs = parsed.s().to_be_bytes::<32>();
648    // Canonical `s` is at most `n / 2`, which is below `2^255`, so the top bit
649    // is free for the recovery bit. The `is_high_s` gate above is what
650    // guarantees that.
651    let Some(top) = vs.first_mut() else {
652        anyhow::bail!("voucher signature `s` is empty");
653    };
654    *top |= u8::from(parsed.v()) << 7;
655
656    Ok((r, B256::from(vs)))
657}
658
659/// The settlement watcher's cursor start: resume the durable
660/// [`CheckpointKey::PoolOpened`] floor; a first-ever boot (cold store) anchors at
661/// **head** ([`ColdStart::Head`]) — no `PoolRedeemed` toward this node can
662/// predate the node itself, so there is no history to replay.
663fn cursor_start(store: Arc<dyn KeyedCheckpointStore>) -> CursorStart {
664    CursorStart::FromCheckpoint {
665        checkpoint: Checkpoint {
666            store,
667            key: CheckpointKey::PoolOpened,
668        },
669        reorg_margin: REORG_MARGIN_BLOCKS,
670        cold_start: ColdStart::Head,
671    }
672}
673
674/// The settlement route's demux key: every `PaymentPool` lifecycle event the
675/// paid-watermark cache and pool projection need. Split out from
676/// [`PoolSettlementService::bootstrap`] so the exact topic0 set is
677/// unit-testable without a provider — dropping `PoolOpened` here, for example,
678/// would silently blind the projection to newly opened pools.
679fn settlement_route_topic0s() -> Vec<B256> {
680    vec![
681        PaymentPool::PoolOpened::SIGNATURE_HASH,
682        PaymentPool::PoolRedeemed::SIGNATURE_HASH,
683        PaymentPool::PoolToppedUp::SIGNATURE_HASH,
684        PaymentPool::PoolCloseInitiated::SIGNATURE_HASH,
685        PaymentPool::PoolReclaimed::SIGNATURE_HASH,
686    ]
687}
688
689/// Applies `PaymentPool` settlement logs to the paid-watermark cache and pool
690/// projection. One per settlement watcher; the resumable `eth_getLogs`
691/// poller feeds it block-ordered logs and advances + persists the scan checkpoint
692/// per window on a clean tick.
693///
694/// Failure policy: paid-watermark updates are in-memory and always `Ok`. A
695/// `PoolReclaimed` forget failure is logged and skipped (`Ok`) — the settled lane
696/// owes nothing and the forget is idempotent. An undecodable log is log-and-skip
697/// so a permanently undecodable log never hot-loops the deterministic re-scan.
698struct PoolSettlementSink {
699    self_address: Address,
700    store: Arc<dyn PoolStateStore>,
701    handler: Arc<ClientHandler>,
702    paid: PaidWatermarks,
703    redeem_tx: mpsc::Sender<LaneKey>,
704    metrics: Arc<Metrics>,
705    /// The serve path's pool solvency/funder view. This sink is its single writer:
706    /// it folds `PoolOpened` (owner + deposit), `PoolToppedUp` (new deposit),
707    /// `PoolRedeemed` for EVERY provider (pool-wide `totalRedeemed`),
708    /// `PoolCloseInitiated` (mark `Closing` with its deadline), and `PoolReclaimed`
709    /// (drop) into the projection so a serve request reads `{owner, remaining}`
710    /// in-memory rather than through a `getPool` `eth_call`.
711    pool_view: PoolProjection,
712}
713
714impl LogSink for PoolSettlementSink {
715    #[allow(clippy::cognitive_complexity)]
716    async fn apply(&mut self, log: Log) -> Result<()> {
717        match log.topic0().copied() {
718            Some(sig) if sig == PaymentPool::PoolOpened::SIGNATURE_HASH => {
719                // Projection-only: `PoolOpened` seeds the serve path's owner +
720                // deposit. It carries no settlement effect — this node holds no
721                // lane against a freshly-opened pool until a voucher arrives.
722                let event = match PaymentPool::PoolOpened::decode_log_data(&log.inner.data) {
723                    Ok(event) => event,
724                    Err(err) => {
725                        warn!(error = %err, "skipping undecodable PoolOpened log");
726                        return Ok(());
727                    }
728                };
729                self.pool_view
730                    .record_opened(event.poolId, event.owner, event.deposit);
731                debug!(pool_id = %event.poolId, owner = %event.owner, "projected PoolOpened");
732            }
733            Some(sig) if sig == PaymentPool::PoolRedeemed::SIGNATURE_HASH => {
734                let event = match PaymentPool::PoolRedeemed::decode_log_data(&log.inner.data) {
735                    Ok(event) => event,
736                    Err(err) => {
737                        warn!(error = %err, "skipping undecodable PoolRedeemed log");
738                        return Ok(());
739                    }
740                };
741                // Pool-wide `totalRedeemed` folds EVERY provider's lanes, so the
742                // solvency projection records this event before the own-provider
743                // filter below — the serve path's `remaining` must reflect other
744                // nodes' redemptions against the same pool, not just this node's.
745                self.pool_view
746                    .record_redeemed(event.poolId, event.provider, &event.lanes);
747                // Not enough to filter on the event signature — `provider` is
748                // an indexed topic but the OR-set filter cannot pin it, so
749                // confirm it names this node before recording the paid watermark.
750                if event.provider != self.self_address {
751                    return Ok(());
752                }
753                // One event carries every lane the batch paid on this pool, so
754                // the watermark write is per entry, not per log.
755                for lane in &event.lanes {
756                    let key = LaneKey {
757                        pool_id: event.poolId,
758                        signer: lane.signer,
759                        provider: event.provider,
760                    };
761                    let paid_cumulative = U256::from(lane.newPaidCumulative);
762                    self.paid.set(key, paid_cumulative);
763                    // Mirror the watermark onto the durable lane record so it
764                    // survives a restart (#2052). Buffered like `record`; the
765                    // periodic lane flush fsyncs it. A no-op for a lane already
766                    // forgotten, and the in-memory cache above stays authoritative
767                    // for this run even if the durable write is refused, so a
768                    // failure only lags durability — log it rather than fail the
769                    // poller apply (whose failure policy is in-memory + always Ok).
770                    if let Err(err) = self.store.set_paid_cumulative(key, paid_cumulative) {
771                        self.metrics.watcher_persist_failure();
772                        warn!(
773                            error = %err,
774                            pool_id = %event.poolId,
775                            signer = %lane.signer,
776                            "failed to persist lane paid watermark; in-memory cache updated, \
777                             durable value lags until the next redemption"
778                        );
779                    }
780                    debug!(
781                        pool_id = %event.poolId,
782                        signer = %lane.signer,
783                        paid_cumulative = lane.newPaidCumulative,
784                        "recorded lane paid watermark from PoolRedeemed"
785                    );
786                }
787            }
788            Some(sig) if sig == PaymentPool::PoolToppedUp::SIGNATURE_HASH => {
789                let event = match PaymentPool::PoolToppedUp::decode_log_data(&log.inner.data) {
790                    Ok(event) => event,
791                    Err(err) => {
792                        warn!(error = %err, "skipping undecodable PoolToppedUp log");
793                        return Ok(());
794                    }
795                };
796                // Raise the serve path's projected deposit so the solvency gate
797                // reserves against the topped-up balance, not the stale one.
798                self.pool_view.record_topup(event.poolId, event.newDeposit);
799                // A top-up may re-open lanes a dry pool left `owed > paid`.
800                // Re-drive by hinting the redeemer for each of the pool's lanes
801                // this node provides.
802                self.redrive_pool_lanes(event.poolId);
803            }
804            Some(sig) if sig == PaymentPool::PoolCloseInitiated::SIGNATURE_HASH => {
805                let event = match PaymentPool::PoolCloseInitiated::decode_log_data(&log.inner.data)
806                {
807                    Ok(event) => event,
808                    Err(err) => {
809                        warn!(error = %err, "skipping undecodable PoolCloseInitiated log");
810                        return Ok(());
811                    }
812                };
813                // Mark the pool `Closing` in the projection so the redeemer's
814                // solvency gate reads its dispute deadline: a lane whose pool is
815                // drained or already past the deadline is dropped from planning
816                // instead of submitting a `redeemMany` that reverts `PoolClosed`.
817                // The node runs no force-redeem here — the periodic sweep redeems
818                // every lane worth the gas well inside the grace window.
819                self.pool_view.record_closing(
820                    event.poolId,
821                    u64::try_from(event.disputeDeadline).unwrap_or(u64::MAX),
822                );
823                if self.holds_unredeemed_on(event.poolId) {
824                    self.metrics.pool_grace_close();
825                }
826            }
827            Some(sig) if sig == PaymentPool::PoolReclaimed::SIGNATURE_HASH => {
828                let event = match PaymentPool::PoolReclaimed::decode_log_data(&log.inner.data) {
829                    Ok(event) => event,
830                    Err(err) => {
831                        warn!(error = %err, "skipping undecodable PoolReclaimed log");
832                        return Ok(());
833                    }
834                };
835                // The pool is `Closed` and refunded: drop it from the serve
836                // projection so a later read fails open rather than reserving
837                // against a stale remaining.
838                self.pool_view.forget(event.poolId);
839                self.forget_pool_lanes(event.poolId).await;
840            }
841            // Unreachable today (the filter's topic0 OR-set bounds the inputs);
842            // don't panic (anti-panic policy), log so a future OR-set drift leaves
843            // a greppable trail instead of a silently dropped event.
844            _ => {
845                debug!(topic0 = ?log.topic0(), "unmatched PaymentPool event in subscribed OR-set");
846            }
847        }
848        Ok(())
849    }
850}
851
852impl PoolSettlementSink {
853    /// Whether this node provides a lane of `pool_id` that is owed more than the
854    /// chain has paid it — revenue that must redeem inside the grace window. A
855    /// store read failure answers `false`: the counter this feeds is advisory.
856    fn holds_unredeemed_on(&self, pool_id: PoolId) -> bool {
857        match self.store.load_all() {
858            Ok(states) => holds_unredeemed(&states, pool_id, self.self_address),
859            Err(err) => {
860                warn!(error = %err, %pool_id, "grace-close check: failed to load lane state");
861                false
862            }
863        }
864    }
865
866    /// Hint the redeemer for every lane of `pool_id` this node provides, so a
867    /// top-up re-drives lanes a dry pool left `owed > paid`. Best-effort: a full
868    /// hint channel drops the nudge (the self-tick sweep is the backstop).
869    fn redrive_pool_lanes(&self, pool_id: PoolId) {
870        let states = match self.store.load_all() {
871            Ok(s) => s,
872            Err(err) => {
873                warn!(error = %err, %pool_id, "top-up re-drive: failed to load lane state");
874                return;
875            }
876        };
877        for st in states {
878            if st.pool_id == pool_id && st.provider == self.self_address {
879                let _ = self.redeem_tx.try_send(st.key());
880            }
881        }
882    }
883
884    /// Forget every lane of a reclaimed `pool_id` this node provides — the pool is
885    /// `Closed`, so no further voucher can be redeemed against it.
886    #[allow(clippy::cognitive_complexity)]
887    async fn forget_pool_lanes(&self, pool_id: PoolId) {
888        let states = match self.store.load_all() {
889            Ok(s) => s,
890            Err(err) => {
891                warn!(error = %err, %pool_id, "reclaim cleanup: failed to load lane state");
892                return;
893            }
894        };
895        for st in states {
896            if st.pool_id != pool_id || st.provider != self.self_address {
897                continue;
898            }
899            let key = st.key();
900            if let Err(err) = self.handler.forget_lane(key).await {
901                self.metrics.watcher_persist_failure();
902                warn!(error = %err, pool_id = %pool_id, signer = %key.signer, "failed to forget reclaimed lane");
903            } else {
904                self.paid.forget(&key);
905                info!(pool_id = %pool_id, signer = %key.signer, "pool reclaimed; dropped tracked lane");
906            }
907        }
908        self.handler.forget_pool_floor(pool_id);
909    }
910}
911
912/// A [`PoolView`](crate::pool_view::PoolView) that confirms a pool on-chain
913/// before a serve is admitted.
914///
915/// The serve gates read `{owner, remaining}` from the event-fed [`PoolProjection`]
916/// the settlement watcher folds, so the common case costs no `eth_call`. A pool
917/// the projection has not observed is the hard case: a pool opened before this
918/// node's `ColdStart::Head` anchor never appears in the forward-only `PoolOpened`
919/// scan, so its owner is absent from the projection even though the pool is live.
920/// Rather than fail open on that gap, [`status`](crate::pool_view::PoolView::status) does ONE `getPool` at
921/// admission — a read the admission path tolerates (it may block) — folds a
922/// servable pool into the projection, and refuses an absent, closed, or faulted
923/// pool. Every admit-path chain read (`getPool` and the signer's
924/// `getAuthorization`) runs under `chain_events::timed`'s default bound, so a
925/// hung read times out and takes the fault path instead of stalling admission.
926/// The client re-sends its capability on its next request (the documented
927/// lane recovery path), and by then the folded owner registers the lane.
928///
929/// The mid-stream re-check calls [`cached_status`](crate::pool_view::PoolView::cached_status), which reads the
930/// projection ONLY and never blocks on a `getPool` — a chain read at a voucher
931/// boundary would stall delivery.
932///
933/// A short-TTL negative cache suppresses a repeat `getPool` for a pool just
934/// found not-servable (`RESOLVE_VERDICT_TTL`) or whose read faulted (the far
935/// shorter `RESOLVE_FAULT_TTL`), so a client re-requesting the same dead pool
936/// every request cannot drive one `getPool` per request, and one failed read does
937/// not refuse a live pool for the full verdict window. Reads are coalesced per
938/// pool: a request that finds a `getPool` in flight for its pool waits for it
939/// and takes its answer.
940///
941/// The signer confirm reads `getAuthorization` once per `(pool, signer)` and
942/// keeps a `Registered` answer for good (see `AuthRead`); only an
943/// `Unregistered` answer is re-read. Those reads are coalesced per pair the same
944/// way.
945pub struct ResolvingPoolView<P: Provider + Clone> {
946    /// The wallet/RPC-backed `PaymentPool` binding for the admit-path reads
947    /// (`getPool`, `getAuthorization`).
948    contract: PaymentPool::PaymentPoolInstance<P>,
949    /// The event-fed projection this view reads first and folds a resolved pool
950    /// into. Shared with the settlement watcher's sink (the authoritative writer).
951    projection: PoolProjection,
952    /// Pool id → (why, when) for pools recently found not-servable or whose read
953    /// faulted. The reason picks the entry's window ([`NegativeReason::ttl`]).
954    /// The guard is held only to read/insert one entry, never across the
955    /// `getPool` await.
956    negative: Mutex<HashMap<B256, (NegativeReason, Instant)>>,
957    /// Pool id → a receiver for the in-flight admit `getPool` of that pool,
958    /// which carries the read's answer. See [`ReadSlot`]. The guard is held only to
959    /// read/insert one entry, never across the `getPool` await.
960    inflight: InflightReads<B256, Option<PoolStatus>>,
961    /// `(pool_id, signer)` → the last `getAuthorization` read of that pair, for
962    /// the admit-path signer confirm. [`cached_auth`] decides whether a read
963    /// still answers. The guard is held only to read/insert one entry, never
964    /// across the `getAuthorization` await.
965    auth_cache: Mutex<HashMap<(B256, Address), AuthRead>>,
966    /// `(pool_id, signer)` → a receiver for the in-flight admit
967    /// `getAuthorization` of that pair. See [`ReadSlot`].
968    auth_inflight: InflightReads<(B256, Address), Option<SignerAuthorization>>,
969    /// Counts each signer confirm by what the cache held for it.
970    metrics: Arc<Metrics>,
971}
972
973impl<P: Provider + Clone> std::fmt::Debug for ResolvingPoolView<P> {
974    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
975        f.debug_struct("ResolvingPoolView")
976            .field("address", self.contract.address())
977            .field("projection", &self.projection)
978            .finish_non_exhaustive()
979    }
980}
981
982impl<P: Provider + Clone> ResolvingPoolView<P> {
983    /// Wrap the event-fed `projection` with admit-path `getPool` and
984    /// `getAuthorization` fallbacks against `contract`.
985    #[must_use]
986    pub fn new(
987        contract: PaymentPool::PaymentPoolInstance<P>,
988        projection: PoolProjection,
989        metrics: Arc<Metrics>,
990    ) -> Self {
991        Self {
992            contract,
993            projection,
994            negative: Mutex::new(HashMap::new()),
995            inflight: Mutex::new(HashMap::new()),
996            auth_cache: Mutex::new(HashMap::new()),
997            auth_inflight: Mutex::new(HashMap::new()),
998            metrics,
999        }
1000    }
1001
1002    /// What the signer-auth cache holds for `(pool_id, signer)` now.
1003    fn lookup_auth(&self, pool_id: B256, signer: Address) -> CachedAuth {
1004        let folded_now = self.projection.signer_spent(pool_id, signer);
1005        let guard = self
1006            .auth_cache
1007            .lock()
1008            .unwrap_or_else(std::sync::PoisonError::into_inner);
1009        cached_auth(guard.get(&(pool_id, signer)), folded_now)
1010    }
1011}
1012
1013impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
1014    /// Read `pool_id` on-chain once and record the result: fold a servable pool
1015    /// into the projection, or negative-cache an absent / `Closed` pool as a
1016    /// [`NegativeReason::Verdict`] and a failed read as a
1017    /// [`NegativeReason::Fault`]. The caller holds the pool's [`ReadSlot::Lead`].
1018    async fn resolve(&self, pool_id: B256) -> Option<PoolStatus> {
1019        // Bounded: the alloy HTTP provider sets no timeout of its own, so a hung
1020        // read times out here and takes the fault path below.
1021        let pool = match timed(None, "admit getPool", self.contract.getPool(pool_id).call()).await {
1022            Ok(pool) => pool,
1023            Err(err) => {
1024                warn!(
1025                    error = %sanitize_err_chain(&err),
1026                    %pool_id,
1027                    suppressed_for = ?RESOLVE_FAULT_TTL,
1028                    "admit getPool failed; refusing this pool until the negative cache lapses"
1029                );
1030                let mut guard = self
1031                    .negative
1032                    .lock()
1033                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1034                remember_negative(&mut guard, pool_id, NegativeReason::Fault);
1035                return None;
1036            }
1037        };
1038        let Some(lifecycle) = resolved_lifecycle(&pool) else {
1039            debug!(%pool_id, "admit getPool: pool absent or closed; refusing");
1040            let mut guard = self
1041                .negative
1042                .lock()
1043                .unwrap_or_else(std::sync::PoisonError::into_inner);
1044            remember_negative(&mut guard, pool_id, NegativeReason::Verdict);
1045            return None;
1046        };
1047        self.projection
1048            .record_resolved(pool_id, pool.owner, U256::from(pool.deposit), lifecycle);
1049        // The pool resolved: drop its lapsed negative entry. The projection
1050        // answers for it from here on.
1051        {
1052            let mut guard = self
1053                .negative
1054                .lock()
1055                .unwrap_or_else(std::sync::PoisonError::into_inner);
1056            guard.remove(&pool_id);
1057        }
1058        self.projection.snapshot(pool_id)
1059    }
1060}
1061
1062#[async_trait::async_trait]
1063impl<P: Provider + Clone + 'static> crate::pool_view::PoolView for ResolvingPoolView<P> {
1064    async fn status(&self, pool_id: B256) -> Option<PoolStatus> {
1065        loop {
1066            // Fast path: the event fold already knows this pool — no chain call.
1067            if let Some(status) = self.projection.snapshot(pool_id) {
1068                return Some(status);
1069            }
1070            // A pool recently found not-servable, or whose read faulted, is
1071            // suppressed for its reason's window, so a re-request flood cannot
1072            // storm `getPool`.
1073            {
1074                let guard = self
1075                    .negative
1076                    .lock()
1077                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1078                if let Some(reason) = negative_cache_hit(&guard, pool_id) {
1079                    debug!(
1080                        %pool_id,
1081                        ?reason,
1082                        "admit getPool suppressed by the negative cache; refusing"
1083                    );
1084                    return None;
1085                }
1086            }
1087            match claim_read(&self.inflight, pool_id) {
1088                ReadSlot::Lead(read) => {
1089                    let status = self.resolve(pool_id).await;
1090                    read.finish(status);
1091                    return status;
1092                }
1093                // The reader sends its answer before it ends the read. A read
1094                // that ends with no answer was cancelled: re-check, and take
1095                // the read over if no other waiter has.
1096                ReadSlot::Wait(mut done) => {
1097                    if done.changed().await.is_ok() {
1098                        return *done.borrow();
1099                    }
1100                }
1101            }
1102        }
1103    }
1104
1105    async fn cached_status(&self, pool_id: B256) -> Option<PoolStatus> {
1106        // MUST NOT block: read the projection only, never a `getPool`.
1107        self.projection.snapshot(pool_id)
1108    }
1109
1110    async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
1111        // The mid-stream signer cap-headroom re-check: projection ONLY, never a
1112        // `getAuthorization`. A signer that drains its shared `cap` at another node
1113        // shows up here as the settlement watcher folds that node's `PoolRedeemed`.
1114        Some(self.projection.signer_spent(pool_id, signer))
1115    }
1116
1117    async fn signer_authorization(
1118        &self,
1119        pool_id: B256,
1120        signer: Address,
1121    ) -> Option<SignerAuthorization> {
1122        match self.lookup_auth(pool_id, signer) {
1123            CachedAuth::Fresh(auth) => {
1124                self.metrics.serve_signer_auth_cached();
1125                return Some(auth);
1126            }
1127            CachedAuth::Absent => self.metrics.serve_signer_auth_first_read(),
1128            CachedAuth::Lapsed => self.metrics.serve_signer_auth_reread(),
1129        }
1130        loop {
1131            match claim_read(&self.auth_inflight, (pool_id, signer)) {
1132                ReadSlot::Lead(read) => {
1133                    let auth = self.read_authorization(pool_id, signer).await;
1134                    read.finish(auth);
1135                    return auth;
1136                }
1137                // A read that ends with no answer was cancelled: take the
1138                // cache's answer if it landed one, else take the read over.
1139                ReadSlot::Wait(mut done) => {
1140                    if done.changed().await.is_ok() {
1141                        return *done.borrow();
1142                    }
1143                    if let CachedAuth::Fresh(auth) = self.lookup_auth(pool_id, signer) {
1144                        return Some(auth);
1145                    }
1146                }
1147            }
1148        }
1149    }
1150}
1151
1152impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
1153    /// Read `getAuthorization` for `(pool_id, signer)` and cache the answer. A
1154    /// fault (an error or a timeout) caches nothing and answers `None`, which
1155    /// refuses the signer as unconfirmed. A `Registered` read is cached for
1156    /// good, so a fault only ever meets a signer with no usable read. The caller
1157    /// holds the pair's [`ReadSlot::Lead`].
1158    async fn read_authorization(
1159        &self,
1160        pool_id: B256,
1161        signer: Address,
1162    ) -> Option<SignerAuthorization> {
1163        // The baseline is taken before the read is sent; see `AuthRead`.
1164        let folded_at_read = self.projection.signer_spent(pool_id, signer);
1165        // Bounded: a hung read times out here and takes the fault path below.
1166        let auth = match timed(
1167            None,
1168            "admit getAuthorization",
1169            self.contract.getAuthorization(pool_id, signer).call(),
1170        )
1171        .await
1172        {
1173            Ok(auth) => SignerAuthorization::from_onchain(&auth),
1174            Err(err) => {
1175                warn!(
1176                    error = %sanitize_err_chain(&err),
1177                    %pool_id,
1178                    %signer,
1179                    "admit getAuthorization failed; refusing this signer"
1180                );
1181                return None;
1182            }
1183        };
1184        let read = AuthRead {
1185            auth,
1186            folded_at_read,
1187            at: Instant::now(),
1188        };
1189        {
1190            let mut guard = self
1191                .auth_cache
1192                .lock()
1193                .unwrap_or_else(std::sync::PoisonError::into_inner);
1194            remember_auth(&mut guard, (pool_id, signer), read);
1195        }
1196        // Answer as the next cache hit would, so a fold that landed during the
1197        // read already counts. An `Unregistered` read answers as read.
1198        match cached_auth(Some(&read), self.projection.signer_spent(pool_id, signer)) {
1199            CachedAuth::Fresh(fresh) => Some(fresh),
1200            CachedAuth::Lapsed | CachedAuth::Absent => Some(auth),
1201        }
1202    }
1203}
1204
1205/// Why `pool_id` is suppressed, when its negative-cache entry is still inside its
1206/// reason's window — the admit path then skips the `getPool`. `None` for an
1207/// absent or lapsed entry. Provider-free, so the TTL gate is unit-testable.
1208fn negative_cache_hit(
1209    cache: &HashMap<B256, (NegativeReason, Instant)>,
1210    pool_id: B256,
1211) -> Option<NegativeReason> {
1212    cache
1213        .get(&pool_id)
1214        .filter(|(reason, at)| at.elapsed() < reason.ttl())
1215        .map(|(reason, _)| *reason)
1216}
1217
1218/// Remember `pool_id` as recently not-servable or faulted, per `reason`. At the
1219/// cache cap, prune lapsed entries first; if the cache is still full, skip the
1220/// insert, so a flood of distinct ids cannot grow the map past
1221/// [`RESOLVE_NEGATIVE_CACHE_MAX`]. Provider-free, so the cap-prune is
1222/// unit-testable.
1223fn remember_negative(
1224    cache: &mut HashMap<B256, (NegativeReason, Instant)>,
1225    pool_id: B256,
1226    reason: NegativeReason,
1227) {
1228    if cache.len() >= RESOLVE_NEGATIVE_CACHE_MAX {
1229        cache.retain(|_, (reason, at)| at.elapsed() < reason.ttl());
1230    }
1231    if cache.len() < RESOLVE_NEGATIVE_CACHE_MAX || cache.contains_key(&pool_id) {
1232        cache.insert(pool_id, (reason, Instant::now()));
1233    }
1234}
1235
1236/// Read key → a receiver for that key's in-flight admit chain read. The channel
1237/// starts at a seen `V::default()` and carries the read's answer once it ends.
1238type InflightReads<K, V> = Mutex<HashMap<K, watch::Receiver<V>>>;
1239
1240/// A request's place in the admit chain read for one key. At most one request
1241/// per key reads at a time; the rest wait for it.
1242enum ReadSlot<'a, K: Eq + Hash, V> {
1243    /// No read is in flight: this request reads, and the guard ends the read.
1244    Lead(InflightRead<'a, K, V>),
1245    /// A read is in flight: the receiver's `changed` returns `Ok` with the
1246    /// read's answer, or an error when the read ends unanswered.
1247    Wait(watch::Receiver<V>),
1248}
1249
1250/// A key's in-flight read. [`finish`](Self::finish) hands the answer to every
1251/// waiter. Dropping it ends the read: it removes the key's entry, then drops
1252/// the sender, which wakes every waiter. A cancelled reader ends the read with
1253/// no answer, so a waiter then re-checks and takes over the read.
1254struct InflightRead<'a, K: Eq + Hash, V> {
1255    /// The map this read is registered in.
1256    inflight: &'a InflightReads<K, V>,
1257    /// The key being read.
1258    key: K,
1259    /// Carries the answer to the waiters. Dropped after [`Drop::drop`] removes
1260    /// the entry, which closes the channel.
1261    done: watch::Sender<V>,
1262}
1263
1264impl<K: Eq + Hash, V> InflightRead<'_, K, V> {
1265    /// Hand `answer` to every waiter, then end the read. The waiters take it
1266    /// as their own answer, so the read's outcome reaches them whether or not
1267    /// a cache had room to record it.
1268    fn finish(self, answer: V) {
1269        self.done.send_replace(answer);
1270    }
1271}
1272
1273impl<K: Eq + Hash, V> Drop for InflightRead<'_, K, V> {
1274    fn drop(&mut self) {
1275        self.inflight
1276            .lock()
1277            .unwrap_or_else(std::sync::PoisonError::into_inner)
1278            .remove(&self.key);
1279    }
1280}
1281
1282/// Join the in-flight read for `key`, or start one.
1283fn claim_read<K: Eq + Hash + Copy, V: Default>(
1284    inflight: &InflightReads<K, V>,
1285    key: K,
1286) -> ReadSlot<'_, K, V> {
1287    let mut guard = inflight
1288        .lock()
1289        .unwrap_or_else(std::sync::PoisonError::into_inner);
1290    // A closed channel means its read already ended: start a new one rather
1291    // than wait on it, which would return at once and spin the caller's loop.
1292    if let Some(done) = guard.get(&key)
1293        && done.has_changed().is_ok()
1294    {
1295        return ReadSlot::Wait(done.clone());
1296    }
1297    let (done, waiters) = watch::channel(V::default());
1298    guard.insert(key, waiters);
1299    ReadSlot::Lead(InflightRead {
1300        inflight,
1301        key,
1302        done,
1303    })
1304}
1305
1306/// The serve-path serve status a resolved `getPool` snapshot maps to, or `None`
1307/// when the pool is not worth seeding: a zero owner (the pool does not exist, or
1308/// a reorg unwound it) or a `Closed` (reclaimed / terminal) pool. Both leave the
1309/// serve gate fail-open `None` rather than register a lane against a pool that
1310/// can no longer be redeemed. Pure, so the mapping is unit-testable without a
1311/// provider.
1312fn resolved_lifecycle(pool: &PaymentPool::Pool) -> Option<Lifecycle> {
1313    if pool.owner == Address::ZERO {
1314        return None;
1315    }
1316    match pool.status {
1317        PaymentPool::Status::Open => Some(Lifecycle::Open),
1318        PaymentPool::Status::Closing => Some(Lifecycle::Closing {
1319            deadline: pool.disputeDeadline,
1320        }),
1321        _ => None,
1322    }
1323}
1324
1325/// Redemption task: chunk lanes' accrued claims and `redeemMany` a chunk once
1326/// its aggregate unredeemed value clears the floor, driven by three sources —
1327/// advisory hints ([`LaneKey`]) from the voucher-accept path, a low-frequency
1328/// self-tick that sweeps every lane into chunked `redeemMany` transactions so a
1329/// dropped hint can never strand a lane whose chunk has cleared the floor, and a
1330/// sweep at the earliest serve cutoff of a lane with a finite capability expiry
1331/// and value owed ([`serve_cutoff_wake`]). Ends cleanly when every hint sender
1332/// is dropped.
1333///
1334/// The cutoff sweep is what makes a delegated lane's final claim redeemable. The
1335/// claim stops growing at the serve cutoff, and the sweep then has about one
1336/// redeem interval of room before [`capability_expired`] skips the lane. A
1337/// self-tick alone leaves no room for delay: the last tick before the expiry can
1338/// fall just before the landing slack, and the loop is serial, so a receipt wait
1339/// that delays that tick forfeits the lane. The cutoff sweep waits behind an
1340/// in-flight redemption too, but the interval absorbs that wait.
1341/// `margin_secs` is the voucher path's capability-expiry margin.
1342///
1343/// A lane whose hint-path redemption hits a chain fault (an RPC fault or a
1344/// revert) is parked: each later hint for it is dropped and counted into
1345/// `redeem_hints_parked`. Each tick or cutoff sweep retries the parked lanes
1346/// with every other lane. Only a sweep that runs and reports no chain fault
1347/// clears the parked set. A failed lane load runs no sweep and keeps the set,
1348/// and a sweep with any chain fault keeps the whole set. So during an RPC
1349/// outage each lane costs one failed attempt per interval: the sweep's batched
1350/// retry. Hints for a parked lane stay dropped until a sweep finishes without a
1351/// chain fault. Parking delays only the hint path: the sweep retries every lane
1352/// each interval, and a serve cutoff still wakes a sweep, so no claim strands.
1353#[allow(clippy::too_many_arguments)]
1354async fn redeemer_loop<P: Provider + Clone>(
1355    contract: PaymentPool::PaymentPoolInstance<P>,
1356    store: Arc<dyn PoolStateStore>,
1357    paid: PaidWatermarks,
1358    self_address: Address,
1359    redeem_threshold: U256,
1360    max_vouchers: usize,
1361    redeem_interval: Duration,
1362    margin_secs: u64,
1363    mut redeem_rx: mpsc::Receiver<LaneKey>,
1364    metrics: Arc<Metrics>,
1365    pool_view: PoolProjection,
1366) {
1367    let mut ticker = tokio::time::interval(redeem_interval);
1368    // Skip the immediate first tick: new vouchers hint anyway, and the first
1369    // sweep waits one interval. A lane persisted before a restart whose claim
1370    // cannot wait that long gets its cutoff sweep instead: no scan has run yet,
1371    // so a cutoff that passed while the node was down is due at once.
1372    ticker.tick().await;
1373    let mut swept_at = 0;
1374    let mut parked: HashSet<LaneKey> = HashSet::new();
1375    let mut next_cutoff = load_lanes(store.as_ref()).and_then(|states| {
1376        next_serve_cutoff(
1377            &states,
1378            &paid,
1379            self_address,
1380            margin_secs,
1381            swept_at,
1382            unix_now(),
1383        )
1384    });
1385    loop {
1386        tokio::select! {
1387            hint = redeem_rx.recv() => match hint {
1388                Some(key) => {
1389                    if parked.contains(&key) {
1390                        metrics.redeem_hint_parked();
1391                        continue;
1392                    }
1393                    let outcome = redeem_one(
1394                        &contract, &store, &paid, self_address, margin_secs, swept_at,
1395                        redeem_threshold, max_vouchers, key, &metrics, &pool_view,
1396                    )
1397                    .await;
1398                    if outcome.chain_fault {
1399                        park_lane(&mut parked, key);
1400                    }
1401                    next_cutoff = next_cutoff.into_iter().chain(outcome.wake).min();
1402                    continue;
1403                }
1404                // All hint senders dropped — the service is going away.
1405                None => break,
1406            },
1407            _ = ticker.tick() => {}
1408            () = sleep_until_unix(next_cutoff) => {}
1409        }
1410        // A tick or a cutoff: sweep every lane, and schedule the next cutoff
1411        // from the same scan. A failed load keeps every cutoff still ahead and
1412        // drops one that has passed, so the loop does not re-fire it at once;
1413        // the next tick retries the scan. The sweep retries the parked lanes
1414        // with every other lane. A failed load runs no sweep, so the parked set
1415        // stays.
1416        let Some(states) = load_lanes(store.as_ref()) else {
1417            next_cutoff = next_cutoff.filter(|wake| *wake > unix_now());
1418            continue;
1419        };
1420        swept_at = unix_now();
1421        next_cutoff = next_serve_cutoff(
1422            &states,
1423            &paid,
1424            self_address,
1425            margin_secs,
1426            swept_at,
1427            swept_at,
1428        );
1429        let chain_fault = redeem_sweep(
1430            &contract,
1431            &store,
1432            states,
1433            &paid,
1434            self_address,
1435            redeem_threshold,
1436            max_vouchers,
1437            true,
1438            &metrics,
1439            &pool_view,
1440        )
1441        .await;
1442        // Release the parked lanes only after a sweep without a chain fault. A
1443        // sweep with a fault keeps the whole set: one failed attempt per lane
1444        // per interval, the sweep's own batched retry.
1445        if !chain_fault {
1446            parked.clear();
1447        }
1448    }
1449    debug!("PaymentPool redeemer loop ended (all hint senders dropped)");
1450}
1451
1452/// One lane planned for redemption: its pool, its persistence key (so a landed
1453/// registration can be written back to `registered_until`), its full claim value
1454/// and its unredeemed value (for the per-chunk floor), the highest voucher to
1455/// submit, and — on the signer's first redemption — the owner-signed capability
1456/// to register.
1457struct PlannedLane {
1458    pool_id: PoolId,
1459    key: LaneKey,
1460    /// The claim's full on-chain-comparable value (`amount + verified frontier`).
1461    /// The contract pays `owed − w.amount`, so the pre-submit reconciliation
1462    /// ([`reconcile_onchain_watermarks`]) drops a lane whose on-chain watermark
1463    /// already reaches this, and recomputes `unredeemed` from the fresh read.
1464    owed: U256,
1465    unredeemed: U256,
1466    voucher: PaymentPool::LaneVoucher,
1467    register: Option<PaymentPool::CapabilityReg>,
1468}
1469
1470/// Sum the unredeemed value across planned lanes — the raw USDC the node holds
1471/// in accepted vouchers it has not yet cashed on-chain. Saturating so a
1472/// pathological total can never wrap; in practice pool deposits bound it far
1473/// below `U256::MAX`. Feeds the `decdn_unredeemed_usdc` gauge once per sweep.
1474fn sum_unredeemed(plans: &[PlannedLane]) -> U256 {
1475    plans
1476        .iter()
1477        .fold(U256::ZERO, |acc, plan| acc.saturating_add(plan.unredeemed))
1478}
1479
1480/// Sum the value still recoverable from the distinct pools behind `plans` —
1481/// `Σ (deposit − totalRedeemed)`, the ceiling those pools can still pay this
1482/// node. Feeds the `decdn_pool_deposit_usdc` gauge once per sweep.
1483///
1484/// `remaining`, not the raw deposit: already-redeemed funds have left the pool
1485/// and are not recoverable from it, so counting them would overstate what the
1486/// node can still collect. A pool the projection has not folded contributes
1487/// nothing rather than a guess — the same fail-quiet direction the planner
1488/// takes on an unknown pool.
1489///
1490/// Scoped to planned lanes, which is what bounds it to this node's own
1491/// counterparties: the projection also folds `PoolOpened` for pools this node
1492/// has no lane on, and summing those would report the network's escrow as its
1493/// own. The precise reading is therefore "pools that currently owe this node",
1494/// not every pool it has ever been paid by — a lane settles out of the planned
1495/// set once it is fully redeemed. The caller skips the publish on an empty plan
1496/// set so that settling out does not read as the escrow disappearing.
1497///
1498/// A pool the projection has not folded contributes nothing rather than a
1499/// guess, so the gauge under-reports in the window after a restart where the
1500/// planner has deliberately failed open on an unknown pool.
1501fn sum_recoverable_deposit(plans: &[PlannedLane], pool_view: &PoolProjection) -> U256 {
1502    let mut seen: HashSet<PoolId> = HashSet::new();
1503    plans
1504        .iter()
1505        .filter(|plan| seen.insert(plan.pool_id))
1506        .filter_map(|plan| pool_view.snapshot(plan.pool_id))
1507        .fold(U256::ZERO, |acc, status| {
1508            acc.saturating_add(status.remaining)
1509        })
1510}
1511
1512/// Group planned lanes into one `PoolBatch` per distinct pool, in first-seen
1513/// order, so the contract amortizes each pool's status read and `totalRedeemed`
1514/// write across its lanes. A lane's capability registration (present only on a
1515/// signer's first redemption) rides in its pool's batch; each `(pool, signer)`
1516/// lane is distinct, so no capability is ever duplicated within a batch.
1517fn group_by_pool(lanes: &[PlannedLane]) -> Vec<PaymentPool::PoolBatch> {
1518    let mut order: Vec<PoolId> = Vec::new();
1519    let mut by_pool: HashMap<PoolId, PaymentPool::PoolBatch> = HashMap::new();
1520    for lane in lanes {
1521        let batch = by_pool.entry(lane.pool_id).or_insert_with(|| {
1522            order.push(lane.pool_id);
1523            PaymentPool::PoolBatch {
1524                poolId: lane.pool_id,
1525                capabilities: Vec::new(),
1526                vouchers: Vec::new(),
1527            }
1528        });
1529        if let Some(reg) = &lane.register {
1530            batch.capabilities.push(reg.clone());
1531        }
1532        batch.vouchers.push(lane.voucher.clone());
1533    }
1534    order
1535        .into_iter()
1536        .filter_map(|pool_id| by_pool.remove(&pool_id))
1537        .collect()
1538}
1539
1540/// Partition planned lanes into gas-bounded redemption chunks (each an
1541/// independent `redeemMany`). Every returned chunk has at most `max_vouchers`
1542/// lanes and an aggregate unredeemed value `>= floor`; a chunk that cannot clear
1543/// the floor is dropped and its lanes defer to a later sweep (a zero floor
1544/// keeps every chunk). The chunk count is the minimum that
1545/// respects `max_vouchers`, and high-value lanes are dealt round-robin across the
1546/// chunks so dust rides alongside real value instead of segregating into a
1547/// below-floor chunk.
1548fn chunk_redemptions(
1549    mut plans: Vec<PlannedLane>,
1550    floor: U256,
1551    max_vouchers: usize,
1552) -> Vec<Vec<PlannedLane>> {
1553    if plans.is_empty() {
1554        return Vec::new();
1555    }
1556    let cap = max_vouchers.max(1);
1557    let k = plans.len().div_ceil(cap);
1558    // Sort by unredeemed descending so round-robin dealing balances value across
1559    // chunks (largest lanes land in distinct buckets first).
1560    plans.sort_by_key(|plan| Reverse(plan.unredeemed));
1561    let mut buckets: Vec<Vec<PlannedLane>> = (0..k).map(|_| Vec::new()).collect();
1562    for (i, plan) in plans.into_iter().enumerate() {
1563        if let Some(bucket) = buckets.get_mut(i % k) {
1564            bucket.push(plan);
1565        }
1566    }
1567    buckets
1568        .into_iter()
1569        .filter(|bucket| {
1570            let sum = bucket
1571                .iter()
1572                .map(|l| l.unredeemed)
1573                .fold(U256::ZERO, |a, b| a + b);
1574            sum >= floor
1575        })
1576        .collect()
1577}
1578
1579/// Redemption policy: whether a lane whose pool has this `status` is worth
1580/// submitting now. Fails OPEN on an unknown pool (`None`) — a projection gap
1581/// (cold start, reorg, an unfolded event) must never hold a lane and strand real
1582/// money; only a positive zero-`remaining` holds. A drained `Open` pool is held
1583/// (a top-up re-drives it); a drained `Closing` pool is dropped (it cannot be
1584/// topped up, so `remaining == 0` is irreversible); a funded `Closing` pool is
1585/// redeemable only while its deadline lies more than [`REDEEM_LANDING_SLACK_SECS`]
1586/// ahead. Past the deadline `redeemMany` reverts `PoolClosed`, and that revert
1587/// takes the whole batch down with it.
1588fn pool_is_redeemable(status: Option<PoolStatus>, now: u64) -> bool {
1589    match status {
1590        None => true,
1591        Some(s) => {
1592            if s.remaining.is_zero() {
1593                return false;
1594            }
1595            match s.lifecycle {
1596                Lifecycle::Open => true,
1597                Lifecycle::Closing { deadline } => {
1598                    now.saturating_add(REDEEM_LANDING_SLACK_SECS) < deadline
1599                }
1600            }
1601        }
1602    }
1603}
1604
1605/// Split candidate lanes into the ones whose pool can pay now and a count of the
1606/// ones held/dropped by [`pool_is_redeemable`]. A pool absent from `snapshot` is
1607/// `None` (fail open).
1608fn partition_redeemable(
1609    states: Vec<LaneState>,
1610    snapshot: &HashMap<PoolId, Option<PoolStatus>>,
1611    now: u64,
1612) -> (Vec<LaneState>, usize) {
1613    let mut kept = Vec::with_capacity(states.len());
1614    let mut skipped = 0usize;
1615    for st in states {
1616        let pool_status = snapshot.get(&st.pool_id).copied().flatten();
1617        if pool_is_redeemable(pool_status, now) {
1618            kept.push(st);
1619        } else {
1620            skipped += 1;
1621        }
1622    }
1623    (kept, skipped)
1624}
1625
1626/// Whether a capability `expiry` has passed, or falls within
1627/// [`REDEEM_LANDING_SLACK_SECS`] of `now`, so a redemption planned now would land
1628/// on an expired capability and pay 0. `0` means no tracked expiry and never
1629/// expires.
1630const fn capability_expired(expiry: u64, now: u64) -> bool {
1631    expiry != 0 && now.saturating_add(REDEEM_LANDING_SLACK_SECS) >= expiry
1632}
1633
1634/// When the redeemer wakes for one lane's serve cutoff, in Unix seconds, or
1635/// `None` when the lane needs no cutoff sweep.
1636///
1637/// The serve cutoff is `expiry − margin_secs`. From that second on, the voucher
1638/// and preimage paths refuse the lane's proofs with `CapabilityExpired`
1639/// ([`inside_capability_expiry_margin`](decdn_common::config::inside_capability_expiry_margin)),
1640/// so the lane's claim is final. The wake
1641/// is one second after the cutoff, because those paths read a coarse clock that
1642/// can lag the wall clock by up to one refresh. A sweep at the wake has one
1643/// redeem interval, less that second, to start the redemption before
1644/// [`capability_expired`] skips the lane, and [`REDEEM_LANDING_SLACK_SECS`] to
1645/// land it.
1646///
1647/// Only a lane this node provides, with a finite expiry and value owed beyond its
1648/// cached paid watermark, needs a wake.
1649fn lane_cutoff_wake(
1650    st: &LaneState,
1651    paid: &PaidWatermarks,
1652    self_address: Address,
1653    margin_secs: u64,
1654) -> Option<u64> {
1655    if st.provider != self_address || st.expiry == 0 || st.owed() <= paid.get(&st.key()) {
1656        return None;
1657    }
1658    Some(st.expiry.saturating_sub(margin_secs).saturating_add(1))
1659}
1660
1661/// When the redeemer must sweep for one lane's cutoff, given that its last
1662/// sweep scanned the lane store at `swept_at` (`0` before the first scan).
1663///
1664/// A scan at or after the lane's [`lane_cutoff_wake`] already read the final
1665/// claim, so the lane needs no further cutoff sweep. Otherwise the sweep is due
1666/// at the wake, or at `now` when the wake has passed unscanned: the node was
1667/// down at the cutoff, or the lane's first hint waited behind redemption work
1668/// until after it. A lane that [`capability_expired`] skips at `now` cannot be
1669/// redeemed and needs no sweep.
1670fn serve_cutoff_wake(
1671    st: &LaneState,
1672    paid: &PaidWatermarks,
1673    self_address: Address,
1674    margin_secs: u64,
1675    swept_at: u64,
1676    now: u64,
1677) -> Option<u64> {
1678    let wake = lane_cutoff_wake(st, paid, self_address, margin_secs)?;
1679    (wake > swept_at && !capability_expired(st.expiry, now)).then_some(wake.max(now))
1680}
1681
1682/// The earliest [`serve_cutoff_wake`] across `states`.
1683fn next_serve_cutoff(
1684    states: &[LaneState],
1685    paid: &PaidWatermarks,
1686    self_address: Address,
1687    margin_secs: u64,
1688    swept_at: u64,
1689    now: u64,
1690) -> Option<u64> {
1691    states
1692        .iter()
1693        .filter_map(|st| serve_cutoff_wake(st, paid, self_address, margin_secs, swept_at, now))
1694        .min()
1695}
1696
1697/// Sleep until the Unix second `wake`, or forever when there is none.
1698async fn sleep_until_unix(wake: Option<u64>) {
1699    match wake {
1700        Some(wake) => {
1701            let wait = Duration::from_secs(wake.saturating_sub(unix_now()));
1702            tokio::time::sleep(wait).await;
1703        }
1704        None => std::future::pending().await,
1705    }
1706}
1707
1708/// Whether a lane's observed registration is still live at `now`. `0`
1709/// (unknown/unregistered) and any past expiry are "not registered" — the safe
1710/// direction is to re-read rather than skip a possibly-absent registration.
1711const fn is_registered(registered_until: u64, now: u64) -> bool {
1712    registered_until > now
1713}
1714
1715/// Whether this node has confirmed a lane's signer is registered on-chain,
1716/// resolved from the persisted `registered_until` watermark alone — no chain read.
1717enum RegistrationStatus {
1718    /// `registered_until > now`: this node has landed the signer's registration;
1719    /// attach no `CapabilityReg`.
1720    Registered,
1721    /// Not yet confirmed registered by this node: attach a `CapabilityReg` built
1722    /// from the lane's held `owner_sig`. On-chain registration is idempotent (a
1723    /// duplicate or already-registered signer is a no-op), so this is safe even
1724    /// when another provider already registered the signer; the first landed
1725    /// redemption persists `registered_until` and every later one skips the reg.
1726    Unregistered,
1727}
1728
1729/// Seed a fresh paid-watermark cache from the durable lane rows this node
1730/// provides. Bootstrap calls this before the redeemer starts so a restart carries
1731/// forward redemptions that landed before the log-poller's persisted cursor,
1732/// instead of re-planning those already-redeemed lanes into a `redeemMany` the
1733/// contract silently no-ops (#2052).
1734///
1735/// A `load_all` failure is not fatal: the `PoolRedeemed` watcher still rebuilds
1736/// `paid` forward from the cursor. It only re-opens the restart-amnesia window for
1737/// pre-cursor lanes, so the failure is surfaced loudly rather than aborting
1738/// bring-up.
1739fn rehydrate_paid_watermarks(store: &dyn PoolStateStore, self_address: Address) -> PaidWatermarks {
1740    let paid = PaidWatermarks::default();
1741    match store.load_all() {
1742        Ok(states) => {
1743            let mut restored = 0usize;
1744            for st in &states {
1745                if st.provider == self_address && !st.paid_cumulative.is_zero() {
1746                    paid.set(st.key(), st.paid_cumulative);
1747                    restored += 1;
1748                }
1749            }
1750            if restored > 0 {
1751                info!(
1752                    restored,
1753                    "rehydrated paid-watermark cache from durable lane store"
1754                );
1755            }
1756        }
1757        Err(err) => warn!(
1758            error = %err,
1759            "failed to rehydrate paid-watermark cache from lane store; pre-cursor \
1760             redemptions may be re-submitted until re-observed on-chain"
1761        ),
1762    }
1763    paid
1764}
1765
1766/// Plan one lane for redemption from its already-loaded state and a resolved
1767/// registration status. Pure and I/O-free — the caller ([`plan_lanes`]) resolves
1768/// the status from the persisted `registered_until` watermark alone, with no chain
1769/// read. Returns `Ok(None)` when the lane is not this node's, has no signed
1770/// voucher, has nothing unredeemed, or — a node-durability fault that should not
1771/// occur — is an unregistered signer whose lane has lost its `owner_sig`.
1772fn plan_lane(
1773    st: &LaneState,
1774    paid: &PaidWatermarks,
1775    self_address: Address,
1776    status: &RegistrationStatus,
1777) -> Result<Option<PlannedLane>> {
1778    if st.provider != self_address {
1779        return Ok(None);
1780    }
1781    let key = st.key();
1782    // The lane's claim: its latest signature PLUS the frontier the chain has
1783    // proved on top of it.
1784    //
1785    // That sum is a real choice rather than a formality. A lane's worth is
1786    // `amount + verified_index × chunk_price`, so counting only the signed
1787    // cumulative would understate it by up to one whole chain — a lane sitting
1788    // on 200 unredeemed reveals and no fresh signature would look worthless to
1789    // the redemption floor and never be swept.
1790    //
1791    // There is only ever one claim to weigh. A rollover that folded less than
1792    // the frontier it retires is refused outright (ADR 003 §Rollover), so the
1793    // lane never holds a retired voucher worth more than its live one.
1794    //
1795    // In the cooperative case the strongest claim is always a signed voucher at
1796    // index 0 — every rollover and every close emits one whose `amount` already
1797    // folds the chain it retires — so a finalized delivery submits a zero index,
1798    // a zero preimage, and walks nothing on-chain.
1799    let Some(claim) = st.live_claim() else {
1800        return Ok(None);
1801    };
1802    let owed = claim.value();
1803    if owed.is_zero() {
1804        return Ok(None);
1805    }
1806    let unredeemed = owed.saturating_sub(paid.get(&key));
1807    if unredeemed.is_zero() {
1808        return Ok(None);
1809    }
1810
1811    let register = match status {
1812        RegistrationStatus::Registered => None,
1813        RegistrationStatus::Unregistered => {
1814            let Some(owner_sig) = st.owner_sig else {
1815                // `owner_sig` is written in the SAME fsynced row as the frontier
1816                // (#1906), so a crash loses the value and its `owner_sig` together —
1817                // a lane with redeemable value always carries the material to
1818                // register its signer. Reaching here means the durable row kept the
1819                // value but lost the `owner_sig`, a node-side durability fault. The
1820                // client already sent its capability once; it is not asked to
1821                // re-send. Skip the lane and surface the invariant break.
1822                error!(
1823                    pool_id = %key.pool_id,
1824                    signer = %key.signer,
1825                    "BUG: redeemable lane has no owner_sig, so its signer cannot be \
1826                     registered; skipping (node durability fault, not a client re-send)"
1827                );
1828                return Ok(None);
1829            };
1830            // Every field of the registration payload comes off the lane record
1831            // itself — the same durable row as the frontier being redeemed (#1906).
1832            // On-chain registration is idempotent, so attaching this when the signer
1833            // is already registered elsewhere is a harmless no-op; the first landed
1834            // redemption persists `registered_until` and every later one omits it.
1835            // `cap` recovers the `u64` spending cap the intake path zero-extended
1836            // into the lane's `U256`.
1837            Some(PaymentPool::CapabilityReg {
1838                signer: key.signer,
1839                spendingCap: to_pool_u64(st.cap, "capability spending cap")?,
1840                expiry: st.expiry,
1841                ownerSig: Bytes::from(owner_sig.to_vec()),
1842            })
1843        }
1844    };
1845
1846    let (r, vs) = compact_voucher_signature(&claim.signature)
1847        .context("compact the lane's voucher signature")?;
1848    let voucher = PaymentPool::LaneVoucher {
1849        signer: key.signer,
1850        // The claim's SIGNED anchor, not its extended value: the contract
1851        // rebuilds the EIP-712 digest from these, then re-derives the extension
1852        // itself from the chain fields below. Submitting the extended value here
1853        // would recover the wrong signer.
1854        cumulative: to_pool_u64(claim.amount, "voucher cumulative")?,
1855        bytesDelivered: to_pool_u64(claim.bytes_delivered, "voucher bytes delivered")?,
1856        r,
1857        vs,
1858        chainRoot: claim.chain.chain_root,
1859        // The deepest preimage this node has verified. At index 0 the root is
1860        // its own preimage, so a settlement voucher needs no chain state at all.
1861        preimage: claim.chain.tip,
1862        chainMeter: claim
1863            .chain_meter()
1864            .context("pack the lane's chainMeter word")?,
1865    };
1866    Ok(Some(PlannedLane {
1867        pool_id: key.pool_id,
1868        key,
1869        owed,
1870        unredeemed,
1871        voucher,
1872        register,
1873    }))
1874}
1875
1876/// Plan a set of candidate lanes for redemption. The only on-chain-derived gate
1877/// is pool solvency (the event-fed [`PoolProjection`], read below); a lane's
1878/// signer-registration status is resolved locally from its persisted
1879/// `registered_until` watermark — no `getAuthorization` read. A lane not yet
1880/// confirmed registered attaches a `CapabilityReg` from its held `owner_sig`
1881/// ([`plan_lane`]); on-chain registration is idempotent, so this is safe even for
1882/// a signer another provider already registered, and the first landed redemption
1883/// persists `registered_until` so later passes skip the reg.
1884///
1885/// A lane with value owed whose capability expires within
1886/// [`REDEEM_LANDING_SLACK_SECS`] of `now` is skipped and metered: the contract
1887/// pays 0 for an expired capability, so its redemption would only spend gas. A
1888/// lane with nothing owed is dropped silently, expired or not.
1889#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
1890fn plan_lanes(
1891    paid: &PaidWatermarks,
1892    self_address: Address,
1893    states: Vec<LaneState>,
1894    metrics: &Arc<Metrics>,
1895    pool_view: &PoolProjection,
1896    now: u64,
1897) -> Vec<PlannedLane> {
1898    // The redeemer's only on-chain-derived gate: hold/drop lanes whose pool cannot
1899    // pay, read from the event-fed projection. Fail open on an unknown pool. A
1900    // lane's signer-registration status is resolved locally below — no chain read.
1901    let mut snapshot: HashMap<PoolId, Option<PoolStatus>> = HashMap::new();
1902    for st in &states {
1903        snapshot
1904            .entry(st.pool_id)
1905            .or_insert_with(|| pool_view.snapshot(st.pool_id));
1906    }
1907    let (states, skipped) = partition_redeemable(states, &snapshot, now);
1908    if skipped > 0 {
1909        metrics.redemption_skipped_insolvent_by(skipped as u64);
1910    }
1911    let mut plans: Vec<PlannedLane> = Vec::new();
1912    for st in &states {
1913        if st.provider != self_address {
1914            continue;
1915        }
1916        // Registered iff this node has already landed the signer's registration
1917        // (persisted `registered_until` still live); otherwise attach a
1918        // `CapabilityReg` from the held `owner_sig` ([`plan_lane`]).
1919        let reg_status = if is_registered(st.registered_until, now) {
1920            RegistrationStatus::Registered
1921        } else {
1922            RegistrationStatus::Unregistered
1923        };
1924        match plan_lane(st, paid, self_address, &reg_status) {
1925            // The expiry check follows `plan_lane`, which drops a lane with
1926            // nothing unredeemed. A fully redeemed lane stays in the store until
1927            // its pool is reclaimed, so only a skip that strands value is metered.
1928            Ok(Some(_)) if capability_expired(st.expiry, now) => {
1929                metrics.redemption_skipped_expired();
1930                debug!(
1931                    pool_id = %st.pool_id,
1932                    signer = %st.signer,
1933                    expiry = st.expiry,
1934                    "redeemer: skipping a lane with value owed whose capability has expired"
1935                );
1936            }
1937            Ok(Some(lane)) => plans.push(lane),
1938            Ok(None) => {}
1939            Err(err) => {
1940                metrics.redemption_failure();
1941                warn!(error = %sanitize_err_chain(&err), pool_id = %st.pool_id, "redemption planning failed");
1942            }
1943        }
1944    }
1945    plans
1946}
1947
1948/// Park `key` after a chain fault on its hint path, until a sweep finishes
1949/// without a chain fault ([`redeemer_loop`]).
1950fn park_lane(parked: &mut HashSet<LaneKey>, key: LaneKey) {
1951    debug!(
1952        pool_id = %key.pool_id,
1953        signer = %key.signer,
1954        provider = %key.provider,
1955        "lane parked after a chain fault until a sweep finishes without one"
1956    );
1957    parked.insert(key);
1958}
1959
1960/// What one hint-path redemption tells [`redeemer_loop`].
1961#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1962struct HintOutcome {
1963    /// The lane's [`serve_cutoff_wake`], if it has one.
1964    wake: Option<u64>,
1965    /// The redemption hit a chain fault: a failed watermark read or a
1966    /// `redeemMany` that did not land. The loop parks the lane until the next
1967    /// sweep.
1968    chain_fault: bool,
1969}
1970
1971/// Hint-path redemption: plan one lane and, if it clears the per-chunk `floor`,
1972/// submit it as a one-lane `redeemMany`. A sub-floor hint defers to the next
1973/// sweep, which packs it with other lanes, and reads nothing from the chain.
1974/// Returns the lane's [`serve_cutoff_wake`] against the last sweep scan at
1975/// `swept_at`, so a lane first seen between sweeps gets its cutoff sweep, even
1976/// when the hint waited behind redemption work until after the cutoff. Also
1977/// returns whether the redemption hit a chain fault. A lane the store cannot
1978/// load is a local fault, not a chain fault.
1979#[allow(clippy::too_many_arguments)]
1980async fn redeem_one<P: Provider + Clone>(
1981    contract: &PaymentPool::PaymentPoolInstance<P>,
1982    store: &Arc<dyn PoolStateStore>,
1983    paid: &PaidWatermarks,
1984    self_address: Address,
1985    margin_secs: u64,
1986    swept_at: u64,
1987    floor: U256,
1988    max_vouchers: usize,
1989    key: LaneKey,
1990    metrics: &Arc<Metrics>,
1991    pool_view: &PoolProjection,
1992) -> HintOutcome {
1993    let st = match store.get(key) {
1994        Ok(Some(st)) => st,
1995        Ok(None) => return HintOutcome::default(),
1996        Err(err) => {
1997            metrics.redemption_failure();
1998            warn!(error = %err, pool_id = %key.pool_id, "redemption planning failed to load lane");
1999            return HintOutcome::default();
2000        }
2001    };
2002    let now = unix_now();
2003    let wake = serve_cutoff_wake(&st, paid, self_address, margin_secs, swept_at, now);
2004    let plans = plan_lanes(paid, self_address, vec![st], metrics, pool_view, now);
2005    // Hint path: require the durability floor and skip the submit on a failed
2006    // flush (`strict_flush`); the lane defers to the next sweep.
2007    let chain_fault =
2008        redeem_planned_lanes(contract, store, plans, floor, max_vouchers, true, metrics).await;
2009    HintOutcome { wake, chain_fault }
2010}
2011
2012/// Load every persisted lane for a sweep or for cutoff scheduling. A load
2013/// failure is logged and returns `None`.
2014fn load_lanes(store: &dyn PoolStateStore) -> Option<Vec<LaneState>> {
2015    store
2016        .load_all()
2017        .inspect_err(|err| warn!(error = %err, "redeemer sweep: failed to load lane state"))
2018        .ok()
2019}
2020
2021/// Sweep: plan every lane in `states` (per-lane error isolation through the
2022/// planning phase), then chunk the survivors under the per-chunk floor +
2023/// voucher-count cap and submit each chunk as its own `redeemMany`. Value is
2024/// spread across chunks so dust settles alongside real value; a chunk that
2025/// cannot clear the floor defers to a later sweep.
2026///
2027/// Returns whether the sweep hit a chain fault, as [`redeem_planned_lanes`]
2028/// reports it. The periodic sweep in [`redeemer_loop`] releases the parked
2029/// lanes only when it returns `false`.
2030#[allow(clippy::too_many_arguments)]
2031async fn redeem_sweep<P: Provider + Clone>(
2032    contract: &PaymentPool::PaymentPoolInstance<P>,
2033    store: &Arc<dyn PoolStateStore>,
2034    states: Vec<LaneState>,
2035    paid: &PaidWatermarks,
2036    self_address: Address,
2037    floor: U256,
2038    max_vouchers: usize,
2039    strict_flush: bool,
2040    metrics: &Arc<Metrics>,
2041    pool_view: &PoolProjection,
2042) -> bool {
2043    let plans = plan_lanes(paid, self_address, states, metrics, pool_view, unix_now());
2044    // Publish the pending-redemption total once per sweep. This is the whole
2045    // planned set, before the per-chunk floor defers any dust — a lane below the
2046    // floor is still owed and rides a later sweep, so counting it here is what
2047    // makes the gauge "USDC waiting to be redeemed" rather than "redeeming now".
2048    metrics.set_unredeemed_usdc(sum_unredeemed(&plans));
2049    // Only when there is something to measure. An empty plan set is the HEALTHY
2050    // steady state — everything owed has been redeemed — and publishing zero for
2051    // it would sawtooth the gauge to 0 after every successful sweep, which reads
2052    // exactly like insolvent counterparties. Leaving the last value standing is
2053    // the same discipline the buyer-wallet read takes.
2054    if !plans.is_empty() {
2055        metrics.set_pool_deposit_usdc(sum_recoverable_deposit(&plans, pool_view));
2056    }
2057    // A sweep is the retry for every lane, so it parks nothing itself. The
2058    // caller decides from the chain-fault flag whether parked lanes go free.
2059    redeem_planned_lanes(
2060        contract,
2061        store,
2062        plans,
2063        floor,
2064        max_vouchers,
2065        strict_flush,
2066        metrics,
2067    )
2068    .await
2069}
2070
2071/// Submit one chunk of planned lanes as a single `redeemMany`. On an oversize
2072/// send failure (`is_oversize_send_err`) with more than one lane, halve the chunk
2073/// and retry each half, bounded by `MAX_SPLIT_DEPTH`. A revert or a non-oversize
2074/// send error records one `redemption_failure`. An unconfirmed receipt (a failed
2075/// or lapsed wait that the by-hash fetch in `send_and_await_receipt` could not
2076/// resolve) is not a failure: the tx may have mined, and it counts only into its
2077/// `onchain_tx_*` bucket. Every non-landed outcome leaves the claims for the next
2078/// sweep (cumulative, monotone, retry-safe). Does NOT seed the paid cache — each
2079/// paid voucher emits its own `PoolRedeemed`.
2080///
2081/// Returns whether the chunk hit a chain fault: any outcome other than
2082/// `Landed`, the unconfirmed ones included, in the chunk itself or in either
2083/// half of a split. The hint path parks the lane on a fault ([`redeemer_loop`]).
2084///
2085/// Runs inside an `onchain_tx` span that records the transaction hash (`tx`)
2086/// and the `outcome`. A halved retry nests its two halves as child spans.
2087#[allow(clippy::cognitive_complexity)]
2088#[tracing::instrument(
2089    name = "onchain_tx",
2090    skip_all,
2091    fields(
2092        op = "redeemMany",
2093        depth = depth,
2094        voucher_count = lanes.len(),
2095        tx = tracing::field::Empty,
2096        outcome = tracing::field::Empty,
2097        otel.status_code = tracing::field::Empty,
2098    )
2099)]
2100async fn submit_chunk<P: Provider + Clone>(
2101    contract: &PaymentPool::PaymentPoolInstance<P>,
2102    store: &Arc<dyn PoolStateStore>,
2103    mut lanes: Vec<PlannedLane>,
2104    metrics: &Arc<Metrics>,
2105    depth: u32,
2106) -> bool {
2107    if lanes.is_empty() {
2108        return false;
2109    }
2110    let span = tracing::Span::current();
2111    let batches = group_by_pool(&lanes);
2112    let cap_count: usize = batches.iter().map(|b| b.capabilities.len()).sum();
2113    let voucher_count = lanes.len();
2114    let pool_count = batches.len();
2115    let sent = contract.redeemMany(batches).send().await;
2116    let outcome = send_and_await_receipt(sent, Some(REDEEM_RECEIPT_TIMEOUT), metrics).await;
2117    let failed = is_redemption_failure(&outcome);
2118    let chain_fault = is_chain_fault(&outcome);
2119    match outcome {
2120        TxOutcome::Landed(receipt) => {
2121            span.record("tx", tracing::field::display(receipt.transaction_hash));
2122            span.record("outcome", "landed");
2123            info!(
2124                pool_count,
2125                cap_count,
2126                voucher_count,
2127                tx = %receipt.transaction_hash,
2128                "batched lane redemption landed (redeemMany)"
2129            );
2130            record_landed_chunk(contract, store, &lanes, metrics).await;
2131            return false;
2132        }
2133        TxOutcome::SendErr(err)
2134            if lanes.len() >= 2
2135                && depth < MAX_SPLIT_DEPTH
2136                && is_oversize_send_err(&err.to_string()) =>
2137        {
2138            span.record("outcome", "split");
2139            let mid = lanes.len() / 2;
2140            let right = lanes.split_off(mid);
2141            warn!(
2142                voucher_count,
2143                depth, "redeemMany send rejected oversized; halving chunk and retrying"
2144            );
2145            let left_faulted =
2146                Box::pin(submit_chunk(contract, store, lanes, metrics, depth + 1)).await;
2147            let right_faulted =
2148                Box::pin(submit_chunk(contract, store, right, metrics, depth + 1)).await;
2149            // Each half counts its own outcome; the split itself is not a failure.
2150            return left_faulted || right_faulted;
2151        }
2152        TxOutcome::Reverted(receipt) => {
2153            record_tx_failure(&span, "reverted", Some(receipt.transaction_hash));
2154            warn!(voucher_count, tx = %receipt.transaction_hash, "redeemMany reverted on-chain; leaving claims for retry");
2155        }
2156        TxOutcome::SendErr(err) => {
2157            record_tx_failure(&span, "send_failed", None);
2158            warn!(error = %sanitize_error_sources(&err), voucher_count, "redeemMany send failed; leaving claims for retry");
2159        }
2160        TxOutcome::ReceiptErr {
2161            error,
2162            tx_hash,
2163            last_lookup,
2164        } => {
2165            record_tx_failure(&span, "receipt_failed", Some(tx_hash));
2166            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");
2167        }
2168        TxOutcome::Timeout {
2169            tx_hash,
2170            last_lookup,
2171        } => {
2172            record_tx_failure(&span, "timeout", Some(tx_hash));
2173            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");
2174        }
2175    }
2176    if failed {
2177        metrics.redemption_failure();
2178    }
2179    chain_fault
2180}
2181
2182/// Whether a `redeemMany` outcome is a chain fault that parks a hinted lane
2183/// ([`redeemer_loop`]). Every outcome but `Landed` is one, the unconfirmed ones
2184/// included: the lane's claim stays unsettled either way, so a hint that retries
2185/// it at once meets the same fault. An oversize send that `submit_chunk` halves
2186/// takes the outcome of its halves instead.
2187const fn is_chain_fault(outcome: &TxOutcome) -> bool {
2188    match outcome {
2189        TxOutcome::Landed(_) => false,
2190        TxOutcome::Reverted(_)
2191        | TxOutcome::SendErr(_)
2192        | TxOutcome::ReceiptErr { .. }
2193        | TxOutcome::Timeout { .. } => true,
2194    }
2195}
2196
2197/// Whether a `redeemMany` outcome counts into `decdn_redemption_failures_total`.
2198/// A revert and a refused send are failures. A landed chunk is not, and neither
2199/// is an unconfirmed one (`ReceiptErr` / `Timeout`): the by-hash lookup already
2200/// resolves every transaction the RPC can see mined, so what stays unconfirmed
2201/// counts into `decdn_onchain_tx_receipt_failed_total` /
2202/// `decdn_onchain_tx_timeout_total`, which `DecdnOnchainTxUnconfirmed` alerts
2203/// on. An oversize send that `submit_chunk` halves never reaches this count.
2204const fn is_redemption_failure(outcome: &TxOutcome) -> bool {
2205    match outcome {
2206        TxOutcome::Reverted(_) | TxOutcome::SendErr(_) => true,
2207        TxOutcome::Landed(_) | TxOutcome::ReceiptErr { .. } | TxOutcome::Timeout { .. } => false,
2208    }
2209}
2210
2211/// Record a landed `redeemMany` chunk: count its vouchers into
2212/// `pool_redemptions`, then persist `registered_until` for every lane whose
2213/// `CapabilityReg` rode in the chunk, so the next sweep does not register it
2214/// again.
2215///
2216/// The persisted value is the expiry the chain registered, read back with one
2217/// batched `getAuthorizations`, not the attached `CapabilityReg`'s expiry.
2218/// On-chain registration is write-once: when another provider landed a
2219/// different capability for the signer first, the attached one was a no-op and
2220/// its expiry is not the signer's. A failed or short read persists nothing for
2221/// the unread lanes; the next sweep attaches their `CapabilityReg` again (a
2222/// no-op on-chain) and re-reads.
2223async fn record_landed_chunk<P: Provider + Clone>(
2224    contract: &PaymentPool::PaymentPoolInstance<P>,
2225    store: &Arc<dyn PoolStateStore>,
2226    lanes: &[PlannedLane],
2227    metrics: &Metrics,
2228) {
2229    metrics.pool_redemptions(u64::try_from(lanes.len()).unwrap_or(u64::MAX));
2230    let registering: Vec<LaneKey> = lanes
2231        .iter()
2232        .filter(|lane| lane.register.is_some())
2233        .map(|lane| lane.key)
2234        .collect();
2235    for chunk in registering.chunks(WATERMARK_READ_BATCH_MAX) {
2236        let pool_ids = chunk.iter().map(|key| key.pool_id).collect();
2237        let signers = chunk.iter().map(|key| key.signer).collect();
2238        let builder = contract.getAuthorizations(pool_ids, signers);
2239        match timed(None, "post-redeem getAuthorizations", builder.call()).await {
2240            Ok(auths) => persist_registered_expiries(store, chunk, &auths),
2241            Err(err) => {
2242                warn!(
2243                    error = %sanitize_err_chain(&err),
2244                    lanes = chunk.len(),
2245                    "post-redeem registration read failed; the next sweep re-registers and re-reads"
2246                );
2247                break;
2248            }
2249        }
2250    }
2251}
2252
2253/// Persist each lane's registered expiry as its `registered_until`. `auths` is
2254/// the `getAuthorizations` result for `keys`, in the same order; a short result
2255/// is a positionally sound prefix and the unread tail is skipped. A signer the
2256/// chain still reports unregistered persists nothing.
2257fn persist_registered_expiries(
2258    store: &Arc<dyn PoolStateStore>,
2259    keys: &[LaneKey],
2260    auths: &[PaymentPool::Authorization],
2261) {
2262    if auths.len() != keys.len() {
2263        warn!(
2264            expected = keys.len(),
2265            got = auths.len(),
2266            "post-redeem registration read returned a mismatched count"
2267        );
2268    }
2269    for (key, auth) in keys.iter().zip(auths) {
2270        let SignerAuthorization::Registered { expiry, .. } =
2271            SignerAuthorization::from_onchain(auth)
2272        else {
2273            continue;
2274        };
2275        if let Err(err) = store.set_registered_until(*key, expiry) {
2276            warn!(error = %err, pool_id = %key.pool_id, signer = %key.signer,
2277                "failed to persist registered_until after a landed registration");
2278        }
2279    }
2280}
2281
2282/// Flush the lane store durable off the async worker (the fsync must not block a
2283/// runtime worker). One flush lands the lane frontier AND the buffered
2284/// capability rows, so it is the durability floor for both. Returns `true` on
2285/// success; a failure is metered and logged.
2286///
2287/// `strict` says what the caller does with a `false`, and is carried here only
2288/// so the log reports the outcome the caller actually takes: the periodic sweep
2289/// and hint paths defer their submit, while the shutdown sweep redeems anyway —
2290/// with the residual that a `CapabilityReg` can go on-chain against material
2291/// that never reached disk.
2292async fn flush_store_durable(
2293    store: &Arc<dyn PoolStateStore>,
2294    metrics: &Arc<Metrics>,
2295    strict: bool,
2296) -> bool {
2297    let store = Arc::clone(store);
2298    match tokio::task::spawn_blocking(move || store.flush()).await {
2299        Ok(Ok(())) => true,
2300        Ok(Err(err)) => {
2301            metrics.lane_flush_failure();
2302            if strict {
2303                warn!(error = %err, "pre-redeem lane store flush failed; deferring redeem");
2304            } else {
2305                warn!(
2306                    error = %err,
2307                    "pre-redeem lane store flush failed; the shutdown sweep proceeds \
2308                     with un-flushed lane and capability state"
2309                );
2310            }
2311            false
2312        }
2313        Err(join_err) => {
2314            metrics.lane_flush_failure();
2315            warn!(%join_err, "pre-redeem lane store flush task join failed");
2316            false
2317        }
2318    }
2319}
2320
2321/// Read the on-chain paid watermark for every planned lane and drop any lane the
2322/// chain already shows settled to its claim value — the last check before
2323/// spending gas on a `redeemMany` the contract would silently no-op (its own
2324/// `claimed <= w.amount` guard, `PaymentPool.redeem`). A surviving lane's
2325/// `unredeemed` is recomputed from the fresh on-chain paid, so the per-chunk
2326/// floor gates on the true delta.
2327///
2328/// This is the freshest possible signal and complements the durable paid cache:
2329/// the event-fed cache can lag the chain between poll ticks, and another actor
2330/// could have redeemed the same lane. The read is the contract's own
2331/// `getWatermarks` batch view: one `eth_call` per batch of at most
2332/// [`WATERMARK_READ_BATCH_MAX`] lanes, no per-lane fan-out and no dependency on
2333/// a contract outside the protocol's own deployment.
2334///
2335/// **Fail-open.** An RPC error, a timeout or a mismatched return length submits
2336/// the lanes it could not read unchanged: the contract's own no-op guard is the
2337/// backstop, and the only cost of a stale read is the gas this check saves. It
2338/// never holds up a redemption on a read it could not make. A failed batch stops
2339/// the read there, so later batches are not issued; the batches already read
2340/// still reconcile and keep their savings. Each batch meters itself through
2341/// `redemption_reconcile_ok` / `redemption_reconcile_failure`, because a zero
2342/// `redemption_reconciled_skip` alone cannot separate a healthy sweep with
2343/// nothing to drop from a read that never landed.
2344///
2345/// Returns the kept lanes and whether a batch failed: an RPC error, a timeout or
2346/// a mismatched return length, each also metered as a reconcile failure. The
2347/// hint path parks the lane on a failed read ([`redeemer_loop`]).
2348async fn reconcile_onchain_watermarks<P: Provider + Clone>(
2349    contract: &PaymentPool::PaymentPoolInstance<P>,
2350    plans: Vec<PlannedLane>,
2351    metrics: &Arc<Metrics>,
2352) -> (Vec<PlannedLane>, bool) {
2353    // An empty plan set is the healthy steady state, so short-circuit rather than
2354    // issue an `eth_call` with three empty arrays on every idle sweep.
2355    if plans.is_empty() {
2356        return (plans, false);
2357    }
2358    let mut read_failed = false;
2359    let mut onchain_paid: Vec<U256> = Vec::with_capacity(plans.len());
2360    for chunk in plans.chunks(WATERMARK_READ_BATCH_MAX) {
2361        let mut pool_ids = Vec::with_capacity(chunk.len());
2362        let mut signers = Vec::with_capacity(chunk.len());
2363        let mut providers = Vec::with_capacity(chunk.len());
2364        for plan in chunk {
2365            pool_ids.push(plan.pool_id);
2366            signers.push(plan.key.signer);
2367            providers.push(plan.key.provider);
2368        }
2369        // Bound the read: the alloy HTTP provider has no request timeout, so a hung
2370        // RPC would wedge the redeemer loop forever — the opposite of fail-open. A
2371        // timeout folds into the error arm and submits the unread tail unchanged
2372        // (degrade-and-continue, per `timed`); the default bound
2373        // (`DEFAULT_RPC_CALL_TIMEOUT`) applies.
2374        let builder = contract.getWatermarks(pool_ids, signers, providers);
2375        let lanes = match timed(None, "pre-redeem getWatermarks", builder.call()).await {
2376            Ok(lanes) => lanes,
2377            Err(err) => {
2378                warn!(
2379                    stage = "reconcile",
2380                    error = %sanitize_err_chain(&err),
2381                    lanes = chunk.len(),
2382                    "pre-redeem watermark reconciliation failed; submitting on the contract's own no-op guard"
2383                );
2384                metrics.redemption_reconcile_failure();
2385                read_failed = true;
2386                break;
2387            }
2388        };
2389        // A well-behaved contract returns one `Lane` per input triple, in input
2390        // order. Any other length means the decoder and the chain disagree about
2391        // the return shape, so the batch's values cannot be trusted to pair with
2392        // this chunk's plans — a long return may be offset, and dropping a lane
2393        // on a mis-paired watermark could forfeit real claim value at the next
2394        // shutdown sweep.
2395        // Keep only a short return's prefix, which is still positionally sound,
2396        // and stop either way; `reconcile_plans` keeps the untouched tail.
2397        if lanes.len() != chunk.len() {
2398            warn!(
2399                stage = "reconcile",
2400                expected = chunk.len(),
2401                got = lanes.len(),
2402                "watermark reconciliation returned a mismatched count; submitting the \
2403                 unreconciled lanes unchanged"
2404            );
2405            metrics.redemption_reconcile_failure();
2406            read_failed = true;
2407            if lanes.len() < chunk.len() {
2408                onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
2409            }
2410            break;
2411        }
2412        metrics.redemption_reconcile_ok();
2413        onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
2414    }
2415    let (kept, skipped) = reconcile_plans(plans, &onchain_paid);
2416    if skipped > 0 {
2417        metrics.redemption_reconciled_skip_by(skipped);
2418    }
2419    (kept, read_failed)
2420}
2421
2422/// Pure core of [`reconcile_onchain_watermarks`]: given each plan's freshly-read
2423/// on-chain paid watermark (parallel to `plans`, same order), drop every lane the
2424/// chain already shows settled to its claim value and recompute `unredeemed` for
2425/// the survivors from that fresh paid. Returns the kept lanes and the count
2426/// dropped. `onchain_paid` is a prefix of `plans`, in the same order: the caller
2427/// supplies a shorter slice when a batch errors or under-returns, and the
2428/// untouched tail is conservatively kept.
2429fn reconcile_plans(plans: Vec<PlannedLane>, onchain_paid: &[U256]) -> (Vec<PlannedLane>, u64) {
2430    let mut kept = Vec::with_capacity(plans.len());
2431    let mut skipped = 0u64;
2432    for (idx, mut plan) in plans.into_iter().enumerate() {
2433        let Some(&onchain) = onchain_paid.get(idx) else {
2434            // No reading for this lane (short slice): keep it unchanged — the
2435            // contract's own no-op guard remains the backstop.
2436            kept.push(plan);
2437            continue;
2438        };
2439        if onchain >= plan.owed {
2440            skipped += 1;
2441            debug!(
2442                pool_id = %plan.pool_id,
2443                signer = %plan.key.signer,
2444                onchain_paid = %onchain,
2445                owed = %plan.owed,
2446                "reconciliation: lane already settled on-chain, dropping from redeem batch"
2447            );
2448            continue;
2449        }
2450        // Recompute against the fresh on-chain paid so a lagging cache cannot push
2451        // this lane over the per-chunk floor on value the chain has already paid.
2452        plan.unredeemed = plan.owed.saturating_sub(onchain);
2453        kept.push(plan);
2454    }
2455    (kept, skipped)
2456}
2457
2458/// Record a failed transaction's `outcome`, its hash when one was issued, and
2459/// an error status on its `onchain_tx` span.
2460fn record_tx_failure(span: &tracing::Span, outcome: &'static str, tx: Option<TxHash>) {
2461    if let Some(tx) = tx {
2462        span.record("tx", tracing::field::display(tx));
2463    }
2464    span.record("outcome", outcome);
2465    span.record("otel.status_code", "ERROR");
2466}
2467
2468/// Whether `provider` has a lane of `pool_id` in `states` that is owed more
2469/// than it has been paid.
2470fn holds_unredeemed(states: &[LaneState], pool_id: PoolId, provider: Address) -> bool {
2471    states.iter().any(|st| {
2472        st.pool_id == pool_id && st.provider == provider && st.owed() > st.paid_cumulative
2473    })
2474}
2475
2476/// Chunk planned lanes under the per-chunk floor + voucher-count cap and submit
2477/// each chunk. Every caller passes the configured
2478/// `blockchain.redeem_threshold_micro_usdc`, which config keeps above zero, so a
2479/// sub-floor chunk defers on every path — the hint, the self-tick sweep, and the
2480/// graceful-shutdown sweep alike. The floor is per CHUNK, and value is spread
2481/// so dust rides alongside larger lanes: a sub-floor lane stays unredeemed
2482/// only until its chunk's aggregate clears the floor, or until the pool owner
2483/// reclaims it.
2484///
2485/// The floor gates the on-chain read too: when the whole set's cached
2486/// `unredeemed` is below the floor, it returns before
2487/// [`reconcile_onchain_watermarks`]. The cached paid watermark can only lag the
2488/// chain, so a fresh read can only lower `unredeemed`, and no chunk after the
2489/// read can hold more than the whole set. The gate is on the total, not per
2490/// chunk, because a lane the read drops can shrink the chunk count and re-pack
2491/// lanes from a failing chunk into one that clears. A sub-floor hint therefore
2492/// costs no `eth_call`. At `floor == 0` the gate always passes.
2493///
2494/// Floors the redeemed watermark first: flushes the lane store durable AFTER the
2495/// lanes were planned (their cumulative amounts already read) and BEFORE any
2496/// chunk goes on-chain, so a crash right after a submit still finds on-disk
2497/// `owed ≥ submitted` (`record` is monotone, so the flush persists at least every
2498/// value in the batch). The periodic sweep and hint path require this floor and
2499/// skip their submit on a failed flush (`strict_flush`); the shutdown sweep
2500/// redeems regardless, since forfeiting the claim across the stop is worse than
2501/// a bounded re-serve risk.
2502///
2503/// Returns whether the cycle hit a chain fault: a failed watermark read or a
2504/// chunk that did not land. A floor deferral, an empty chunk set and a failed
2505/// strict flush add no fault of their own; the flush is local to this node.
2506async fn redeem_planned_lanes<P: Provider + Clone>(
2507    contract: &PaymentPool::PaymentPoolInstance<P>,
2508    store: &Arc<dyn PoolStateStore>,
2509    plans: Vec<PlannedLane>,
2510    floor: U256,
2511    max_vouchers: usize,
2512    strict_flush: bool,
2513    metrics: &Arc<Metrics>,
2514) -> bool {
2515    // Apply the floor on the cached total before any chain read.
2516    if plans.is_empty() || sum_unredeemed(&plans) < floor {
2517        return false;
2518    }
2519    // Last check before spending gas: reconcile against the on-chain watermark
2520    // and drop lanes the chain already shows settled, so a lagging cache or a
2521    // concurrent redeemer can never cost a no-op `redeemMany`.
2522    let (plans, read_failed) = reconcile_onchain_watermarks(contract, plans, metrics).await;
2523    let chunks = chunk_redemptions(plans, floor, max_vouchers);
2524    if chunks.is_empty() {
2525        return read_failed;
2526    }
2527    let span = tracing::info_span!("redeem_cycle", chunks = chunks.len(), strict_flush);
2528    async move {
2529        if !flush_store_durable(store, metrics, strict_flush).await && strict_flush {
2530            return read_failed;
2531        }
2532        let mut faulted = read_failed;
2533        for chunk in chunks {
2534            faulted |= submit_chunk(contract, store, chunk, metrics, 0).await;
2535        }
2536        faulted
2537    }
2538    .instrument(span)
2539    .await
2540}
2541
2542/// Mutable debounce bookkeeping for [`DebouncedCheckpointStore`], guarded by a
2543/// single `std::sync::Mutex`. The lock is only ever held for the brief duration
2544/// of a `record`/`flush` decision (no `.await` inside), so a sync mutex is the
2545/// right primitive.
2546struct DebounceState {
2547    /// The block last forwarded to a *durable* write on the inner store, or
2548    /// `None` if nothing has been persisted in this process yet.
2549    last_persisted: Option<u64>,
2550    /// Monotonic [`Instant`] of the last durable write, for the time-based
2551    /// threshold. Seeded at construction so the first interval is measured from
2552    /// service start.
2553    last_persist_at: Instant,
2554    /// The highest block buffered but not yet durably written. Monotonic. `None`
2555    /// once flushed/forwarded — i.e. equal to `last_persisted`.
2556    pending: Option<u64>,
2557    /// Whether the first in-process `record` has run. Until it does we lazily fold
2558    /// the inner store's existing checkpoint into `last_persisted`, and force that
2559    /// first record durable so a restart re-anchors the floor promptly.
2560    seeded: bool,
2561}
2562
2563/// Debouncing decorator over a [`KeyedCheckpointStore`] (#784, keyed in #1092).
2564/// Buffers `record_checkpoint` in memory **per [`CheckpointKey`]** and forwards a
2565/// durable write for a key only when its buffered block is at least `flush_blocks`
2566/// ahead of that key's last persisted value *or* at least `flush_interval` has
2567/// elapsed since that key's last durable write — cutting the per-block fsync
2568/// amplification a watcher's live tail would otherwise pay on a backlog drain.
2569///
2570/// **Durability contract preserved (per key).** The persisted value is only ever
2571/// a *floor* for the resume backfill (the resumable watcher rewinds it by
2572/// `REORG_MARGIN_BLOCKS` and every sink is idempotent), so a
2573/// buffered-but-not-yet-fsynced advance that a crash loses merely widens the next
2574/// rescan — never narrows it. `record_checkpoint` only raises a key's `pending`,
2575/// so the forwarded value stays monotonic. [`Self::flush_checkpoint`] forces the
2576/// buffered value out and is wired into graceful shutdown.
2577pub struct DebouncedCheckpointStore {
2578    inner: Arc<dyn KeyedCheckpointStore>,
2579    flush_blocks: u64,
2580    flush_interval: Duration,
2581    /// Clock source, injectable so the time-based threshold is unit-testable
2582    /// without sleeping. Production uses [`Instant::now`].
2583    clock: Box<dyn Fn() -> Instant + Send + Sync>,
2584    /// Per-key debounce bookkeeping, created lazily on a key's first record/flush.
2585    state: std::sync::Mutex<HashMap<CheckpointKey, DebounceState>>,
2586}
2587
2588impl std::fmt::Debug for DebouncedCheckpointStore {
2589    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2590        let tracked: Vec<(CheckpointKey, Option<u64>, Option<u64>)> = {
2591            let map = self
2592                .state
2593                .lock()
2594                .unwrap_or_else(std::sync::PoisonError::into_inner);
2595            map.iter()
2596                .map(|(k, s)| (*k, s.pending, s.last_persisted))
2597                .collect()
2598        };
2599        f.debug_struct("DebouncedCheckpointStore")
2600            .field("flush_blocks", &self.flush_blocks)
2601            .field("flush_interval", &self.flush_interval)
2602            .field("tracked", &tracked)
2603            .finish_non_exhaustive()
2604    }
2605}
2606
2607impl DebouncedCheckpointStore {
2608    /// Wrap `inner` with the production debounce thresholds
2609    /// (`CHECKPOINT_FLUSH_BLOCKS` / `CHECKPOINT_FLUSH_INTERVAL`) and the real wall
2610    /// clock.
2611    #[must_use]
2612    pub fn new(inner: Arc<dyn KeyedCheckpointStore>) -> Self {
2613        Self::with_params(
2614            inner,
2615            CHECKPOINT_FLUSH_BLOCKS,
2616            CHECKPOINT_FLUSH_INTERVAL,
2617            Box::new(Instant::now),
2618        )
2619    }
2620
2621    /// Construct with explicit thresholds and clock — the seam the unit tests
2622    /// drive to exercise the block-cadence and time-cadence paths deterministically.
2623    fn with_params(
2624        inner: Arc<dyn KeyedCheckpointStore>,
2625        flush_blocks: u64,
2626        flush_interval: Duration,
2627        clock: Box<dyn Fn() -> Instant + Send + Sync>,
2628    ) -> Self {
2629        Self {
2630            inner,
2631            flush_blocks,
2632            flush_interval,
2633            clock,
2634            state: std::sync::Mutex::new(HashMap::new()),
2635        }
2636    }
2637
2638    /// Forward `block` to the inner store for `key` and reset that key's debounce
2639    /// window. Caller holds `state`. On a durable-write error the buffered
2640    /// `pending` is left intact so the next `record`/`flush` retries.
2641    fn persist_locked(
2642        &self,
2643        key: CheckpointKey,
2644        state: &mut DebounceState,
2645        block: u64,
2646    ) -> Result<(), StoreError> {
2647        self.inner.record_checkpoint(key, block)?;
2648        state.last_persisted = Some(block);
2649        state.last_persist_at = (self.clock)();
2650        state.pending = None;
2651        Ok(())
2652    }
2653}
2654
2655impl KeyedCheckpointStore for DebouncedCheckpointStore {
2656    fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError> {
2657        let persisted = self.inner.load_checkpoint(key)?;
2658        let pending = self
2659            .state
2660            .lock()
2661            .unwrap_or_else(std::sync::PoisonError::into_inner)
2662            .get(&key)
2663            .and_then(|s| s.pending);
2664        Ok(match (persisted, pending) {
2665            (Some(a), Some(b)) => Some(a.max(b)),
2666            (a, b) => a.or(b),
2667        })
2668    }
2669
2670    fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError> {
2671        let mut map = self
2672            .state
2673            .lock()
2674            .unwrap_or_else(std::sync::PoisonError::into_inner);
2675        let clock = &self.clock;
2676        let state = map.entry(key).or_insert_with(|| DebounceState {
2677            last_persisted: None,
2678            last_persist_at: clock(),
2679            pending: None,
2680            seeded: false,
2681        });
2682        // On the first in-process record for this key, fold the inner store's
2683        // existing checkpoint into `last_persisted` so a fresh key-state around an
2684        // already-populated inner store cannot regress the on-disk floor. `seeded`
2685        // flips only after the load *succeeds*.
2686        let first_record = !state.seeded;
2687        if first_record {
2688            state.last_persisted = self.inner.load_checkpoint(key)?;
2689            state.seeded = true;
2690        }
2691        let highest = state
2692            .pending
2693            .max(state.last_persisted)
2694            .unwrap_or(0)
2695            .max(block);
2696        state.pending = Some(highest);
2697
2698        if state.last_persisted == Some(highest) {
2699            return Ok(());
2700        }
2701
2702        let blocks_ahead = highest.saturating_sub(state.last_persisted.unwrap_or(0));
2703        let elapsed = (self.clock)().saturating_duration_since(state.last_persist_at);
2704        let due =
2705            first_record || blocks_ahead >= self.flush_blocks || elapsed >= self.flush_interval;
2706        if due {
2707            self.persist_locked(key, state, highest)
2708        } else {
2709            Ok(())
2710        }
2711    }
2712
2713    fn flush_checkpoint(&self, key: CheckpointKey) -> Result<(), StoreError> {
2714        let mut map = self
2715            .state
2716            .lock()
2717            .unwrap_or_else(std::sync::PoisonError::into_inner);
2718        let Some(state) = map.get_mut(&key) else {
2719            return Ok(());
2720        };
2721        match state.pending {
2722            Some(block) if state.last_persisted != Some(block) => {
2723                self.persist_locked(key, state, block)
2724            }
2725            _ => Ok(()),
2726        }
2727    }
2728}
2729
2730/// Whether a `redeemMany` send error looks like "the transaction is too big to
2731/// include" — exceeding the block gas limit or the node/mempool transaction-size
2732/// cap — rather than a revert or a transient RPC fault. Matched case-insensitively
2733/// against a small set of client markers; the caller also bounds retry depth, so a
2734/// false negative simply leaves the claim for the next sweep and a false positive
2735/// costs at most a bounded number of doomed smaller sends.
2736fn is_oversize_send_err(msg: &str) -> bool {
2737    const MARKERS: [&str; 5] = [
2738        "gas required exceeds",
2739        "exceeds block gas limit",
2740        "oversized data",
2741        "transaction too large",
2742        "request entity too large",
2743    ];
2744    let m = msg.to_ascii_lowercase();
2745    MARKERS.iter().any(|marker| m.contains(marker))
2746}
2747
2748#[cfg(test)]
2749mod tests;