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, ®_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;