Skip to main content

decdn_node/
buyer_channel.rs

1//! On-chain `PaymentPool` buyer-side service (#744).
2//!
3//! The node is the *buyer* when it pulls content from an upstream provider on a
4//! cache miss (ADR 003 §node→node). It owns one `PaymentPool` and signs vouchers
5//! against per-`(signer, provider)` lanes of that single pool: the deposit fans
6//! out across every upstream it pays. This service owns the on-chain half of that
7//! path that the off-chain voucher signer in [`decdn_client`] leaves
8//! open:
9//!
10//! - **One-time USDC approval.** `openPool` escrows the deposit via
11//!   `safeTransferFrom`, so the node holds a standing ERC-20 allowance for the
12//!   `PaymentPool` contract. At bootstrap it reads the allowance and, if
13//!   insufficient, issues a single unlimited `approve` (ADR 003 § Deposit
14//!   Economics one-time-approval design).
15//! - **Lazy open + reuse.** [`BuyerPoolService::open_or_reuse_pool`] returns a
16//!   [`PoolContext`] pinned to the upstream provider (`with_provider`) and
17//!   carrying the node's own self-issued capability (`with_capability`), for the
18//!   requester to sign vouchers against. It reuses the single pool the node owns,
19//!   or opens one on the first miss (lazy-on-first-miss). One pool amortizes the
20//!   deposit + gas across many pulls to many providers.
21//! - **Abandonment reclaim.** A background sweep completes the grace-window
22//!   `reclaim` of a pool the node has closed, refunding the residual and dropping
23//!   the local record.
24//!
25//! Structurally this mirrors [`crate::payment_settlement::PoolSettlementService`]:
26//! a generic-over-`Provider` struct that owns a background task and aborts it on drop.
27
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30use std::time::Duration;
31
32use alloy::dyn_abi::Eip712Domain;
33use alloy::eips::BlockId;
34use alloy::primitives::{Address, U256};
35use alloy::providers::Provider;
36use alloy::signers::local::PrivateKeySigner;
37use anyhow::{Context, Result};
38use decdn_incentive::erc20::Erc20;
39use decdn_incentive::payment_pool::{PaymentPool, enumerate_owned_pools};
40use decdn_incentive::{
41    AdvanceOutcome, BuyerPoolState, BuyerPoolStore, Deployment, LaneKey, PoolId,
42    PoolOpenFailureReason,
43};
44use futures_util::FutureExt;
45use tokio_util::task::AbortOnDropHandle;
46use tracing::{debug, error, info, warn};
47
48use crate::metrics::Metrics;
49use decdn_client::buyer_pool::{
50    LOW_WATER_DIVISOR, PoolNotOpen, ProgressWrite, ToppedUpPool, ensure_allowance,
51    grade_deposit_credit, open_pool, pool_accepts_funds, refill_amount, self_owned_lane_ctx,
52    top_up as pool_top_up, topped_up_effect,
53};
54use decdn_client::{LocalPullFault, PoolContext, PoolReplaced, Recovery};
55use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
56
57/// How often the reclaim sweep scans the node's pool for a completed close.
58/// Pool lifetimes are long, so an hourly scan is ample — it matches the seller
59/// expiry-sweep cadence.
60const RECLAIM_SWEEP_INTERVAL: Duration = Duration::from_hours(1);
61
62/// Bound on one lane's `getWatermark` read when the lane seeds from chain. The
63/// read runs under that lane's seed lock and ahead of the pull's own budget,
64/// so a hung RPC must not hold the lane's first pull open indefinitely.
65const WATERMARK_READ_TIMEOUT: Duration = Duration::from_secs(10);
66
67/// A lane's seed queue: the lock the streams seeding one lane take in turn, and
68/// the watermark read the first of them made, for the lane it read. The rest
69/// reuse that read, so the chain is asked once per queue.
70type SeedSlot = Arc<tokio::sync::Mutex<Option<(LaneKey, PaymentPool::Lane)>>>;
71
72/// One stream's hold on a lane's [`SeedSlot`]. Dropping it removes the slot
73/// from the map when no other stream holds the slot, so a lane that has
74/// finished seeding, or whose pull was cancelled while it waited, leaves
75/// nothing behind.
76struct SeedTurn<'a> {
77    slots: &'a Mutex<HashMap<LaneKey, SeedSlot>>,
78    lane: LaneKey,
79    slot: SeedSlot,
80}
81
82impl Drop for SeedTurn<'_> {
83    fn drop(&mut self) {
84        let mut slots = self
85            .slots
86            .lock()
87            .unwrap_or_else(std::sync::PoisonError::into_inner);
88        // Every holder clones the slot under this map lock, so the count read
89        // here is exact: the map's handle plus this one means nobody waits.
90        let last = Arc::strong_count(&self.slot) <= 2;
91        if last
92            && slots
93                .get(&self.lane)
94                .is_some_and(|held| Arc::ptr_eq(held, &self.slot))
95        {
96            slots.remove(&self.lane);
97        }
98    }
99}
100
101/// Expiry stamped on the self-owned capability the buyer signs for its own key.
102/// Typed sentinel: the caller's `open_or_reuse_pool` budget elapsed while the
103/// pool open is still in flight. The open **keeps going** in a detached task; the
104/// caller retries the next candidate and a later miss reuses the pool once it
105/// lands (#1143).
106#[derive(Debug)]
107pub struct PoolOpenPending {
108    /// How long the caller waited before giving up on the answer.
109    pub waited: Duration,
110}
111
112impl std::fmt::Display for PoolOpenPending {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(
115            f,
116            "buyer pool open still in flight after {:?}; retry",
117            self.waited
118        )
119    }
120}
121
122impl std::error::Error for PoolOpenPending {}
123
124/// Typed marker: an open failure this layer has already logged and metered, so
125/// the caller-side classification ladder does not restate it.
126#[derive(Debug)]
127pub struct OpenReported;
128
129impl std::fmt::Display for OpenReported {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        write!(f, "buyer pool open failure (already reported)")
132    }
133}
134
135impl std::error::Error for OpenReported {}
136
137type OpenOutcome = Result<(), Arc<anyhow::Error>>;
138type SharedOpen = futures_util::future::Shared<BoxFuture<'static, OpenOutcome>>;
139type TopUpOutcome = Result<TopUpLanded, Arc<anyhow::Error>>;
140type SharedTopUp = futures_util::future::Shared<BoxFuture<'static, TopUpOutcome>>;
141type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
142
143/// The result of a pool top-up: the pool's new total deposit, and how much of the
144/// growth belongs to the caller.
145///
146/// `added` is the caller's own share. Another funder's concurrent `topUp` can raise
147/// `new_deposit` by more than `added`, and that headroom is already spoken for.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct TopUpLanded {
150    /// The pool's total deposit after the top-up, as the local pool row records it.
151    pub new_deposit: U256,
152    /// The part of the deposit growth that this call funded or claimed.
153    pub added: U256,
154}
155
156/// Typed marker: a `topUp` mined but its deposit could not be credited to the local
157/// pool row. The deposit is escrowed and untracked, so no caller funds again on top
158/// of it — a second `topUp` would strand a second deposit the same way.
159#[derive(Debug)]
160struct EscrowedUntracked;
161
162impl std::fmt::Display for EscrowedUntracked {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(f, "topUp escrowed but not credited to the local pool row")
165    }
166}
167
168impl std::error::Error for EscrowedUntracked {}
169
170/// Who starts a funding `topUp`. The two differ in who may rely on its headroom.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum TopUpFunder {
173    /// The proactive low-water refill. No pull waits on its amount, so a recovery
174    /// top-up that joins it can claim that amount as its own.
175    Refill,
176    /// A funding recovery step's top-up. Its spawner relies on the whole amount,
177    /// so no joiner can claim any of it.
178    Recovery,
179}
180
181/// What a funding call gets from the `topUp` slot.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183enum TopUpClaim {
184    /// The call started a `topUp` for its own amount.
185    Spawned,
186    /// The call joined a `topUp` another funder started, and claimed part of its
187    /// unclaimed amount.
188    Joined {
189        /// How much of the joined `topUp` this call claimed. It can be zero.
190        claimed: U256,
191        /// The amount the joined `topUp` asked for. Claims are made against this
192        /// amount before the `topUp` lands, so they hold only if all of it lands.
193        requested: U256,
194    },
195}
196
197/// The single in-flight funding `topUp` and the part of its amount no caller has
198/// claimed yet.
199struct InFlightTopUp {
200    fut: SharedTopUp,
201    /// The amount this `topUp` asks the chain for.
202    requested: U256,
203    /// Headroom this `topUp` adds that no pull relies on yet. A refill starts with
204    /// its full amount; a recovery top-up starts with zero.
205    unclaimed: U256,
206    /// Who started it.
207    funder: TopUpFunder,
208}
209
210impl InFlightTopUp {
211    fn new(fut: SharedTopUp, requested: U256, funder: TopUpFunder) -> Self {
212        let unclaimed = match funder {
213            TopUpFunder::Refill => requested,
214            TopUpFunder::Recovery => U256::ZERO,
215        };
216        Self {
217            fut,
218            requested,
219            unclaimed,
220            funder,
221        }
222    }
223
224    /// Join this `topUp`. A recovery joiner claims up to `want` of the unclaimed
225    /// amount. A refill joiner claims nothing, because no pull waits on it.
226    fn join(&mut self, want: U256, funder: TopUpFunder) -> (SharedTopUp, TopUpClaim) {
227        let claimed = match funder {
228            TopUpFunder::Refill => U256::ZERO,
229            TopUpFunder::Recovery => self.unclaimed.min(want),
230        };
231        self.unclaimed = self.unclaimed.saturating_sub(claimed);
232        let claim = TopUpClaim::Joined {
233            claimed,
234            requested: self.requested,
235        };
236        (self.fut.clone(), claim)
237    }
238}
239
240/// The headroom a join counts once its `topUp` has landed. Claims are split out of
241/// the requested amount before the chain answers. The contract credits the measured
242/// transfer, which can be less than requested, and the claims can then add up to
243/// more than landed. So a join counts its claim only when the whole requested amount
244/// landed. Otherwise it counts nothing and funds its need with its own `topUp`.
245fn joined_headroom(claimed: U256, requested: U256, landed: U256) -> U256 {
246    if landed >= requested {
247        claimed
248    } else {
249        U256::ZERO
250    }
251}
252
253/// How many funding calls one recovery top-up makes before it gives up. A join that
254/// covers less than the request, or that fails, is followed by a call for the
255/// remainder. That call normally spawns, because the joined task frees the slot
256/// before its result reaches any waiter. This bound stops a caller that keeps losing
257/// the slot to other funders from looping without end.
258const MAX_TOPUP_CALLS: u32 = 3;
259
260/// Fund at least `additional` of new headroom for one recovery top-up on `pool_id`.
261///
262/// `join_or_spawn(amount)` joins the in-flight `topUp` or spawns one for `amount`.
263///
264/// - A spawned `topUp` escrows this caller's own amount, so its result is final.
265///   A spawned `topUp` that credits less than requested is not retried: the
266///   shortfall is not from a join, and another call escrows a second `topUp`.
267/// - A joined `topUp` counts only for the amount the join claimed, and only when
268///   the whole requested amount landed (see [`joined_headroom`]). When that is less
269///   than the remainder, or when the joined `topUp` fails, this warns and asks
270///   again for the rest.
271///
272/// When [`MAX_TOPUP_CALLS`] calls end short, this returns what landed and warns.
273/// The caller compares [`TopUpLanded::added`] with its request.
274///
275/// # Errors
276///
277/// Propagates the error of a spawned `topUp`, and of any `topUp` whose deposit is
278/// escrowed but untracked. Errors when every call joins a `topUp` that fails.
279async fn top_up_at_least<F, Fut>(
280    pool_id: PoolId,
281    additional: U256,
282    mut join_or_spawn: F,
283) -> Result<TopUpLanded>
284where
285    F: FnMut(U256) -> (Fut, TopUpClaim),
286    Fut: std::future::Future<Output = TopUpOutcome>,
287{
288    let mut added = U256::ZERO;
289    let mut new_deposit = None;
290    let mut last_err = None;
291    for _ in 0..MAX_TOPUP_CALLS {
292        let (fut, claim) = join_or_spawn(additional.saturating_sub(added));
293        let landed = match (fut.await, claim) {
294            (Ok(landed), TopUpClaim::Spawned) => {
295                added = added.saturating_add(landed.added);
296                let out = TopUpLanded {
297                    new_deposit: landed.new_deposit,
298                    added,
299                };
300                if added < additional {
301                    warn_short_top_up(pool_id, additional, out, "our own topUp credited less");
302                }
303                return Ok(out);
304            }
305            (Ok(landed), TopUpClaim::Joined { claimed, requested }) => {
306                added = added.saturating_add(joined_headroom(claimed, requested, landed.added));
307                TopUpLanded {
308                    new_deposit: landed.new_deposit,
309                    added,
310                }
311            }
312            (Err(err), claim) => {
313                last_err = Some(retryable_join_error(pool_id, additional, err, claim)?);
314                continue;
315            }
316        };
317        if added >= additional {
318            return Ok(landed);
319        }
320        warn_short_top_up(pool_id, additional, landed, "joined topUp covered less");
321        new_deposit = Some(landed.new_deposit);
322    }
323    match (new_deposit, last_err) {
324        (Some(new_deposit), _) => {
325            let out = TopUpLanded { new_deposit, added };
326            warn_short_top_up(pool_id, additional, out, "funding calls ran out");
327            Ok(out)
328        }
329        (None, Some(err)) => {
330            let failed =
331                anyhow::anyhow!("recovery top-up failed: every joined topUp failed, last: {err:#}");
332            // The recovery step answers a pool that accepts no more funds
333            // with a new pool, so the marker rides out.
334            Err(if err.downcast_ref::<PoolNotOpen>().is_some() {
335                failed.context(PoolNotOpen)
336            } else {
337                failed
338            })
339        }
340        (None, None) => Err(anyhow::anyhow!(
341            "recovery top-up made no funding call (MAX_TOPUP_CALLS is zero)"
342        )),
343    }
344}
345
346/// Warn that a recovery top-up on `pool_id` has less than `requested` so far.
347fn warn_short_top_up(pool_id: PoolId, requested: U256, landed: TopUpLanded, why: &str) {
348    warn!(
349        %pool_id,
350        %requested,
351        landed = %landed.added,
352        new_deposit = %landed.new_deposit,
353        why,
354        "recovery top-up is short of the requested amount"
355    );
356}
357
358/// Decide whether a failed `topUp` lets the recovery top-up try again. A joined
359/// `topUp` failure is another funder's, so the caller funds with its own `topUp`
360/// and gets the error back to report if every call fails.
361///
362/// # Errors
363///
364/// A spawned `topUp` failure is this caller's own and is final. So is an
365/// escrowed-but-untracked failure: a second `topUp` against a row that cannot be
366/// credited strands a second deposit. `fund_pool` has already graded that case
367/// into the error, tx and all, so there is nothing to re-diagnose.
368fn retryable_join_error(
369    pool_id: PoolId,
370    requested: U256,
371    err: Arc<anyhow::Error>,
372    claim: TopUpClaim,
373) -> Result<Arc<anyhow::Error>> {
374    if claim == TopUpClaim::Spawned || err.downcast_ref::<EscrowedUntracked>().is_some() {
375        let failed = anyhow::anyhow!("recovery top-up failed: {err:#}");
376        // A pool that accepts no more funds is what the recovery step answers
377        // with a new pool, so the marker rides out of the shared future.
378        return Err(if err.downcast_ref::<PoolNotOpen>().is_some() {
379            failed.context(PoolNotOpen)
380        } else {
381            failed
382        });
383    }
384    warn!(
385        %pool_id,
386        %requested,
387        error = %sanitize_err_chain(&err),
388        "recovery top-up: the joined topUp failed; funding with our own topUp"
389    );
390    Ok(err)
391}
392
393/// Un-`Arc` a shared open error into a fresh chain for one waiter, preserving the
394/// typed markers ([`OpenReported`], [`LocalPullFault`], `PoolOpenFailureReason`)
395/// the classifier downcasts on. A `Shared` future hands every waiter the same
396/// `Arc<anyhow::Error>`; a waiter that needs an owned `anyhow::Error` rebuilds one
397/// that keeps the markers rather than the concrete source.
398fn rehydrate_open_error(err: &Arc<anyhow::Error>) -> anyhow::Error {
399    let mut rebuilt = anyhow::anyhow!("{err:#}");
400    if err.downcast_ref::<OpenReported>().is_some() {
401        rebuilt = rebuilt.context(OpenReported);
402    }
403    if err.downcast_ref::<LocalPullFault>().is_some() {
404        rebuilt = rebuilt.context(LocalPullFault);
405    }
406    if let Some(reason) = err.downcast_ref::<decdn_incentive::PoolOpenFailureReason>() {
407        rebuilt = rebuilt.context(*reason);
408    }
409    rebuilt
410}
411
412/// RAII claim on the single-pool open (or top-up) slot: clears it when the task
413/// that holds it ends (any path, including a panic unwind), so a wedged task can
414/// never pin the slot shut. A poisoned lock is recovered into its inner value —
415/// releasing the slot always wins over propagating the poison.
416struct SlotGuard<T> {
417    slot: Arc<Mutex<Option<T>>>,
418}
419
420impl<T> Drop for SlotGuard<T> {
421    fn drop(&mut self) {
422        *self
423            .slot
424            .lock()
425            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
426    }
427}
428
429/// The `'static` slice of [`BuyerPoolService`] a detached funding task needs,
430/// lifted off `&self` so the task can outlive any single caller.
431struct FundingHandles<P: Provider + Clone + 'static> {
432    contract: PaymentPool::PaymentPoolInstance<P>,
433    store: Arc<dyn BuyerPoolStore>,
434    rpc: P,
435    token: Address,
436    owner: Address,
437    payment_pool_addr: Address,
438    metrics: Arc<Metrics>,
439}
440
441/// Run `attempt` (a `topUp`), and only if it fails with an
442/// [`AllowanceShortfall`](decdn_client::buyer_pool::AllowanceShortfall)
443/// run `recover_allowance` (a just-in-time `approve`) and retry `attempt`
444/// exactly once. Any other error — and any error from the recovery or the
445/// retry — returns as-is. Generic over the two async effects so the retry
446/// control flow is unit-testable with plain closures and counters, no RPC
447/// (matches the repo's extracted-control-flow posture). On the happy path
448/// `attempt` runs once and
449/// `recover_allowance` never runs, so the node issues zero allowance reads
450/// per top-up.
451///
452/// `T` is whatever the top-up returns, carried through untouched — from the
453/// retry on a recovered allowance, so it describes the second transaction.
454/// `attempt` may run twice, so producing a `T` must be safe to repeat.
455async fn top_up_recovering_allowance<T, A, AFut, R, RFut>(
456    attempt: A,
457    recover_allowance: R,
458) -> Result<T>
459where
460    A: Fn() -> AFut,
461    AFut: std::future::Future<Output = Result<T>>,
462    R: FnOnce() -> RFut,
463    RFut: std::future::Future<Output = Result<()>>,
464{
465    match attempt().await {
466        Ok(out) => Ok(out),
467        Err(err)
468            if err
469                .downcast_ref::<decdn_client::buyer_pool::AllowanceShortfall>()
470                .is_some() =>
471        {
472            // The standing approval was revoked or never granted; do the
473            // just-in-time `approve`, then retry the transfer exactly once.
474            recover_allowance().await?;
475            attempt().await
476        }
477        Err(err) => Err(err),
478    }
479}
480
481/// Add `additional` USDC to the buyer pool `pool_id` on-chain, then credit the
482/// returned amount into the persisted [`BuyerPoolState`] and return the new
483/// deposit. The shared funding kernel behind both the proactive low-water refill
484/// and the funding recovery top-up (#1146/#1530), so the allowance posture, the
485/// deposit-credit grading, and the metering cannot drift apart.
486///
487/// # Errors
488///
489/// Errors if the allowance or `topUp` fails — the funds did not move — and if a
490/// mined `topUp` cannot be credited locally, in which case the deposit is
491/// escrowed and the error names the tx.
492async fn fund_pool<P: Provider + Clone + 'static>(
493    handles: &FundingHandles<P>,
494    pool_id: PoolId,
495    additional: U256,
496) -> Result<TopUpLanded> {
497    // Daemon posture: attempt the transfer directly against the standing
498    // unlimited allowance granted at bootstrap. Only when `topUp` reverts with
499    // an allowance shortfall (the approval was revoked or never granted) do a
500    // just-in-time `approve` and retry once — so the happy path issues zero
501    // allowance reads per top-up.
502    let ToppedUpPool { credited, tx, .. } = match top_up_recovering_allowance(
503        || pool_top_up(&handles.contract, handles.owner, pool_id, additional),
504        || {
505            ensure_allowance(
506                &handles.rpc,
507                handles.token,
508                handles.owner,
509                handles.payment_pool_addr,
510                None,
511            )
512        },
513    )
514    .await
515    {
516        Ok(topped_up) => topped_up,
517        Err(err) => {
518            warn!(
519                %pool_id,
520                %additional,
521                error = %sanitize_err_chain(&err),
522                "buyer top-up: topUp failed; pool not topped up"
523            );
524            handles.metrics.buyer_topup_failure();
525            return Err(err);
526        }
527    };
528
529    // Credit the CHAIN-measured delta into the committed row inside one write txn,
530    // so a concurrent settle cannot clobber the deposit or lose the top-up.
531    // `add_deposit` splits its failures across two channels — a backend fault is
532    // the `Err`, a committed-row mismatch (the row vanished or was replaced
533    // during the RPC) is a non-`Added` `Ok`. Both mean the topUp landed and the
534    // funds are escrowed-but-untracked, so both meter as a failure rather than
535    // `buyer_topup_ok`, or an operator watching the failure metric would miss
536    // stranded deposits (#1146 review). Grading both here is also what puts the
537    // tx in the propagated error: `DepositOutcome` has nowhere to carry it.
538    let effect = topped_up_effect(pool_id, credited);
539    match grade_deposit_credit(
540        handles.store.add_deposit(handles.owner, pool_id, credited),
541        &effect,
542        tx,
543    ) {
544        Ok(new_deposit) => {
545            handles.metrics.buyer_topup_ok();
546            Ok(TopUpLanded {
547                new_deposit,
548                added: credited,
549            })
550        }
551        Err(err) => {
552            error!(
553                %pool_id,
554                %credited,
555                %tx,
556                error = %format_args!("{err:#}"),
557                "buyer top-up: topUp landed on-chain but the local pool row could not be \
558                 credited; the deposit is ESCROWED AND UNTRACKED — reconcile against the chain"
559            );
560            handles.metrics.buyer_topup_failure();
561            Err(err.context(EscrowedUntracked))
562        }
563    }
564}
565
566/// The proactive low-water top-up amount for a reused pool (#1146, #1103):
567/// `U256::ZERO` when the remaining deposit still has headroom, else the amount
568/// that restores it to the `working_deposit` target — the deposit a proven-good
569/// pool refills toward — with the trigger at `working_deposit / LOW_WATER_DIVISOR`
570/// (20% remaining). `spent` is the pool's spend as its row knows it
571/// ([`BuyerPoolState::pool_spend`]), so the remaining spendable is
572/// `deposit - spent`. Pure so the policy is unit-testable; the shared
573/// [`refill_amount`] kernel is the same one the CLI fetch auto-refill uses.
574fn refill_decision(deposit: U256, spent: U256, working_deposit: U256) -> U256 {
575    let low_water = working_deposit / U256::from(LOW_WATER_DIVISOR);
576    refill_amount(deposit, spent, working_deposit, low_water)
577}
578
579/// Build the [`PoolContext`] paying `provider_addr` from this pool's state,
580/// resuming the lane at its recorded cumulative totals ([`self_owned_lane_ctx`]).
581///
582/// # Errors
583///
584/// Propagates a signing error from the owner signer while issuing the capability.
585fn pin_ctx(
586    state: &BuyerPoolState,
587    signer: &Arc<PrivateKeySigner>,
588    voucher_domain: &Eip712Domain,
589    provider_addr: Address,
590) -> Result<PoolContext> {
591    let lane = LaneKey {
592        pool_id: state.pool_id,
593        signer: signer.address(),
594        provider: provider_addr,
595    };
596    let (prior_bytes, prior_amount) = state
597        .lane_progress(lane)
598        .map_or((U256::ZERO, U256::ZERO), |p| (p.last_bytes, p.last_amount));
599    self_owned_lane_ctx(
600        state,
601        signer,
602        voucher_domain,
603        provider_addr,
604        prior_bytes,
605        prior_amount,
606    )
607}
608
609/// Buyer-side `PaymentPool` service. Generic over the alloy [`Provider`] (a
610/// wallet-filled provider is required for the `approve` / `openPool` / `topUp` /
611/// `closePool` / `reclaim` write paths). Cheap to construct; owns its background
612/// reclaim task.
613pub struct BuyerPoolService<P: Provider + Clone + 'static> {
614    contract: PaymentPool::PaymentPoolInstance<P>,
615    store: Arc<dyn BuyerPoolStore>,
616    signer: Arc<PrivateKeySigner>,
617    /// The chain and `PaymentPool` this service buys on. Every buyer row it opens
618    /// or adopts carries it, and every row it reuses must match it.
619    deployment: Deployment,
620    /// The voucher EIP-712 domain of [`Self::deployment`].
621    voucher_domain: Eip712Domain,
622    token: Address,
623    owner: Address,
624    /// The deposit a fresh `openPool` escrows, and the target a reused pool's
625    /// low-water refill tops up toward (see [`refill_decision`]). The shared pool
626    /// is fully withdrawable, so the open escrows the working deposit directly.
627    working_deposit: U256,
628    /// The single in-flight `openPool`, if one is running (#1143). A concurrent
629    /// [`Self::open_or_reuse_pool`] that arrives mid-open JOINS it rather than
630    /// escrowing a second deposit. `None` when no open is running.
631    open_in_flight: Arc<Mutex<Option<SharedOpen>>>,
632    /// The single in-flight funding `topUp`, if one is running (#1146/#1530). Both
633    /// the proactive low-water refill and the funding recovery top-up dedup here,
634    /// so the two legs racing on the one pool cannot double-escrow one shortfall. The
635    /// slot also tracks how much of the running `topUp` no pull has claimed, so two
636    /// recovery top-ups cannot both count one escrow as their own.
637    topup_in_flight: Arc<Mutex<Option<InFlightTopUp>>>,
638    /// One [`SeedSlot`] per lane that a stream is seeding from chain
639    /// ([`Self::reseed_lane_from_chain`]), so the streams that find one lane
640    /// missing at the same time read its watermark once. A lane's slot never
641    /// blocks another lane's seed, which matters after a buyer-table schema
642    /// bump, when every lane seeds at once. A slot leaves the map with the last
643    /// stream queued on it ([`SeedTurn`]), so the map holds only lanes being
644    /// seeded now.
645    seed_slots: Mutex<HashMap<LaneKey, SeedSlot>>,
646    metrics: Arc<Metrics>,
647    _reclaimer: AbortOnDropHandle<()>,
648}
649
650impl<P: Provider + Clone + 'static> BuyerPoolService<P> {
651    /// Bootstrap the service: self-check the contract, read the immutable USDC
652    /// token, issue the one-time USDC approval if requested, and spawn the
653    /// reclaim sweep.
654    ///
655    /// `deployment` is the chain and `PaymentPool` the service buys on; the
656    /// voucher EIP-712 domain derives from it. `working_deposit` is the deposit a
657    /// fresh open escrows and the target a reused pool's low-water refill tops it
658    /// up toward.
659    ///
660    /// # Errors
661    ///
662    /// Returns an error if the `usdc()` self-check call fails (a bad
663    /// `payment_pool_address` or an unreachable RPC), if the persisted buyer
664    /// pools cannot be loaded, or if the one-time approval transaction fails.
665    /// The runtime treats the error as non-fatal: the node serves, and only its
666    /// paid node-to-node cache-miss leg is off.
667    #[allow(clippy::too_many_arguments)]
668    pub async fn bootstrap(
669        provider: P,
670        deployment: Deployment,
671        owner: Address,
672        store: Arc<dyn BuyerPoolStore>,
673        signer: Arc<PrivateKeySigner>,
674        working_deposit: U256,
675        ensure_max_approval: bool,
676        metrics: Arc<Metrics>,
677    ) -> Result<Self> {
678        let payment_pool_addr = deployment.payment_pool;
679        let contract = PaymentPool::new(payment_pool_addr, provider.clone());
680
681        let token = contract
682            .usdc()
683            .call()
684            .await
685            .with_context(|| format!("PaymentPool.usdc() self-check at {payment_pool_addr}"))?;
686
687        if ensure_max_approval {
688            // Daemon posture: an unlimited (`None`) standing approval for a
689            // long-lived node, avoiding re-approve churn across many miss pulls.
690            ensure_allowance(&provider, token, owner, payment_pool_addr, None).await?;
691        }
692
693        let load = store.load_all().context("hydrate persisted buyer pools")?;
694        metrics.buyer_pool_store_skipped_undecodable_records(load.skipped.len());
695
696        let adopted =
697            reconcile_owned_pool(&contract, deployment, &store, owner, token, &metrics).await;
698        info!(
699            %payment_pool_addr,
700            chain_id = deployment.chain_id,
701            %token,
702            %owner,
703            tracked = load.pools.len(),
704            adopted,
705            "BuyerPoolService bootstrap complete"
706        );
707
708        publish_buyer_wallet_usdc(&provider, token, owner, &metrics).await;
709
710        let reclaimer = tokio::spawn(reclaim_loop(
711            contract.clone(),
712            Arc::clone(&store),
713            owner,
714            token,
715            Arc::clone(&metrics),
716        ));
717
718        Ok(Self {
719            contract,
720            store,
721            signer,
722            deployment,
723            voucher_domain: deployment.voucher_domain(),
724            token,
725            owner,
726            working_deposit,
727            open_in_flight: Arc::new(Mutex::new(None)),
728            topup_in_flight: Arc::new(Mutex::new(None)),
729            seed_slots: Mutex::new(HashMap::new()),
730            metrics,
731            _reclaimer: AbortOnDropHandle::new(reclaimer),
732        })
733    }
734
735    /// Load the node's pool, reporting a store fault at the severity it deserves —
736    /// a node that cannot read its pool store can pay no one, so the fault is
737    /// metered and marked [`OpenReported`] + [`LocalPullFault`] (node-wide, ours),
738    /// not restated as an ordinary skipped candidate.
739    ///
740    /// A row on another `PaymentPool` deployment reads as no row at all, which
741    /// sends the caller down the lazy-open path. Bootstrap already drops such a
742    /// row ([`drop_foreign_row`]), but it drops it only if the store let it: the
743    /// check belongs here too, because this is the read that decides what gets
744    /// paid. Without it the guard is temporal — one failed forget at boot and
745    /// every pull for the life of the process pins to a pool whose contract has
746    /// never heard of it, resuming lane progress the live pool never redeemed.
747    fn reuse_or_report(&self) -> Result<Option<BuyerPoolState>> {
748        let row = self.store.get_by_owner(self.owner).map_err(|err| {
749            self.metrics.node_pull_pool_open_failure();
750            error!(
751                error = %format_args!("{err:#}"),
752                "buyer pool store read failed; this node can neither open nor reuse its \
753                 payment pool until the store recovers"
754            );
755            anyhow::Error::new(err)
756                .context("look up the node's buyer pool")
757                .context(OpenReported)
758                .context(LocalPullFault)
759        })?;
760        let configured = self.deployment;
761        Ok(row.filter(|state| {
762            let ours = state.is_on(configured);
763            if !ours {
764                warn!(
765                    pool_id = %state.pool_id,
766                    foreign_payment_pool = %state.deployment.payment_pool,
767                    foreign_chain_id = state.deployment.chain_id,
768                    configured_payment_pool = %configured.payment_pool,
769                    configured_chain_id = configured.chain_id,
770                    "ignoring a tracked buyer pool from another PaymentPool deployment; \
771                     opening a fresh pool instead of paying against it"
772                );
773            }
774            ours
775        }))
776    }
777
778    /// Return a [`PoolContext`] for paying `provider_addr`, pinned to that
779    /// provider and carrying the node's self-issued capability: reuse the single
780    /// pool the node owns, or lazily open one.
781    ///
782    /// A freshly-opened pool escrows the service's `working_deposit`; on reuse
783    /// that deposit is ignored (a low-water refill tops a live pool up instead).
784    ///
785    /// # Bounding (#1143)
786    ///
787    /// `budget` bounds how long the CALLER waits — not how long the open runs. When
788    /// it expires this returns [`PoolOpenPending`] and the open **keeps going** in
789    /// a detached task: a broadcast `openPool` tx that is dropped mid-flight would
790    /// escrow a deposit against a pool nobody tracks, so the open is never
791    /// cancelled, only stopped-waiting-on. The next miss reuses it once it lands.
792    ///
793    /// # Errors
794    ///
795    /// [`PoolOpenPending`] when `budget` expires with the open still running.
796    /// Otherwise: store errors, any failure of the `openPool` transaction (submit,
797    /// revert, or receipt — carrying a [`decdn_incentive::PoolOpenFailureReason`]
798    /// the caller can `downcast_ref`), or a capability-signing fault.
799    pub async fn open_or_reuse_pool(
800        &self,
801        provider_addr: Address,
802        budget: Duration,
803    ) -> Result<PoolContext> {
804        // Fast path: reuse the live pool. A below-low-water pool kicks off a
805        // detached refill (#1146) and hands THIS pull the current deposit
806        // immediately — the refill must not sit in the hot path behind an on-chain
807        // `topUp`.
808        if let Some(state) = self.reuse_or_report()? {
809            self.spawn_refill_if_low(&state);
810            let state = self.reseed_lane_from_chain(state, provider_addr).await?;
811            return pin_ctx(&state, &self.signer, &self.voucher_domain, provider_addr);
812        }
813
814        let open = self.join_or_spawn_open(None);
815
816        match tokio::time::timeout(budget, open).await {
817            // Still running. The task owns the tx; hand the caller a typed "not
818            // yet" so it can move to the next candidate.
819            Err(_) => Err(anyhow::Error::new(PoolOpenPending { waited: budget })),
820            Ok(Err(err)) => Err(rehydrate_open_error(&err)),
821            // The task persisted the pool before it signalled, so re-reading the
822            // store is how we collect the result — the same read a later reuse does.
823            Ok(Ok(())) => {
824                let state = self.reuse_or_report()?.ok_or_else(|| {
825                    self.metrics.node_pull_pool_open_failure();
826                    error!(
827                        "buyer pool opened on-chain but is not present in the store — a deposit \
828                         is escrowed against a row we cannot see"
829                    );
830                    anyhow::anyhow!(
831                        "buyer pool opened but is not present in the store (store/chain disagree?)"
832                    )
833                    .context(OpenReported)
834                    .context(LocalPullFault)
835                })?;
836                pin_ctx(&state, &self.signer, &self.voucher_domain, provider_addr)
837            }
838        }
839    }
840
841    /// Seed a lane's committed progress from the chain when this node has no
842    /// local record of it, returning the state `pin_ctx` should pin.
843    ///
844    /// `PoolLedger` treats the pinned priors as an *offset*: it signs
845    /// `prior + accrued`. A lane the node has paid on before therefore has to
846    /// resume from what that lane was already paid, and the buyer store is the
847    /// only thing that remembers it. Resuming a known lane from zero signs
848    /// cumulatives at or below the contract's watermark, which `_applyVoucher`
849    /// treats as transient-empty and pays nothing for — the node would stream
850    /// real bytes and buy none of them.
851    ///
852    /// Reached whenever the local record is missing and the chain's is not: a
853    /// buyer-table schema bump, which renames the table, so bootstrap adopts the
854    /// on-chain pool with no lanes, or a deleted or partially-restored store. A
855    /// lane seeds on its first use, which can come long after the restart.
856    /// `seed_progress` is monotone, so a lane the node is already ahead of keeps
857    /// its local watermark, and a lane new to an adopted row takes its watermark
858    /// out of the row's redeemed spend ([`BuyerPoolState::seed_lane`]).
859    /// Concurrent streams that find one lane missing seed it once
860    /// ([`Self::seed_slots`]). They share one watermark read, a zero read
861    /// included, so a burst of first pulls costs one `getWatermark`.
862    ///
863    /// The chain's watermark counts *redeemed* vouchers only. A provider still
864    /// holding an unredeemed voucher is ahead of it by at most its own
865    /// redemption threshold. Its rejections of this node's vouchers below that
866    /// anchor carry the anchor, and so does the `BytesRegression` it returns
867    /// when the ledger, priced from the lower seed, passes the anchor on amount
868    /// while it still trails on bytes. The pull moves the lane's ledger to that
869    /// anchor and resumes (ADR 005, `BytesRegression`). The anchor is a voucher
870    /// this node signed, so the move never pays more than the node signed for.
871    ///
872    /// **A failed read refuses the pull.** Resuming the lane from zero is not a
873    /// recoverable degradation: the first pull persists its own progress on every
874    /// exit path (`SettleOnDrop`), so a zero-resumed lane gains a local row, the
875    /// `lane_progress` guard above then matches, and this reseed never runs for
876    /// that provider again. One transient RPC blip would strand the lane below
877    /// the chain watermark until it climbed back organically — one rejected
878    /// voucher at a time. A node that cannot read a lane's watermark cannot price
879    /// that lane, so it refuses rather than delivering bytes it cannot buy. A
880    /// read that runs past [`WATERMARK_READ_TIMEOUT`] fails the same way.
881    ///
882    /// The refusal is [`LocalPullFault`]: the failure is this node's chain lane,
883    /// not the upstream's, so the classifier exonerates the peer and refuses
884    /// instead of reporting the blob absent.
885    ///
886    /// # Errors
887    ///
888    /// The buyer row could not be re-read or has gone, the `getWatermark` read
889    /// failed or timed out, or the seed could not be persisted.
890    async fn reseed_lane_from_chain(
891        &self,
892        state: BuyerPoolState,
893        provider_addr: Address,
894    ) -> Result<BuyerPoolState> {
895        let lane = LaneKey {
896            pool_id: state.pool_id,
897            signer: self.signer.address(),
898            provider: provider_addr,
899        };
900        if state.lane_progress(lane).is_some() {
901            return Ok(state);
902        }
903        let turn = self.seed_turn(lane);
904        let mut shared_read = turn.slot.lock().await;
905        // A sibling stream may have seeded the lane while this one waited for
906        // the slot, so re-read the row before going to the chain.
907        let state = self.reread_before_seed(lane.pool_id)?;
908        let lane = LaneKey {
909            pool_id: state.pool_id,
910            ..lane
911        };
912        if state.lane_progress(lane).is_some() {
913            return Ok(state);
914        }
915        // A zero read persists nothing, so the row cannot tell the next stream
916        // in the queue that the chain was already asked; the slot does.
917        let onchain = if let Some((_, onchain)) = shared_read
918            .as_ref()
919            .filter(|(read_for, _)| *read_for == lane)
920        {
921            onchain.clone()
922        } else {
923            let onchain = self.read_watermark(lane).await?;
924            *shared_read = Some((lane, onchain.clone()));
925            onchain
926        };
927        if onchain.amount == 0 && onchain.bytesDelivered == 0 {
928            // The chain has never paid this lane, so zero is the right resume
929            // point. A provider holding vouchers below its own redemption
930            // threshold also reads zero here; its rejections carry its anchor,
931            // and the pull moves the ledger to it (see the doc above).
932            debug!(
933                pool_id = %state.pool_id, %provider_addr,
934                "lane has no on-chain watermark; resuming it from zero"
935            );
936            return Ok(state);
937        }
938        self.persist_lane_seed(lane, &onchain)?;
939        Ok(self.pin_seeded_state(state, lane, &onchain))
940    }
941
942    /// This stream's place in `lane`'s seed queue: the lane's [`SeedSlot`],
943    /// created on first use.
944    fn seed_turn(&self, lane: LaneKey) -> SeedTurn<'_> {
945        let mut slots = self
946            .seed_slots
947            .lock()
948            .unwrap_or_else(std::sync::PoisonError::into_inner);
949        SeedTurn {
950            slots: &self.seed_slots,
951            lane,
952            slot: Arc::clone(slots.entry(lane).or_default()),
953        }
954    }
955
956    /// Read `lane`'s on-chain watermark, bounded by [`WATERMARK_READ_TIMEOUT`].
957    ///
958    /// # Errors
959    ///
960    /// The read failed or timed out; see [`Self::unread_watermark`].
961    async fn read_watermark(&self, lane: LaneKey) -> Result<PaymentPool::Lane> {
962        let call = self
963            .contract
964            .getWatermark(lane.pool_id, lane.signer, lane.provider);
965        match tokio::time::timeout(WATERMARK_READ_TIMEOUT, call.call()).await {
966            Ok(Ok(onchain)) => Ok(onchain),
967            Ok(Err(err)) => {
968                let shown = sanitize_error_sources(&err);
969                Err(self.unread_watermark(lane, &shown, anyhow::Error::new(err)))
970            }
971            Err(elapsed) => {
972                let shown = format!("no answer within {WATERMARK_READ_TIMEOUT:?}");
973                Err(self.unread_watermark(lane, &shown, anyhow::Error::new(elapsed)))
974            }
975        }
976    }
977
978    /// The node's buyer row, read again under a lane's seed lock. A row that a
979    /// newer pool replaced is the one to seed, because it is the one the pull
980    /// pays from now. A row that has gone refuses the pull: seeding or pinning
981    /// a pool the store no longer tracks pays against a pool nothing records.
982    fn reread_before_seed(&self, pool_id: PoolId) -> Result<BuyerPoolState> {
983        let row = self
984            .reuse_or_report()
985            .context("re-read the buyer pool before seeding a lane")?;
986        row.ok_or_else(|| {
987            self.metrics.buyer_lane_seed_failure();
988            warn!(
989                %pool_id,
990                "the buyer pool row vanished while a lane waited to seed; refusing the pull"
991            );
992            anyhow::anyhow!("buyer pool {pool_id} vanished before its lane could be seeded")
993                .context(OpenReported)
994                .context(LocalPullFault)
995        })
996    }
997
998    /// Meter and log a `getWatermark` read that failed or timed out, and build
999    /// the refusal [`Self::reseed_lane_from_chain`] returns for it.
1000    fn unread_watermark(&self, lane: LaneKey, shown: &str, err: anyhow::Error) -> anyhow::Error {
1001        self.metrics.buyer_lane_seed_failure();
1002        error!(
1003            pool_id = %lane.pool_id, provider_addr = %lane.provider, error = %shown,
1004            "could not read this lane's on-chain watermark; refusing the pull rather than \
1005             resuming the lane from zero, which would strand it below the watermark \
1006             permanently"
1007        );
1008        err.context("read the lane's on-chain watermark")
1009            .context(OpenReported)
1010            .context(LocalPullFault)
1011    }
1012
1013    /// The state to pin once a lane seed is committed: the freshly-read row when
1014    /// the store can be read, and the snapshot with the seed applied when it
1015    /// cannot.
1016    ///
1017    /// Never the unseeded snapshot. The store has just committed the seed, so
1018    /// pinning zero priors against a committed watermark would sign cumulatives
1019    /// the contract pays nothing for — and then persist that regression on the
1020    /// way out, where it reads as ordinary shared-ledger race noise.
1021    fn pin_seeded_state(
1022        &self,
1023        state: BuyerPoolState,
1024        lane: LaneKey,
1025        onchain: &PaymentPool::Lane,
1026    ) -> BuyerPoolState {
1027        match self.store.get_by_owner(self.owner) {
1028            Ok(Some(seeded)) => seeded,
1029            Ok(None) => {
1030                warn!(
1031                    pool_id = %lane.pool_id,
1032                    "the buyer pool row vanished between persisting a lane seed and \
1033                     re-reading it; pinning the seed from memory"
1034                );
1035                Self::apply_seed_in_memory(state, lane, onchain)
1036            }
1037            Err(err) => {
1038                warn!(
1039                    pool_id = %lane.pool_id,
1040                    error = %format_args!("{err:#}"),
1041                    "could not re-read the buyer pool after seeding a lane; pinning the \
1042                     seed from memory"
1043                );
1044                Self::apply_seed_in_memory(state, lane, onchain)
1045            }
1046        }
1047    }
1048
1049    /// Apply a committed lane seed to an in-memory snapshot, so a pinned context
1050    /// carries it even when the store cannot be re-read. Monotone via
1051    /// [`BuyerPoolState::seed_lane`]; a snapshot already ahead keeps its own
1052    /// watermark.
1053    fn apply_seed_in_memory(
1054        mut state: BuyerPoolState,
1055        lane: LaneKey,
1056        onchain: &PaymentPool::Lane,
1057    ) -> BuyerPoolState {
1058        if let Err(err) = state.seed_lane(
1059            lane,
1060            U256::from(onchain.bytesDelivered),
1061            U256::from(onchain.amount),
1062        ) {
1063            debug!(error = %err, "in-memory lane seed did not advance the snapshot");
1064        }
1065        state
1066    }
1067
1068    /// Commit one lane seed read from the chain. Split from
1069    /// [`Self::reseed_lane_from_chain`] so the read, the decision and the write
1070    /// each stay legible on their own.
1071    ///
1072    /// An outcome other than `Advanced` is not an error: `seed_progress` is
1073    /// monotone, so a committed row already at or beyond the seed is the answer
1074    /// the caller wanted. Only a store fault fails, because that leaves the lane
1075    /// unpriced.
1076    ///
1077    /// # Errors
1078    ///
1079    /// The buyer store could not commit the seed.
1080    fn persist_lane_seed(&self, lane: LaneKey, onchain: &PaymentPool::Lane) -> Result<()> {
1081        match self.store.seed_progress(
1082            self.owner,
1083            lane.pool_id,
1084            lane,
1085            U256::from(onchain.bytesDelivered),
1086            U256::from(onchain.amount),
1087        ) {
1088            Ok(AdvanceOutcome::Advanced) => {
1089                info!(
1090                    pool_id = %lane.pool_id,
1091                    provider_addr = %lane.provider,
1092                    amount = onchain.amount,
1093                    bytes = onchain.bytesDelivered,
1094                    "seeded a lane from its on-chain watermark; this node had no local record \
1095                     of having paid on it"
1096                );
1097                Ok(())
1098            }
1099            Ok(other) => {
1100                debug!(
1101                    pool_id = %lane.pool_id, provider_addr = %lane.provider, ?other,
1102                    "lane seed from chain did not advance the committed row"
1103                );
1104                Ok(())
1105            }
1106            Err(err) => {
1107                self.metrics.buyer_lane_seed_failure();
1108                error!(
1109                    pool_id = %lane.pool_id, provider_addr = %lane.provider,
1110                    error = %format_args!("{err:#}"),
1111                    "could not persist a lane seed; refusing the pull rather than resuming \
1112                     the lane from zero"
1113                );
1114                Err(anyhow::Error::new(err)
1115                    .context("persist the lane's on-chain watermark")
1116                    .context(OpenReported)
1117                    .context(LocalPullFault))
1118            }
1119        }
1120    }
1121
1122    /// Best-effort background top-up of a reused pool that has run below its
1123    /// low-water mark (#1146). Spawns a detached task and returns immediately — it
1124    /// NEVER blocks the pull. Deduped via [`Self::topup_in_flight`], so many
1125    /// concurrent reuse pulls fire at most one `topUp`. An in-flight refill, an
1126    /// allowance failure, or a reverted `topUp` all just skip it (logged /
1127    /// metered), leaving the pool un-topped-up — strictly no worse. The one leg
1128    /// that IS worse is a `topUp` that mines and cannot be credited locally: the
1129    /// deposit is then escrowed and untracked, and because this handle is
1130    /// dropped, `fund_pool`'s `error!` and `buyer_topup_failure()` are all the
1131    /// operator gets.
1132    fn spawn_refill_if_low(&self, state: &BuyerPoolState) {
1133        let additional = refill_decision(state.deposit, state.pool_spend(), self.working_deposit);
1134        if additional.is_zero() {
1135            return; // still above the low-water mark
1136        }
1137        info!(
1138            pool_id = %state.pool_id,
1139            %additional,
1140            "buyer refill: reused pool below its low-water mark; funding toward the working \
1141             deposit (#1146)"
1142        );
1143        // Detached: the handle is DROPPED, not awaited — the task self-reports, and
1144        // a recovery top-up arriving while it runs JOINS the same future and can
1145        // claim its amount.
1146        drop(self.join_or_spawn_topup(state.pool_id, additional, TopUpFunder::Refill));
1147    }
1148
1149    /// Everything the detached funding task needs, lifted off `&self` so the task
1150    /// can be `'static`.
1151    fn funding_handles(&self) -> FundingHandles<P> {
1152        FundingHandles {
1153            contract: self.contract.clone(),
1154            store: Arc::clone(&self.store),
1155            rpc: self.contract.provider().clone(),
1156            token: self.token,
1157            owner: self.owner,
1158            payment_pool_addr: *self.contract.address(),
1159            metrics: Arc::clone(&self.metrics),
1160        }
1161    }
1162
1163    /// Join the in-flight funding `topUp`, or spawn one escrowing `additional`.
1164    /// Reservation and spawn are one indivisible step under the slot lock, so two
1165    /// callers cannot both decide to spawn; the task holds a [`SlotGuard`] that
1166    /// frees the slot when it ends (any path). The returned [`TopUpClaim`] says
1167    /// which, and how much of a joined `topUp` this caller claimed (see
1168    /// [`InFlightTopUp::join`]).
1169    fn join_or_spawn_topup(
1170        &self,
1171        pool_id: PoolId,
1172        additional: U256,
1173        funder: TopUpFunder,
1174    ) -> (SharedTopUp, TopUpClaim) {
1175        // Recover the slot on poison rather than treat a prior holder's panic as
1176        // node-fatal (as [`SlotGuard`]'s own Drop does): the guarded value is a
1177        // single `Option<Shared…>` move that cannot tear, so the worst a panic
1178        // leaves is a stale slot this call overwrites. Refusing here would wedge
1179        // ALL funding node-wide for one unrelated panic.
1180        let mut slot = self
1181            .topup_in_flight
1182            .lock()
1183            .unwrap_or_else(std::sync::PoisonError::into_inner);
1184
1185        if let Some(existing) = slot.as_mut() {
1186            debug!(%pool_id, "joining a pool topUp already in flight");
1187            return existing.join(additional, funder);
1188        }
1189
1190        let handles = self.funding_handles();
1191        let guard = SlotGuard {
1192            slot: Arc::clone(&self.topup_in_flight),
1193        };
1194        let task = tokio::spawn(async move {
1195            // Frees the slot when the task ends (including a panic unwind), never
1196            // before — a wedged funding task cannot pin the slot shut.
1197            let _guard = guard;
1198            fund_pool(&handles, pool_id, additional)
1199                .await
1200                .map_err(Arc::new)
1201        });
1202        let fut: BoxFuture<'static, TopUpOutcome> = Box::pin(async move {
1203            task.await.unwrap_or_else(|join_err| {
1204                Err(Arc::new(anyhow::anyhow!(
1205                    "buyer pool topUp task failed: {join_err}"
1206                )))
1207            })
1208        });
1209        let shared = fut.shared();
1210        *slot = Some(InFlightTopUp::new(shared.clone(), additional, funder));
1211        (shared, TopUpClaim::Spawned)
1212    }
1213
1214    /// Join the in-flight `openPool`, or spawn one. The one pool the node owns is
1215    /// opened at most once; concurrent misses join the single running open.
1216    /// `replacing` names a tracked pool that no longer accepts funds: the open
1217    /// then runs although the store tracks it.
1218    fn join_or_spawn_open(&self, replacing: Option<PoolId>) -> SharedOpen {
1219        // Recover on poison rather than treat one panic as node-fatal — see
1220        // [`Self::join_or_spawn_topup`] for why the slot is safe to recover.
1221        let mut slot = self
1222            .open_in_flight
1223            .lock()
1224            .unwrap_or_else(std::sync::PoisonError::into_inner);
1225
1226        if let Some(existing) = slot.as_ref() {
1227            debug!("joining an openPool already in flight");
1228            return existing.clone();
1229        }
1230
1231        let contract = self.contract.clone();
1232        let store = Arc::clone(&self.store);
1233        let signer = Arc::clone(&self.signer);
1234        let deployment = self.deployment;
1235        let token = self.token;
1236        let owner = self.owner;
1237        // The shared pool is fully withdrawable, so open at the working deposit.
1238        let deposit = self.working_deposit;
1239        let metrics = Arc::clone(&self.metrics);
1240        let guard = SlotGuard {
1241            slot: Arc::clone(&self.open_in_flight),
1242        };
1243        let task = tokio::spawn(async move {
1244            let _guard = guard;
1245            run_open(
1246                &contract, &store, signer, deployment, token, owner, deposit, replacing, &metrics,
1247            )
1248            .await
1249            .map_err(Arc::new)
1250        });
1251        let join_metrics = Arc::clone(&self.metrics);
1252        let fut: BoxFuture<'static, OpenOutcome> = Box::pin(async move {
1253            task.await.unwrap_or_else(|join_err| {
1254                // Meters here to hold the invariant the classifier relies on: a site
1255                // marks `OpenReported` if and only if it meters the total. Without
1256                // this the open task could panic and move no counter at all.
1257                //
1258                // `LocalPullFault` too, and for the same reason the store legs carry
1259                // it: the open task dying is this node's machinery failing, not the
1260                // upstream's, so the client is refused rather than told the blob does
1261                // not exist. A cancellation at shutdown takes the same path, where
1262                // "do not retry this node" is if anything the more useful answer.
1263                join_metrics.node_pull_pool_open_failure();
1264                error!(%join_err, "buyer pool open task did not run to completion");
1265                Err(Arc::new(
1266                    anyhow::anyhow!("buyer pool open task failed: {join_err}")
1267                        .context(OpenReported)
1268                        .context(LocalPullFault),
1269                ))
1270            })
1271        });
1272        let shared = fut.shared();
1273        *slot = Some(shared.clone());
1274        shared
1275    }
1276
1277    /// Persist one lane's voucher progress after a delivery exchange so a later
1278    /// reuse (or a restart) resumes the `(signer, provider)` lane at the right
1279    /// cumulative `bytes` / `amount`. The buyer prices `cumulative = bytes × rate`
1280    /// from its own BLAKE3-verified bytes, so there is no nonce to track.
1281    /// [`ProgressWrite`] says whether the write rebases the record down first.
1282    ///
1283    /// # Errors
1284    ///
1285    /// Errors on a store write fault. A stale write (the owner's pool was replaced
1286    /// by a newer open) or a superseded watermark (a concurrent settle wrote a
1287    /// higher value first) is metered and returns `Ok(())` — neither loses a
1288    /// voucher and neither must fail the already-paid pull.
1289    pub fn record_progress(
1290        &self,
1291        provider_addr: Address,
1292        pool_id: PoolId,
1293        write: ProgressWrite,
1294    ) -> Result<()> {
1295        let lane = LaneKey {
1296            pool_id,
1297            signer: self.signer.address(),
1298            provider: provider_addr,
1299        };
1300        let totals = write.totals();
1301        let outcome = write
1302            .apply(self.store.as_ref(), self.owner, pool_id, lane)
1303            .with_context(|| match write {
1304                ProgressWrite::Rebase { anchor, .. } => format!(
1305                    "rebase buyer pool lane progress down to the upstream watermark \
1306                     (amount {}, bytes {}), then to (amount {}, bytes {})",
1307                    anchor.last_amount, anchor.last_bytes, totals.last_amount, totals.last_bytes
1308                ),
1309                ProgressWrite::Advance { .. } => format!(
1310                    "advance buyer pool lane progress to (amount {}, bytes {})",
1311                    totals.last_amount, totals.last_bytes
1312                ),
1313                _ => format!(
1314                    "record buyer pool lane progress at (amount {}, bytes {})",
1315                    totals.last_amount, totals.last_bytes
1316                ),
1317            });
1318        match outcome? {
1319            AdvanceOutcome::Advanced => Ok(()),
1320            AdvanceOutcome::UnknownPool => {
1321                anyhow::bail!("record_progress for unknown pool {pool_id}")
1322            }
1323            // The owner's pool was replaced by a newer open between the delivery and
1324            // this write. Recording stale progress onto the new pool would be wrong;
1325            // real USDC was paid but its watermark is dropped, so it is METERED, not
1326            // just logged — a sustained rate would mean a pool-id plumbing bug rather
1327            // than the rare benign mid-pull replacement (#1145 review).
1328            AdvanceOutcome::PoolMismatch => {
1329                self.metrics.node_pull_progress_dropped();
1330                warn!(
1331                    provider = %provider_addr,
1332                    %pool_id,
1333                    "record_progress: the owner's pool was replaced by a newer open; \
1334                     skipping stale progress write"
1335                );
1336                Ok(())
1337            }
1338            // A CONCURRENT pull on this shared lane already persisted a higher
1339            // watermark, and the store is monotonic, so it kept the correct value and
1340            // rejected ours. No voucher is lost — the routine outcome of two settles
1341            // racing under `BuyerLedgers`, metered as benign (#1145 review).
1342            AdvanceOutcome::Regressed(_err) => {
1343                self.metrics.node_pull_progress_superseded();
1344                debug!(
1345                    provider = %provider_addr,
1346                    %pool_id,
1347                    "record_progress: a concurrent settle persisted a higher watermark first; \
1348                     ours superseded (benign under the shared ledger)"
1349                );
1350                Ok(())
1351            }
1352        }
1353    }
1354
1355    /// Run this node's funding recovery step (ADR 003 § Funding recovery): top the
1356    /// current pool up toward the working deposit, or, when the pool is `Closing` or
1357    /// `Closed` and its `topUp` reverts `PoolNotOpen`, open a new pool at the
1358    /// working deposit and make it the current pool in the store. The closed pool
1359    /// is not reclaimed here; the reclaim sweep recovers it once its dispute window
1360    /// ends.
1361    ///
1362    /// Concurrent fills share one pool, so one step funds them all.
1363    /// `seen_deposit` is the deposit the calling fill saw. A row that already
1364    /// holds more is a sibling's step that landed: the call returns the row's
1365    /// deposit and escrows nothing. A recovery top-up still in flight is a
1366    /// sibling's step too: the call waits for it and returns its deposit. When
1367    /// that top-up reverts `PoolNotOpen`, the call joins the replacement open.
1368    ///
1369    /// The step sizes itself from the pool row: the deposit less the spend its
1370    /// lanes recorded. A fill runs the step only once its own pulls ended, and
1371    /// each pull records its progress as it ends, so the row holds that spend.
1372    /// A pool that already holds the working deposit has nothing to add. A
1373    /// `topUp` gas estimate (no transaction) then tells a closing or closed
1374    /// pool, which a new pool replaces. An open pool is the upstreams' stale
1375    /// view of a deposit they have not seen yet: the step settles and returns
1376    /// the row's deposit, so the fill's settle window asks them again.
1377    ///
1378    /// # Errors
1379    ///
1380    /// No pool is tracked, the `topUp` or replacement `openPool` fails, a
1381    /// joined top-up fails for any reason but `PoolNotOpen`, or a mined
1382    /// transaction cannot be recorded
1383    /// (escrowed and untracked).
1384    pub async fn recover_pool(&self, seen_deposit: U256) -> Result<Recovery> {
1385        let state = self
1386            .reuse_or_report()?
1387            .with_context(|| "no buyer pool tracked to recover")?;
1388        if state.deposit > seen_deposit {
1389            return Ok(Recovery::ToppedUp(state.deposit));
1390        }
1391        if let Some(in_flight) = self.recovery_in_flight() {
1392            return match in_flight.await {
1393                Ok(landed) => Ok(Recovery::ToppedUp(landed.new_deposit)),
1394                // The sibling's step replaces the pool; this fill joins that
1395                // open.
1396                Err(err) if err.downcast_ref::<PoolNotOpen>().is_some() => {
1397                    self.replace_pool(state.pool_id).await
1398                }
1399                Err(err) => Err(anyhow::anyhow!(
1400                    "a sibling's recovery top-up failed: {err:#}"
1401                )),
1402            };
1403        }
1404        // A zero working deposit turns the node's funding off.
1405        if self.working_deposit.is_zero() {
1406            return Ok(Recovery::Unavailable);
1407        }
1408        let remaining = state.deposit.saturating_sub(state.pool_spend());
1409        let additional = self.working_deposit.saturating_sub(remaining);
1410        if additional.is_zero() {
1411            return self.settle_or_replace(state.pool_id, state.deposit).await;
1412        }
1413        match self.top_up_pool_by(additional).await {
1414            Ok(landed) if landed.added.is_zero() => Ok(Recovery::Unavailable),
1415            Ok(landed) => Ok(Recovery::ToppedUp(landed.new_deposit)),
1416            Err(err) if err.downcast_ref::<PoolNotOpen>().is_some() => {
1417                self.replace_pool(state.pool_id).await
1418            }
1419            Err(err) => Err(err),
1420        }
1421    }
1422
1423    /// The recovery step for pool `closed`, whose `topUp` reverted
1424    /// `PoolNotOpen`: open a new pool in its place. Concurrent fills join one
1425    /// open ([`Self::open_replacement`]).
1426    ///
1427    /// # Errors
1428    ///
1429    /// The replacement open fails.
1430    async fn replace_pool(&self, closed: PoolId) -> Result<Recovery> {
1431        info!(
1432            pool_id = %closed,
1433            "buyer pool no longer accepts funds; opening a new pool"
1434        );
1435        let opened = self.open_replacement(closed).await?;
1436        Ok(Recovery::Replaced(PoolReplaced { closed, opened }))
1437    }
1438
1439    /// The recovery step for pool `pool_id`, which already holds its working
1440    /// deposit `deposit`: a `topUp` gas estimate (no transaction) tells a
1441    /// closing or closed pool, which a new pool replaces. An open pool
1442    /// settles at `deposit`.
1443    ///
1444    /// # Errors
1445    ///
1446    /// The replacement open fails.
1447    async fn settle_or_replace(&self, pool_id: PoolId, deposit: U256) -> Result<Recovery> {
1448        if pool_accepts_funds(&self.contract, self.owner, pool_id).await {
1449            info!(
1450                %pool_id,
1451                %deposit,
1452                "buyer pool already holds its working deposit; waiting for the upstreams to \
1453                 see it"
1454            );
1455            return Ok(Recovery::ToppedUp(deposit));
1456        }
1457        info!(
1458            %pool_id,
1459            "buyer pool holds its working deposit but accepts no more funds; opening a new pool"
1460        );
1461        let opened = self.open_replacement(pool_id).await?;
1462        Ok(Recovery::Replaced(PoolReplaced {
1463            closed: pool_id,
1464            opened,
1465        }))
1466    }
1467
1468    /// The recovery top-up in flight, if one is: a sibling fill's step, which
1469    /// this fill waits for instead of funding its own.
1470    fn recovery_in_flight(&self) -> Option<SharedTopUp> {
1471        self.topup_in_flight
1472            .lock()
1473            .unwrap_or_else(std::sync::PoisonError::into_inner)
1474            .as_ref()
1475            .filter(|in_flight| in_flight.funder == TopUpFunder::Recovery)
1476            .map(|in_flight| in_flight.fut.clone())
1477    }
1478
1479    /// The deposit the store's pool row records, or `None` with no pool
1480    /// tracked: the deposit a fill sees.
1481    pub fn pool_deposit(&self) -> Option<U256> {
1482        self.store
1483            .get_by_owner(self.owner)
1484            .ok()
1485            .flatten()
1486            .map(|state| state.deposit)
1487    }
1488
1489    /// Open a new pool in place of `closed`, the pool the store tracks now, and
1490    /// return its id. The open runs on the shared open slot, like a first open.
1491    ///
1492    /// # Errors
1493    ///
1494    /// The open fails, or the store still tracks `closed` after it.
1495    async fn open_replacement(&self, closed: PoolId) -> Result<PoolId> {
1496        self.join_or_spawn_open(Some(closed))
1497            .await
1498            .map_err(|err| rehydrate_open_error(&err))?;
1499        let state = self
1500            .reuse_or_report()?
1501            .with_context(|| "the replacement buyer pool is not in the store")?;
1502        if state.pool_id == closed {
1503            anyhow::bail!("no replacement opened for closed buyer pool {closed}");
1504        }
1505        Ok(state.pool_id)
1506    }
1507
1508    /// Fund `additional` of new headroom in the node's pool and return the pool's NEW
1509    /// total deposit with the amount this call added (#1530): the top-up behind
1510    /// [`Self::recover_pool`].
1511    ///
1512    /// A `topUp` already in flight is JOINED, not duplicated. This call counts only
1513    /// the part of it that no other pull relies on: all of a refill's amount, none of
1514    /// another recovery top-up's. When that part is less than `additional`, this
1515    /// method funds the remainder with a further `topUp` (see `top_up_at_least`).
1516    /// `added` is below `additional` only when the funding calls run out, or when the
1517    /// chain credits less than a `topUp` asked for. The caller compares the two.
1518    ///
1519    /// Routes through the detached, join-or-spawn funding task (never an inline
1520    /// `.await`): `topUp` waits on an unbounded `get_receipt`, and this runs inside
1521    /// the miss-pull future the foreground serve path DROPS on its deadline — an
1522    /// inline await cancelled mid-receipt would leave the deposit escrowed and
1523    /// untracked. On the spawned task, a dropped caller loses only the answer.
1524    ///
1525    /// # Errors
1526    ///
1527    /// Errors if no pool is tracked, if our own allowance/`topUp` fails, if a
1528    /// `topUp` mined but the local row could not be credited (terminal — the
1529    /// deposit is escrowed-and-untracked; a retry would escrow again), or if every
1530    /// funding call joined a `topUp` that failed.
1531    pub async fn top_up_pool_by(&self, additional: U256) -> Result<TopUpLanded> {
1532        let state = self
1533            .reuse_or_report()?
1534            .with_context(|| "no buyer pool tracked to top up")?;
1535        if additional.is_zero() {
1536            return Ok(TopUpLanded {
1537                new_deposit: state.deposit,
1538                added: U256::ZERO,
1539            });
1540        }
1541        let landed = top_up_at_least(state.pool_id, additional, |amount| {
1542            self.join_or_spawn_topup(state.pool_id, amount, TopUpFunder::Recovery)
1543        })
1544        .await?;
1545        info!(
1546            pool_id = %state.pool_id,
1547            %additional,
1548            added = %landed.added,
1549            new_deposit = %landed.new_deposit,
1550            "recovery top-up: funded the buyer pool (#1530)"
1551        );
1552        Ok(landed)
1553    }
1554
1555    /// Run one reclaim-sweep pass synchronously: complete the grace-window
1556    /// `reclaim` of the node's pool if it has been closed and its dispute window
1557    /// has elapsed. Best-effort — errors are logged, never propagated. Exposed so
1558    /// the runtime (or a test) can trigger an immediate pass.
1559    pub async fn sweep_reclaimable_once(&self) {
1560        reclaim_once(&self.contract, &self.store, self.owner, &self.metrics).await;
1561    }
1562}
1563
1564impl<P: Provider + Clone + 'static> std::fmt::Debug for BuyerPoolService<P> {
1565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1566        f.debug_struct("BuyerPoolService")
1567            .field("address", self.contract.address())
1568            .field("owner", &self.owner)
1569            .finish_non_exhaustive()
1570    }
1571}
1572
1573/// Object-safe seam over [`BuyerPoolService`] (#831).
1574///
1575/// `BuyerPoolService` is generic over the alloy [`Provider`], but the
1576/// node-to-node pull origin ([`crate::node_origin::NodeOrigin`]) is stored as an
1577/// `Arc<dyn Origin>` and cannot itself be generic. This trait erases the provider
1578/// type so the origin holds the bootstrapped service behind an `Arc<dyn
1579/// PoolOpener>` and opens (or reuses) the node's buyer pool on a cache-miss pull.
1580#[async_trait::async_trait]
1581pub trait PoolOpener: Send + Sync + std::fmt::Debug {
1582    /// Open or reuse the node's buyer pool and return a [`PoolContext`] pinned to
1583    /// `provider_addr` (with the self-issued capability attached). See
1584    /// [`BuyerPoolService::open_or_reuse_pool`] for the full contract.
1585    async fn open_or_reuse_pool(
1586        &self,
1587        provider_addr: Address,
1588        budget: Duration,
1589    ) -> Result<PoolContext>;
1590
1591    /// Persist one lane's voucher progress on the `(signer, provider)` lane of
1592    /// `pool_id`. See [`BuyerPoolService::record_progress`].
1593    ///
1594    /// # Errors
1595    ///
1596    /// On store write failure.
1597    fn record_progress(
1598        &self,
1599        provider_addr: Address,
1600        pool_id: PoolId,
1601        write: ProgressWrite,
1602    ) -> Result<()>;
1603
1604    /// Run the node's funding recovery step on its buyer pool, for a fill that
1605    /// saw `seen_deposit`. See [`BuyerPoolService::recover_pool`].
1606    ///
1607    /// Defaults to "funding not supported", so a read-only or test double need
1608    /// not override it: [`Recovery::Unavailable`].
1609    ///
1610    /// # Errors
1611    ///
1612    /// Implementations error when no pool is tracked, when the allowance,
1613    /// `topUp` or replacement `openPool` fails, or when a transaction lands but
1614    /// the local row can no longer record it.
1615    async fn recover_pool(&self, _seen_deposit: U256) -> Result<Recovery> {
1616        Ok(Recovery::Unavailable)
1617    }
1618
1619    /// The deposit the node's pool row records now, or `None` with no pool
1620    /// tracked. See [`BuyerPoolService::pool_deposit`].
1621    fn pool_deposit(&self) -> Option<U256> {
1622        None
1623    }
1624}
1625
1626#[async_trait::async_trait]
1627impl<P: Provider + Clone + 'static> PoolOpener for BuyerPoolService<P> {
1628    async fn open_or_reuse_pool(
1629        &self,
1630        provider_addr: Address,
1631        budget: Duration,
1632    ) -> Result<PoolContext> {
1633        BuyerPoolService::open_or_reuse_pool(self, provider_addr, budget).await
1634    }
1635
1636    fn record_progress(
1637        &self,
1638        provider_addr: Address,
1639        pool_id: PoolId,
1640        write: ProgressWrite,
1641    ) -> Result<()> {
1642        BuyerPoolService::record_progress(self, provider_addr, pool_id, write)
1643    }
1644
1645    async fn recover_pool(&self, seen_deposit: U256) -> Result<Recovery> {
1646        BuyerPoolService::recover_pool(self, seen_deposit).await
1647    }
1648
1649    fn pool_deposit(&self) -> Option<U256> {
1650        BuyerPoolService::pool_deposit(self)
1651    }
1652}
1653
1654/// Adopt the pool this `owner` already holds on-chain when the local store has
1655/// no row for it. Returns whether a pool was adopted.
1656///
1657/// The buyer store is the node's only record that it owns a pool, and ADR 003
1658/// §`node→node` is unambiguous about what that record is for: "A pool is opened
1659/// once and reused. There is no per-node, per-fetch, or per-client open", and
1660/// "Owner funds are therefore never stranded". A store that is reset — a moved
1661/// data dir, a redeployed host — breaks both. The node forgets a funded pool,
1662/// `openPool`s a second deposit beside it, forgets that one too, and once the
1663/// wallet is drained every pull fails `ERC20: transfer amount exceeds balance`
1664/// with its own escrow sitting idle on-chain (#2072).
1665///
1666/// `getPools` is the chain's answer to the question the store could not, so ask
1667/// it before opening anything. The newest `Open` pool wins: pools are
1668/// enumerated oldest-first and a later one is the one a previous adoption cycle
1669/// would have been using.
1670///
1671/// The enumeration runs whether or not anything is adopted, and that is the
1672/// second half of the job: every open pool beside the one in use is a deposit
1673/// this node is not spending, and [`report_stranded_pools`] is the only thing
1674/// that names them. Reporting them solely after an adoption would leave the
1675/// steady state — an intact store and a deposit the store does not track —
1676/// permanently silent.
1677///
1678/// Adopting is deliberately softer than the rest of bootstrap. A failure here
1679/// leaves the store untouched and returns `false`, so the first miss falls
1680/// through to the ordinary lazy open — an RPC blip while enumerating must not
1681/// disable buying for the life of the process the way a failed approval does.
1682async fn reconcile_owned_pool<P: Provider + Clone>(
1683    contract: &PaymentPool::PaymentPoolInstance<P>,
1684    deployment: Deployment,
1685    store: &Arc<dyn BuyerPoolStore>,
1686    owner: Address,
1687    token: Address,
1688    metrics: &Arc<Metrics>,
1689) -> bool {
1690    // Resolve every verdict here, at the one point that can act on each, so the
1691    // rest of the function carries only an `Option<PoolId>`. Leaving an
1692    // unreadable store alive downstream would let a future edit turn "could not
1693    // read" into "the store is empty, go open a pool" — the duplicate-deposit
1694    // bug — without a compile error.
1695    //
1696    // `Foreign` is matched ahead of `AlreadyTracked` by the guard on the arm,
1697    // not by position; swapping them would compile and silently restore the
1698    // collision.
1699    let tracked: Option<PoolId> = match adoption_applies(store, owner, deployment) {
1700        AdoptionCheck::Unknown => {
1701            metrics.buyer_pool_adoption_failure();
1702            return false;
1703        }
1704        AdoptionCheck::Applies => None,
1705        AdoptionCheck::Foreign { pool_id, was_on } => {
1706            if !drop_foreign_row(store, owner, pool_id, was_on, deployment) {
1707                return false;
1708            }
1709            None
1710        }
1711        AdoptionCheck::AlreadyTracked(pool_id) => Some(pool_id),
1712    };
1713
1714    let Some(pools) = enumerate_open_pools(contract, owner, tracked.is_none(), metrics).await
1715    else {
1716        return false;
1717    };
1718
1719    let candidate = match tracked {
1720        Some(pool_id) => match reconcile_tracked(&pools, store, owner, pool_id) {
1721            TrackedOutcome::Keep => return false,
1722            TrackedOutcome::Replace(candidate) => candidate,
1723        },
1724        None => pools.newest_solvent(),
1725    };
1726
1727    let Some((pool_id, pool)) = candidate else {
1728        return false;
1729    };
1730    // The row has no lanes to account for what the pool already paid out, so it
1731    // keeps `totalRedeemed` until the lanes it seeds from chain take it over.
1732    let state = BuyerPoolState::adopt(
1733        pool_id,
1734        deployment,
1735        owner,
1736        token,
1737        U256::from(pool.deposit),
1738        U256::from(pool.totalRedeemed),
1739    );
1740    if let Err(err) = store.record(&state) {
1741        metrics.buyer_pool_adoption_failure();
1742        warn!(
1743            %pool_id,
1744            error = %format_args!("{err:#}"),
1745            "could not persist the adopted pool; the next miss opens a fresh one"
1746        );
1747        return false;
1748    }
1749    report_stranded_pools(&pools.recoverable_beside(pool_id), pools.unreadable.len());
1750    info!(
1751        %pool_id,
1752        deposit = pool.deposit,
1753        remaining = pool.deposit.saturating_sub(pool.totalRedeemed),
1754        "adopted this node's existing on-chain payment pool; not opening a second one"
1755    );
1756    true
1757}
1758
1759/// Every pool this `owner` holds on chain with its state, or `None` when the
1760/// chain could not be asked at all.
1761///
1762/// Runs on BOTH reconciliation paths, not only when adopting. A node whose
1763/// store is intact is the steady state, and it is exactly the state in which a
1764/// deposit the store does not track sits unnoticed: nothing else ever asks the
1765/// chain what this owner holds, so nothing ever names it (#2078).
1766///
1767/// `adopting` decides how a failure is reported, and the distinction matters:
1768/// `decdn_buyer_pool_adoption_failures_total` means "could not tell whether it
1769/// already owned a pool and is about to open a second one", which is what the
1770/// runbook reads it as. A sweep that could not run beside an already-tracked
1771/// pool was never going to adopt anything, so it warns instead of counting.
1772async fn enumerate_open_pools<P: Provider + Clone>(
1773    contract: &PaymentPool::PaymentPoolInstance<P>,
1774    owner: Address,
1775    adopting: bool,
1776    metrics: &Arc<Metrics>,
1777) -> Option<OwnedPools> {
1778    let ids = match enumerate_owned_pools(contract, owner).await {
1779        Ok(ids) => ids,
1780        Err(err) if adopting => {
1781            metrics.buyer_pool_adoption_failure();
1782            warn!(
1783                error = %sanitize_err_chain(&err),
1784                "could not enumerate this node's on-chain pools; a pool it already owns \
1785                 stays unadopted and the next miss opens a fresh one"
1786            );
1787            return None;
1788        }
1789        Err(err) => {
1790            warn!(
1791                error = %sanitize_err_chain(&err),
1792                "could not enumerate this node's on-chain pools; a deposit stranded beside \
1793                 the pool it is using would go unreported this boot"
1794            );
1795            return None;
1796        }
1797    };
1798    Some(OwnedPools::walk(contract, ids).await)
1799}
1800
1801/// What reconciliation should do about the pool the store already tracks.
1802enum TrackedOutcome {
1803    /// Keep the row and adopt nothing this boot.
1804    Keep,
1805    /// The row is gone; adopt this candidate, if any.
1806    Replace(Option<(PoolId, PaymentPool::Pool)>),
1807}
1808
1809/// Decide the fate of the pool the store tracks, against what the chain said.
1810///
1811/// The three answers are deliberately not two. Only a pool read successfully
1812/// and found not `Open` justifies deleting the node's sole record that it owns
1813/// a funded deposit; a read that faulted leaves the row exactly where it is.
1814/// Collapsing those two would turn one transient RPC error at bootstrap into a
1815/// forgotten pool plus a second escrow — the #2072 failure, self-inflicted.
1816fn reconcile_tracked(
1817    pools: &OwnedPools,
1818    store: &Arc<dyn BuyerPoolStore>,
1819    owner: Address,
1820    pool_id: PoolId,
1821) -> TrackedOutcome {
1822    match pools.tracked_status(pool_id) {
1823        // In use. Everything else this owner holds open is stranded, including
1824        // pools this node never chose and would never have selected.
1825        TrackedStatus::Open => {
1826            report_stranded_pools(&pools.recoverable_beside(pool_id), pools.unreadable.len());
1827            TrackedOutcome::Keep
1828        }
1829        TrackedStatus::Unknown => {
1830            warn!(
1831                %pool_id,
1832                "could not read the tracked buyer pool's on-chain status; keeping the row. A \
1833                 pool this node owns must not be dropped because one read failed — the next \
1834                 bootstrap asks again"
1835            );
1836            TrackedOutcome::Keep
1837        }
1838        TrackedStatus::NotOpen => {
1839            if drop_stale_row(store, owner, pool_id) {
1840                TrackedOutcome::Replace(pools.newest_solvent())
1841            } else {
1842                TrackedOutcome::Keep
1843            }
1844        }
1845    }
1846}
1847
1848/// Forget a tracked row that belongs to a different `PaymentPool` deployment,
1849/// so nothing reuses it here. Returns whether this owner's reuse lookup can
1850/// still reach the row.
1851///
1852/// This runs ahead of the on-chain enumeration, and that order is the point:
1853/// the row's `pool_id` is one this contract can mint too (the derivation omits
1854/// the contract address, and a redeploy restarts the owner's nonce), so leaving
1855/// it in place long enough to be enumerated is what turns a stale row into a
1856/// silent resume against an existing, unrelated pool.
1857///
1858/// The guarantee is "reuse can no longer reach it", which is weaker than "the
1859/// record is erased" and is what adoption needs.
1860/// [`BuyerPoolStore::forget_if_pool`] is a compare-and-delete on the owner
1861/// index, and `get_by_owner` resolves only through that index, so its
1862/// `Ok(false)` — index absent, or already pointing elsewhere — leaves nothing
1863/// mapping this owner to the foreign row and is a success here. A main-table
1864/// record left behind stays visible to `load_all`, so it still appears in the
1865/// reclaim sweep and in `decdn pool list`; a later `record` of the same id
1866/// overwrites it.
1867///
1868/// Only a store `Err` refuses the adoption: there the mapping may well survive,
1869/// and adopting beside it would leave the reuse lookup free to answer with the
1870/// foreign row. That refusal is not an adoption *failure* — the node is stuck
1871/// on a row it already has rather than about to escrow a second deposit beside
1872/// one, which is what `buyer_pool_adoption_failures` counts — so, like
1873/// [`drop_stale_row`], it warns instead of metering.
1874fn drop_foreign_row(
1875    store: &Arc<dyn BuyerPoolStore>,
1876    owner: Address,
1877    pool_id: PoolId,
1878    was_on: Deployment,
1879    configured: Deployment,
1880) -> bool {
1881    warn!(
1882        %pool_id,
1883        foreign_payment_pool = %was_on.payment_pool,
1884        foreign_chain_id = was_on.chain_id,
1885        configured_payment_pool = %configured.payment_pool,
1886        configured_chain_id = configured.chain_id,
1887        "the tracked buyer pool belongs to a different PaymentPool deployment; dropping the \
1888         stale row. Its deposit, if any, is recoverable only against \
1889         `foreign_payment_pool`, which is the last record this node keeps of it"
1890    );
1891    if let Err(err) = store.forget_if_pool(owner, pool_id) {
1892        warn!(
1893            %pool_id,
1894            foreign_payment_pool = %was_on.payment_pool,
1895            foreign_chain_id = was_on.chain_id,
1896            error = %format_args!("{err:#}"),
1897            "could not drop the foreign buyer pool row; this node keeps reusing a pool on a \
1898             contract that has never heard of it, and its vouchers fund nothing, until the \
1899             store recovers"
1900        );
1901        return false;
1902    }
1903    true
1904}
1905
1906/// Forget a tracked row whose pool the chain no longer lists as open, so the
1907/// caller may adopt or open a replacement. Returns whether the row is gone.
1908///
1909/// `redeemMany` rejects a voucher on a `Closed` pool outright, and on a
1910/// `Closing` one once `block.timestamp >= disputeDeadline`. Inside the dispute
1911/// window a `Closing` pool still redeems, so the wedge is not immediate — it
1912/// becomes total when the window elapses, and the residual is refunded to the
1913/// owner at `reclaim` regardless. [`BuyerPoolService::reuse_or_report`] reads
1914/// the tracked row without a status check, so a node that kept this row would
1915/// pin every pull to that pool and, past the deadline, stream bytes it cannot
1916/// pay for. This is the state `decdn pool close --pool` run from a node host
1917/// leaves behind: it lands the close on chain but cannot write the daemon's
1918/// store.
1919///
1920/// A failed forget does NOT count as an adoption failure. That counter means
1921/// "could not tell whether it already owns a pool, and is about to open a
1922/// second one", which the runbook reads it as; a node whose forget failed
1923/// keeps reusing the pool it already has and opens nothing. It is stuck, not
1924/// duplicating, so it warns instead.
1925fn drop_stale_row(store: &Arc<dyn BuyerPoolStore>, owner: Address, pool_id: PoolId) -> bool {
1926    warn!(
1927        %pool_id,
1928        "the tracked buyer pool is no longer open on chain; dropping the stale row so this \
1929         node stops pinning its pulls to a pool that can fund no voucher"
1930    );
1931    if let Err(err) = store.forget_if_pool(owner, pool_id) {
1932        warn!(
1933            %pool_id,
1934            error = %format_args!("{err:#}"),
1935            "could not drop the stale buyer pool row; this node keeps reusing a pool whose \
1936             vouchers stop redeeming at its dispute deadline, until the store recovers"
1937        );
1938        return false;
1939    }
1940    true
1941}
1942
1943/// What bootstrap knows about whether `owner` already has a tracked pool.
1944///
1945/// Distinguishes "no pool tracked", "a pool on another deployment", "a pool on
1946/// this one", and "the store could not be read". The last two are the pair that
1947/// must never be confused at the call site: "there is no pool" and "the store
1948/// could not be read" want the same action for opposite reasons, and only the
1949/// former justifies adopting while only the latter is a fault worth counting.
1950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1951enum AdoptionCheck {
1952    /// The store holds no pool for this owner, so adoption applies.
1953    Applies,
1954    /// The store holds a pool for this owner, but on a different
1955    /// `PaymentPool` deployment. Adoption applies, and the foreign row must be
1956    /// dropped before anything reuses it: `pool_id` is
1957    /// `keccak256(owner, ownerPoolNonce)` with the contract address nowhere in
1958    /// it, and a fresh deployment restarts that nonce at zero, so this row's id
1959    /// will eventually name an existing, unrelated pool here — carrying lane
1960    /// progress that priced bytes the live contract never saw.
1961    ///
1962    /// Carries the deployment the row was written against, because that is the
1963    /// only chain and contract against which its deposit can be reclaimed, and
1964    /// it is not recoverable from anywhere else once the row is dropped.
1965    Foreign {
1966        /// The tracked pool, as the foreign contract numbered it.
1967        pool_id: PoolId,
1968        /// The chain and `PaymentPool` the row was written against.
1969        was_on: Deployment,
1970    },
1971    /// The store already tracks this pool; there is nothing to adopt. The id
1972    /// rides along because the stranded set is "every other open pool", and
1973    /// the tracked pool is not necessarily the one an adoption would pick.
1974    AlreadyTracked(PoolId),
1975    /// The store could not be read, so what it holds is unknown.
1976    Unknown,
1977}
1978
1979/// Decide whether adoption applies to `owner`.
1980///
1981/// An unreadable store answers [`AdoptionCheck::Unknown`], never `Applies`:
1982/// writing an adopted row into a store whose contents are unknown risks a second
1983/// row beside one already there, which is the failure adoption exists to prevent.
1984fn adoption_applies(
1985    store: &Arc<dyn BuyerPoolStore>,
1986    owner: Address,
1987    deployment: Deployment,
1988) -> AdoptionCheck {
1989    match store.get_by_owner(owner) {
1990        Ok(None) => AdoptionCheck::Applies,
1991        Ok(Some(state)) if !state.is_on(deployment) => AdoptionCheck::Foreign {
1992            pool_id: state.pool_id,
1993            was_on: state.deployment,
1994        },
1995        Ok(Some(state)) => AdoptionCheck::AlreadyTracked(state.pool_id),
1996        Err(err) => {
1997            warn!(
1998                error = %format_args!("{err:#}"),
1999                "buyer pool store read failed before on-chain reconciliation; \
2000                 skipping adoption and leaving the open path to report it"
2001            );
2002            AdoptionCheck::Unknown
2003        }
2004    }
2005}
2006
2007/// Name the open pools this node holds and is not using, so their deposits are
2008/// recoverable. `unreadable` is how many pools could not be read this boot;
2009/// a non-zero count means the list below may be short.
2010///
2011/// Nothing else reports which ids they are: adoption takes one pool and the rest
2012/// are invisible, which is how a deposit stays stranded indefinitely.
2013///
2014/// The remedy names each pool explicitly. `--all` is refused on a node's data
2015/// dir precisely because it enumerates from chain and would close the pool this
2016/// node is paying from (#2078).
2017fn report_stranded_pools(stranded: &[PoolId], unreadable: usize) {
2018    if stranded.is_empty() {
2019        if unreadable > 0 {
2020            warn!(
2021                unreadable,
2022                "could not read every pool this node owns, so this boot cannot say whether it \
2023                 holds a stranded deposit; the absence of a stranded-pool warning is not \
2024                 evidence there is none"
2025            );
2026        }
2027        return;
2028    }
2029    warn!(
2030        count = stranded.len(),
2031        pools = ?stranded,
2032        unreadable,
2033        "this node owns further open payment pools it is not using; their deposits are \
2034         recoverable with `decdn pool close --pool <id>` then, after the dispute window, \
2035         `decdn pool reclaim --pool <id>`. Name each id: `--all` would close the pool this \
2036         node is using, and is refused on a node's data dir. Neither command clears this \
2037         node's own record — restart it afterwards if you closed the pool it is using"
2038    );
2039}
2040
2041/// What a walk of this owner's on-chain pools learned, newest first.
2042///
2043/// The two fields are kept apart because their absence means opposite things.
2044/// A pool missing from [`Self::read`] because its `getPool` call faulted is
2045/// **not** known to be closed, and reading it as closed is what would let one
2046/// transient RPC error delete a live pool's row.
2047struct OwnedPools {
2048    /// Every pool whose state was read successfully, with that state, newest
2049    /// first. Includes pools of every status, not only `Open`.
2050    read: Vec<(PoolId, PaymentPool::Pool)>,
2051    /// Ids whose `getPool` call faulted. Nothing is known about these.
2052    unreadable: Vec<PoolId>,
2053}
2054
2055/// What the chain says about the pool the store tracks.
2056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2057enum TrackedStatus {
2058    /// Read successfully and `Open`: this is the pool in use.
2059    Open,
2060    /// Read successfully and not `Open`: the row may be dropped.
2061    NotOpen,
2062    /// Nothing is known — the read faulted, or `getPools` did not list it at
2063    /// all. Either way the row stays.
2064    Unknown,
2065}
2066
2067impl OwnedPools {
2068    /// Walk `ids`, reading each pool's on-chain state.
2069    ///
2070    /// `ids` arrives oldest-first from `getPools`, and this walks it in
2071    /// reverse: a later pool is the one a previous adoption cycle would have
2072    /// been using, and the earlier ones are what the cycle stranded. A pool
2073    /// whose state cannot be read is recorded as unreadable rather than
2074    /// failing the walk — one unreadable pool must not hide a live one behind
2075    /// it, and must not be mistaken for a closed one either.
2076    async fn walk<P: Provider + Clone>(
2077        contract: &PaymentPool::PaymentPoolInstance<P>,
2078        ids: Vec<PoolId>,
2079    ) -> Self {
2080        let mut read = Vec::new();
2081        let mut unreadable = Vec::new();
2082        for pool_id in ids.into_iter().rev() {
2083            match contract.getPool(pool_id).call().await {
2084                Ok(pool) => read.push((pool_id, pool)),
2085                Err(err) => {
2086                    warn!(
2087                        %pool_id,
2088                        error = %sanitize_error_sources(&err),
2089                        "could not read an owned pool's state; this boot cannot say whether it \
2090                         is open, so it is neither adopted nor reported as stranded"
2091                    );
2092                    unreadable.push(pool_id);
2093                }
2094            }
2095        }
2096        Self { read, unreadable }
2097    }
2098
2099    /// What the chain says about `pool_id`.
2100    ///
2101    /// Answers [`TrackedStatus::Unknown`] for anything not read successfully,
2102    /// including an id `getPools` never listed. `getPools` is append-only —
2103    /// it derives ids from `ownerPoolNonce`, and `closePool`/`reclaim` only
2104    /// change a pool's status — so a tracked id it does not list is an
2105    /// anomaly, not evidence the pool is gone. Destroying a row on that
2106    /// evidence is exactly the mistake this type exists to prevent.
2107    fn tracked_status(&self, pool_id: PoolId) -> TrackedStatus {
2108        self.read.iter().find(|(id, _)| *id == pool_id).map_or(
2109            TrackedStatus::Unknown,
2110            |(_, pool)| {
2111                if matches!(pool.status, PaymentPool::Status::Open) {
2112                    TrackedStatus::Open
2113                } else {
2114                    TrackedStatus::NotOpen
2115                }
2116            },
2117        )
2118    }
2119
2120    /// Every pool read as `Open`, newest first.
2121    fn open(&self) -> impl Iterator<Item = &(PoolId, PaymentPool::Pool)> {
2122        self.read
2123            .iter()
2124            .filter(|(_, pool)| matches!(pool.status, PaymentPool::Status::Open))
2125    }
2126
2127    /// The newest `Open` pool that still has something to spend.
2128    ///
2129    /// A fully-redeemed pool is still `Open` on chain, and adopting one would
2130    /// wedge the node: `reuse_or_report` would answer `Some` forever, so no
2131    /// fresh pool would ever open, against a deposit that can fund no voucher.
2132    fn newest_solvent(&self) -> Option<(PoolId, PaymentPool::Pool)> {
2133        self.open()
2134            .find(|(_, pool)| pool.deposit > pool.totalRedeemed)
2135            .map(|(id, pool)| (*id, pool.clone()))
2136    }
2137
2138    /// Every open pool other than `in_use` that still holds something to
2139    /// recover — the set [`report_stranded_pools`] names.
2140    ///
2141    /// Solvency is the filter because the warning promises the deposits are
2142    /// recoverable, and a fully-redeemed pool refunds nothing: `reclaim`
2143    /// returns `deposit - totalRedeemed`. It is the same test that decides
2144    /// adoptability, so a pool this node would refuse to adopt is also one it
2145    /// will not ask an operator to chase.
2146    ///
2147    /// Pools whose read faulted are absent, so this list can under-report. The
2148    /// per-pool warning in [`Self::walk`] is what says the answer is partial.
2149    fn recoverable_beside(&self, in_use: PoolId) -> Vec<PoolId> {
2150        self.open()
2151            .filter(|(id, pool)| *id != in_use && pool.deposit > pool.totalRedeemed)
2152            .map(|(id, _)| *id)
2153            .collect()
2154    }
2155}
2156
2157/// Open the node's buyer pool and persist it, unless a concurrent open already
2158/// landed one for this owner (the fast-path miss that spawned this task read the
2159/// store BEFORE the slot lock, so a previous open could have persisted in the
2160/// gap). A tracked pool named `replacing` no longer accepts funds, so it does not
2161/// count as that landed pool. Detached, so every failure leg reports for itself:
2162/// by the time it fails there may be nobody waiting to observe the `Err`.
2163#[allow(clippy::too_many_arguments)]
2164#[allow(clippy::cognitive_complexity)]
2165async fn run_open<P: Provider + Clone>(
2166    contract: &PaymentPool::PaymentPoolInstance<P>,
2167    store: &Arc<dyn BuyerPoolStore>,
2168    signer: Arc<PrivateKeySigner>,
2169    deployment: Deployment,
2170    token: Address,
2171    owner: Address,
2172    deposit: U256,
2173    replacing: Option<PoolId>,
2174    metrics: &Arc<Metrics>,
2175) -> Result<()> {
2176    // Re-check under the slot: a previous open for this owner may have persisted
2177    // between the caller's fast-path miss and our claiming the slot. Only a row on
2178    // this deployment counts: a foreign row that bootstrap could not drop is
2179    // invisible to the fast path too, so treating it as live would skip the open
2180    // on every pull.
2181    match store.get_by_owner(owner) {
2182        Ok(Some(state)) if state.is_on(deployment) && Some(state.pool_id) != replacing => {
2183            debug!("a live buyer pool appeared while claiming the open slot; not opening a second");
2184            return Ok(());
2185        }
2186        Ok(_) => {}
2187        Err(err) => {
2188            error!(error = %err, "buyer pool store read failed under the open slot; cannot open a pool");
2189            metrics.node_pull_pool_open_failure();
2190            return Err(anyhow::Error::new(err))
2191                .context("look up the node's buyer pool under the open slot")
2192                .context(OpenReported)
2193                .context(LocalPullFault);
2194        }
2195    }
2196
2197    // The open task is the reporter for every one of its legs, so this one meters
2198    // and logs here and marks the error `OpenReported` — a caller still waiting on
2199    // the shared open must not restate it. Metering the by-reason sibling here too
2200    // is what keeps the unlabeled total and the family reconcilable: the classifier's
2201    // residual arm never sees a leg that reports itself.
2202    //
2203    // `InsufficientDeposit` and `RpcError` are additionally `LocalPullFault`, because
2204    // neither can be answered by trying a different provider. `openPool(uint64 deposit)`
2205    // names no provider: every candidate re-runs the identical call against the identical
2206    // contract through the identical RPC. A wallet that cannot fund a deposit cannot pay
2207    // anyone, and a chain lane that cannot carry the transaction cannot carry it for
2208    // anyone — so walking the candidate list burns `MAX_PROVIDER_ATTEMPTS` futile opens.
2209    // The marker stops that walk at the first failure. What the client
2210    // hears is the pool-open classifier's call, not the marker's: a wallet that cannot
2211    // fund a deposit answers `NotFound`, because the funding problem is this node's, not
2212    // the requester's (ADR 005 §Open-time refusal classes) (#1560).
2213    //
2214    // `ContractRevert` stays unmarked: it is deterministic on-chain state (a paused
2215    // contract, a future revert reason), it is metered by reason, and it does not say
2216    // this node is unable to pay — another candidate may still deliver.
2217    let opened = match open_pool(contract, signer, deployment, token, owner, deposit).await {
2218        Ok(opened) => opened,
2219        Err(err) => {
2220            error!(error = %sanitize_err_chain(&err), "buyer pool open failed");
2221            metrics.node_pull_pool_open_failure();
2222            let reason = err.downcast_ref::<PoolOpenFailureReason>().copied();
2223            if let Some(reason) = reason {
2224                metrics.pool_open_failure_by_reason(reason);
2225            }
2226            let node_wide = matches!(
2227                reason,
2228                Some(PoolOpenFailureReason::InsufficientDeposit | PoolOpenFailureReason::RpcError)
2229            );
2230            let err = err.context(OpenReported);
2231            return Err(if node_wide {
2232                err.context(LocalPullFault)
2233            } else {
2234                err
2235            });
2236        }
2237    };
2238
2239    if let Err(err) = store.record(&opened.state) {
2240        // The deposit is escrowed on-chain (`openPool` mined) but the row could not
2241        // be persisted — escrowed-but-untracked. Log the tx so an operator can
2242        // reconcile; the funds are safe on-chain, and re-persisting is idempotent.
2243        error!(
2244            tx = %opened.tx,
2245            pool_id = %opened.state.pool_id,
2246            error = %err,
2247            "buyer pool opened on-chain but its row could not be persisted; the deposit is \
2248             escrowed but UNTRACKED — reconcile against the tx"
2249        );
2250        metrics.node_pull_pool_open_failure();
2251        return Err(anyhow::Error::new(err))
2252            .context("persist opened buyer pool")
2253            .context(OpenReported)
2254            .context(LocalPullFault);
2255    }
2256    info!(pool_id = %opened.state.pool_id, %deposit, "opened buyer payment pool");
2257    Ok(())
2258}
2259
2260/// Background reclaim sweep: complete the grace-window `reclaim` of the node's
2261/// pool once it has been closed and its dispute window has elapsed, refunding the
2262/// residual and dropping the local row. Best-effort — errors are logged, never
2263/// fatal. The node does not auto-close its pool (it is long-lived and reused); a
2264/// close is initiated on shutdown/operator action, and this sweep finishes the
2265/// refund permissionlessly (ADR 003 § grace-window close).
2266async fn reclaim_loop<P: Provider + Clone>(
2267    contract: PaymentPool::PaymentPoolInstance<P>,
2268    store: Arc<dyn BuyerPoolStore>,
2269    owner: Address,
2270    token: Address,
2271    metrics: Arc<Metrics>,
2272) {
2273    let mut ticker = tokio::time::interval(RECLAIM_SWEEP_INTERVAL);
2274    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2275    loop {
2276        ticker.tick().await;
2277        reclaim_once(&contract, &store, owner, &metrics).await;
2278        publish_buyer_wallet_usdc(contract.provider(), token, owner, &metrics).await;
2279    }
2280}
2281
2282/// Read the buyer wallet's USDC balance and publish `decdn_buyer_wallet_usdc`.
2283///
2284/// Rides the reclaim sweep rather than a task of its own: the balance moves
2285/// only when this node opens or tops up a pool, so a sweep-cadence read is
2286/// ample, and it costs one `eth_call` an hour. Published at bootstrap too, so
2287/// the gauge is true from the first scrape instead of reading zero until the
2288/// first sweep — which is the exact reading the gauge exists to distinguish.
2289///
2290/// Best-effort: a failed read leaves the previous value standing rather than
2291/// publishing a zero the operator would read as an empty wallet.
2292async fn publish_buyer_wallet_usdc<P: Provider>(
2293    provider: &P,
2294    token: Address,
2295    owner: Address,
2296    metrics: &Arc<Metrics>,
2297) {
2298    match Erc20::new(token, provider).balanceOf(owner).call().await {
2299        Ok(balance) => metrics.set_buyer_wallet_usdc(balance),
2300        Err(err) => {
2301            debug!(%token, %owner, error = %sanitize_error_sources(&err), "could not read the buyer wallet's USDC balance");
2302        }
2303    }
2304}
2305
2306/// Chain head block timestamp. The grace-window pre-check compares against
2307/// `disputeDeadline`, which the contract set from `block.timestamp`, so it reads
2308/// the same clock the contract's `reclaim` gate uses rather than the node's wall
2309/// clock.
2310async fn head_timestamp<P: Provider>(provider: &P) -> Result<u64> {
2311    let block = provider
2312        .get_block(BlockId::latest())
2313        .await
2314        .context("read the latest block for the reclaim grace-window pre-check")?
2315        .ok_or_else(|| anyhow::anyhow!("no latest block"))?;
2316    Ok(block.header.timestamp)
2317}
2318
2319/// One reclaim pass. Reads the node's tracked pool; if the on-chain pool has been
2320/// closed (`disputeDeadline > 0`) and its grace window has elapsed, submits
2321/// `reclaim` and forgets the row on success.
2322#[allow(clippy::cognitive_complexity)]
2323async fn reclaim_once<P: Provider + Clone>(
2324    contract: &PaymentPool::PaymentPoolInstance<P>,
2325    store: &Arc<dyn BuyerPoolStore>,
2326    owner: Address,
2327    metrics: &Arc<Metrics>,
2328) {
2329    let Some(state) = (match store.get_by_owner(owner) {
2330        Ok(state) => state,
2331        Err(err) => {
2332            warn!(error = %err, "reclaim sweep: buyer pool store read failed");
2333            metrics.buyer_reclaim_failure();
2334            return;
2335        }
2336    }) else {
2337        return; // no pool tracked
2338    };
2339
2340    let pool = match contract.getPool(state.pool_id).call().await {
2341        Ok(pool) => pool,
2342        Err(err) => {
2343            warn!(pool_id = %state.pool_id, error = %sanitize_error_sources(&err), "reclaim sweep: getPool failed");
2344            metrics.buyer_reclaim_failure();
2345            return;
2346        }
2347    };
2348
2349    // Reclaim is permissionless, so another caller may have reclaimed the pool
2350    // first. Nothing is left to reclaim, and a row that survives sends every later
2351    // fill to a pool that nodes answer `NotFound`, which no funding recovery step
2352    // replaces. Drop the row, so the next fill opens a new pool.
2353    if matches!(pool.status, PaymentPool::Status::Closed) {
2354        match store.forget_if_pool(owner, state.pool_id) {
2355            Ok(_) => info!(
2356                pool_id = %state.pool_id,
2357                "the buyer pool was already reclaimed; dropped the row"
2358            ),
2359            Err(err) => {
2360                warn!(pool_id = %state.pool_id, error = %err, "reclaim sweep: forget of a reclaimed pool failed");
2361                metrics.buyer_reclaim_failure();
2362            }
2363        }
2364        return;
2365    }
2366
2367    // Not closed: nothing to reclaim yet.
2368    if pool.disputeDeadline == 0 {
2369        return;
2370    }
2371
2372    // Still inside the grace window: skip the doomed submit. The deadline is
2373    // chain time (`closePool` set it from `block.timestamp`), so this pre-check
2374    // reads the chain head — not the node's wall clock — to match the
2375    // `block.timestamp >= disputeDeadline` gate the contract enforces on
2376    // `reclaim`. A head-read failure leaves the check optimistic (attempt the
2377    // reclaim; the contract is the authoritative backstop).
2378    match head_timestamp(contract.provider()).await {
2379        Ok(now) if now < pool.disputeDeadline => return,
2380        Ok(_) => {}
2381        Err(err) => {
2382            debug!(pool_id = %state.pool_id, error = %sanitize_err_chain(&err), "reclaim sweep: chain head read failed; attempting reclaim anyway");
2383        }
2384    }
2385
2386    match contract.reclaim(state.pool_id).send().await {
2387        Ok(pending) => match pending.get_receipt().await {
2388            Ok(receipt) if receipt.status() => {
2389                // Only the `Err` leaves the row's fate unknown — `Ok(false)`
2390                // means the compare-and-delete found no row for this pool, so
2391                // nothing maps the owner to it. A surviving row would send a
2392                // later reuse back to a `Closed` pool whose `deposit` field
2393                // still reads healthy, so meter it like the sibling arms
2394                // instead of dropping out of the sweep silently.
2395                if let Err(err) = store.forget_if_pool(owner, state.pool_id) {
2396                    warn!(pool_id = %state.pool_id, error = %err, "reclaim sweep: forget after reclaim failed");
2397                    metrics.buyer_reclaim_failure();
2398                    return;
2399                }
2400                info!(pool_id = %state.pool_id, "reclaimed the buyer pool residual and dropped the row");
2401            }
2402            Ok(_) => {
2403                warn!(pool_id = %state.pool_id, "reclaim sweep: reclaim reverted");
2404                metrics.buyer_reclaim_failure();
2405            }
2406            Err(err) => {
2407                warn!(pool_id = %state.pool_id, error = %sanitize_error_sources(&err), "reclaim sweep: reclaim receipt failed");
2408                metrics.buyer_reclaim_failure();
2409            }
2410        },
2411        Err(err) => {
2412            warn!(pool_id = %state.pool_id, error = %sanitize_error_sources(&err), "reclaim sweep: reclaim submit failed");
2413            metrics.buyer_reclaim_failure();
2414        }
2415    }
2416}
2417
2418#[allow(
2419    clippy::unwrap_used,
2420    clippy::expect_used,
2421    clippy::indexing_slicing,
2422    reason = "workspace anti-panic policy targets runtime code"
2423)]
2424#[cfg(test)]
2425mod tests;