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;