decdn_client/buyer_pool.rs
1//! Buyer-side `PaymentPool` open kernel, shared by the node's node-to-node
2//! cache-miss buyer (#744) and the CLI (`decdn fetch`, #940).
3//!
4//! The genuinely duplication-prone part — the `openPool` transaction, the
5//! authoritative `PoolOpened`-from-receipt decode, the self-owned capability the
6//! single-user buyer signs for its own key, and the [`BuyerPoolState`] /
7//! [`PoolContext`] construction for a self-owned lane
8//! ([`self_owned_lane_ctx`](crate::buyer_pool::self_owned_lane_ctx)) —
9//! lives here, with the rule that picks a lane's progress write
10//! ([`ProgressWrite`](crate::buyer_pool::ProgressWrite)). Pool *reuse* is a thin
11//! composition over [`decdn_incentive::BuyerPoolStore::get_by_owner`] that each
12//! caller does directly: a one-shot CLI fetch needs neither the node service's
13//! per-owner concurrency guard nor its background reclaim/reconcile machinery.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use alloy::dyn_abi::Eip712Domain;
19use alloy::primitives::{Address, B256, TxHash, U256};
20use alloy::providers::Provider;
21use alloy::signers::local::PrivateKeySigner;
22use anyhow::{Context, Result};
23use decdn_incentive::erc20::Erc20;
24use decdn_incentive::payment_pool::{PaymentPool, to_pool_u64};
25use decdn_incentive::{
26 AdvanceOutcome, BuyerLaneProgress, BuyerPoolState, BuyerPoolStore, Capability, Deployment,
27 DepositOutcome, LaneKey, PoolId, PoolOpenFailureReason, SignedCapability, StoreError,
28};
29use tracing::{debug, error, info, warn};
30
31use crate::{PoolContext, VoucherProgress};
32
33/// Re-approve the `PaymentPool` spender for the *unlimited* case when the
34/// standing USDC allowance has fallen below this floor. Half of `U256::MAX` so
35/// one max approval covers effectively unlimited deposits and a re-run with the
36/// approval already in place skips the redundant `approve`, while a
37/// never-approved wallet (allowance `0`) trips it. Only consulted for the
38/// unlimited mode; the exact mode compares against the requested amount instead
39/// (see [`approve_decision`]).
40fn approval_floor() -> U256 {
41 U256::MAX >> 1
42}
43
44/// Decide, purely, what `approve` value (if any) to issue given the wallet's
45/// `current` standing allowance and the requested approval `amount`:
46///
47/// - `amount == None` (unlimited): skip when `current >= approval_floor()`,
48/// otherwise approve `U256::MAX` — the node/operator posture that avoids
49/// re-approve churn.
50/// - `amount == Some(needed)` (exact, the client default): skip when
51/// `current >= needed`, otherwise approve exactly `needed`. Because the skip
52/// threshold *is* `needed`, a wallet that previously granted an unlimited
53/// (`U256::MAX`) allowance always skips — switching a client to exact never
54/// re-approves the allowance *downward*.
55fn approve_decision(current: U256, amount: Option<U256>) -> Option<U256> {
56 let (threshold, approve_value) = match amount {
57 None => (approval_floor(), U256::MAX),
58 Some(needed) => (needed, needed),
59 };
60 if current >= threshold {
61 None
62 } else {
63 Some(approve_value)
64 }
65}
66
67/// Bound the wait for the one-time USDC `approve` receipt so a stuck or
68/// underpriced tx can't wedge the buyer path (#1109). Sized well above a normal
69/// inclusion window but short enough that the worst case is a few minutes. On
70/// timeout the broadcast tx may still mine later; the allowance read at the top
71/// of the next run makes the re-approve idempotent, so no funds are stranded.
72const APPROVE_RECEIPT_TIMEOUT: Duration = Duration::from_mins(3);
73
74/// A freshly opened buyer pool: the persistable [`BuyerPoolState`], a
75/// ready-to-sign [`PoolContext`], the self-owned [`SignedCapability`] the buyer
76/// registers on its first redemption, and the open transaction hash. The deposit
77/// is on-chain the moment `openPool` mines, so a caller whose subsequent
78/// `store.record` fails names that tx for manual reconciliation — an error in the
79/// CLI, an `error!` in the daemon.
80#[derive(Debug)]
81#[non_exhaustive]
82#[must_use = "the open tx is the only handle to an escrowed-but-untracked deposit"]
83pub struct OpenedPool {
84 /// Persist this via `BuyerPoolStore::record`.
85 pub state: BuyerPoolState,
86 /// Use this to sign vouchers for the new pool. The fetch target's provider
87 /// address and the lane priors are pinned per-pull via
88 /// [`PoolContext::with_provider`].
89 pub ctx: PoolContext,
90 /// The owner-signed grant delegating spend on this pool to the buyer's own
91 /// signing key, regenerated per open and never stored.
92 pub capability: SignedCapability,
93 /// The `openPool` transaction hash.
94 pub tx: TxHash,
95}
96
97/// The outcome of a mined [`top_up`]: what the contract actually credited, and
98/// the transaction that credited it.
99///
100/// The tx hash rides along for the same reason [`OpenedPool::tx`] does. The
101/// funds are escrowed the moment this returns, so a caller whose local credit
102/// then fails has to name the transaction an operator reconciles against — see
103/// [`escrowed_but_untracked`].
104#[derive(Debug, Clone, Copy)]
105#[non_exhaustive]
106#[must_use = "the top-up tx is the only handle to an escrowed-but-untracked deposit"]
107pub struct ToppedUpPool {
108 /// The amount the contract credited, read back from the receipt.
109 pub credited: U256,
110 /// The `topUp` transaction hash.
111 pub tx: TxHash,
112}
113
114/// Build the error for "the on-chain effect landed, the local record did not".
115///
116/// The funds have already moved when this is reached, so a non-zero exit is the
117/// only outcome that guarantees anyone reconciles: a caller that logs and
118/// returns success is indistinguishable from one that did the work, and a shell
119/// wrapper (`if decdn pool top-up …; then mark_funded; fi`) records the money as
120/// tracked when it is not.
121///
122/// `effect` names what landed on-chain in the operator's vocabulary, as a
123/// past-tense clause that reads correctly with ` on-chain` appended
124/// (`buyer pool opened`, `pool 0x… topped up by 5 µUSDC`). `tx` is the handle to
125/// reconcile against, and `cause` is the local failure.
126///
127/// The message steers the operator away from the obvious response to a non-zero
128/// exit: re-running an escrow that already landed escrows a second time.
129#[must_use]
130pub fn escrowed_but_untracked(
131 effect: &str,
132 tx: TxHash,
133 cause: impl std::fmt::Display,
134) -> anyhow::Error {
135 anyhow::Error::new(EscrowUntracked(format!(
136 "{effect} on-chain (tx {tx}) but the local record did not survive; the deposit is \
137 escrowed but untracked — reconcile against the tx before retrying, because a retry \
138 escrows again: {cause}"
139 )))
140}
141
142/// The error [`escrowed_but_untracked`] builds: USDC moved on-chain and the
143/// local record does not account for it. A retry escrows again, so the acquire
144/// loop classifies it as fatal to the command ([`crate::Fault::Fatal`]) instead
145/// of retrying the lane build.
146#[derive(Debug)]
147pub struct EscrowUntracked(pub(crate) String);
148
149impl std::fmt::Display for EscrowUntracked {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.write_str(&self.0)
152 }
153}
154
155impl std::error::Error for EscrowUntracked {}
156
157/// The `effect` clause for a landed `topUp`, in the past-tense shape
158/// [`escrowed_but_untracked`] expects.
159///
160/// Every caller that grades a top-up credit names the same effect, and the
161/// amount and pool are what an operator reconciles the escrow against — so the
162/// wording lives here rather than being written out per call site.
163#[must_use]
164pub fn topped_up_effect(pool_id: B256, credited: U256) -> String {
165 format!("pool {pool_id} topped up by {credited} µUSDC")
166}
167
168/// Grade the local credit that follows a mined [`top_up`], turning every
169/// not-credited outcome into an [`escrowed_but_untracked`] error.
170///
171/// [`decdn_incentive::BuyerPoolStore::add_deposit`] splits its failures across two channels by
172/// design: a backend or codec fault is the `Err`, while a committed-row
173/// mismatch (the row vanished, or now tracks a different pool) is a non-`Added`
174/// `Ok`. For a caller standing over freshly escrowed USDC the distinction does
175/// not change the disposition — either way the deposit moved on-chain and the
176/// local row is short by `credited` — so both collapse here. `DepositOutcome`
177/// is `#[must_use]` for exactly this reason: a dropped `PoolMismatch` looks
178/// identical to a successful credit.
179///
180/// Returns the new committed deposit on success.
181///
182/// # Errors
183///
184/// Errors on any outcome other than [`DepositOutcome::Added`].
185pub fn grade_deposit_credit(
186 outcome: Result<DepositOutcome, StoreError>,
187 effect: &str,
188 tx: TxHash,
189) -> Result<U256> {
190 match outcome {
191 Ok(DepositOutcome::Added(new_deposit)) => Ok(new_deposit),
192 Ok(other) => Err(escrowed_but_untracked(
193 effect,
194 tx,
195 format!("the local record was not updated: {other:?}"),
196 )),
197 Err(e) => Err(escrowed_but_untracked(effect, tx, e)),
198 }
199}
200
201/// Sign a self-owned [`SignedCapability`]: the pool owner delegates spend on
202/// `pool_id` to its OWN signing key, up to `spending_cap`, until `expiry`. This
203/// is the single-user case — the owner and the voucher signer are the same key —
204/// so the capability is regenerated from the key per connection and never stored.
205///
206/// The serving node registers it on the buyer's first on-chain redemption
207/// (`redeemMany`'s `CapabilityReg`), then accepts vouchers from this signer up to
208/// the cap (ADR 003 §Capability delegation).
209///
210/// # Errors
211///
212/// Propagates a signing error from the owner signer.
213#[doc(hidden)]
214pub fn issue_self_capability(
215 owner_signer: &PrivateKeySigner,
216 pool_id: B256,
217 spending_cap: u64,
218 expiry: u64,
219 voucher_domain: &Eip712Domain,
220) -> Result<SignedCapability> {
221 Capability {
222 signer: owner_signer.address(),
223 spending_cap,
224 pool_id,
225 expiry,
226 }
227 .sign(owner_signer, voucher_domain)
228 .map_err(|e| anyhow::anyhow!("sign self capability: {e}"))
229}
230
231/// The [`PoolContext`] one lane of a self-owned pool signs under: `signer` pays
232/// `provider` from `state`'s pool, resuming at the lane's prior cumulative
233/// `(prior_bytes, prior_amount)`.
234///
235/// The context carries a freshly issued self-owned capability. The capability
236/// is node-agnostic (valid at every provider this pool pays) and cheap to
237/// regenerate, so a fresh open and a reused pool both present one without a
238/// stored copy. An upstream registers the signer from it on the first on-chain
239/// redemption.
240///
241/// The cap is [`SELF_CAPABILITY_CAP`], not the deposit. The delegate IS the
242/// owner, so the pool deposit is the real spending bound. A cap at the deposit
243/// would freeze the on-chain cap at the opening deposit (`_registerCapability` is
244/// idempotent past the first redemption) and reject spend past a later `topUp`.
245/// It is not `U256::MAX` either: the pool's `spendingCap` is a `uint64`, so a
246/// wider cap hashes to a word the contract cannot reconstruct and every
247/// redemption on the lane reverts.
248///
249/// The provider pin is mandatory: a voucher signed with `provider ==
250/// Address::ZERO` fails at signing, so a context left at
251/// [`PoolContext::for_pool`]'s zero provider cannot pay.
252///
253/// # Errors
254///
255/// Propagates a signing error from `signer` while issuing the capability.
256pub fn self_owned_lane_ctx(
257 state: &BuyerPoolState,
258 signer: &Arc<PrivateKeySigner>,
259 voucher_domain: &Eip712Domain,
260 provider: Address,
261 prior_bytes: U256,
262 prior_amount: U256,
263) -> Result<PoolContext> {
264 let capability = issue_self_capability(
265 signer.as_ref(),
266 state.pool_id,
267 SELF_CAPABILITY_CAP,
268 SELF_CAPABILITY_EXPIRY,
269 voucher_domain,
270 )?;
271 Ok(
272 PoolContext::for_pool(state, Arc::clone(signer), voucher_domain.clone())
273 .with_provider(provider, prior_bytes, prior_amount)
274 .with_capability(capability),
275 )
276}
277
278/// The buyer-store write that records one lane's [`VoucherProgress`], so a later
279/// reuse or a restart resumes the lane at the right cumulative `bytes` /
280/// `amount`.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282#[non_exhaustive]
283pub enum ProgressWrite {
284 /// The lane's ledger rebased to the upstream's authenticated watermark
285 /// `anchor`, behind the record on `amount` (`PoolLedger::rebase`), and no write has recorded it yet. The
286 /// record is overwritten with `anchor` and then advanced to `totals`. A
287 /// monotone advance refuses a lower total, and the next reuse would then sign
288 /// from the anchor the upstream already refused.
289 Rebase {
290 /// The upstream watermark the ledger rebased to.
291 anchor: BuyerLaneProgress,
292 /// The lane's cumulative totals after the rebase.
293 totals: BuyerLaneProgress,
294 },
295 /// A monotone advance to `totals`.
296 Advance {
297 /// The lane's new cumulative totals.
298 totals: BuyerLaneProgress,
299 },
300}
301
302impl ProgressWrite {
303 /// The write `progress` calls for, or `None` when it neither advanced past
304 /// its seed nor carries a rebase anchor. A pending anchor is written even
305 /// when the totals did not advance: the write exists to move the record
306 /// down.
307 #[must_use]
308 pub fn of(progress: &VoucherProgress) -> Option<Self> {
309 let (last_bytes, last_amount) = progress.totals();
310 let totals = BuyerLaneProgress {
311 last_amount,
312 last_bytes,
313 };
314 match progress.rebase_anchor() {
315 Some(anchor) => Some(Self::Rebase {
316 anchor: BuyerLaneProgress {
317 last_amount: anchor.amount,
318 last_bytes: anchor.bytes,
319 },
320 totals,
321 }),
322 None => progress.advanced().map(|_| Self::Advance { totals }),
323 }
324 }
325
326 /// The lane's cumulative totals after this write.
327 #[must_use]
328 pub const fn totals(&self) -> BuyerLaneProgress {
329 match self {
330 Self::Rebase { totals, .. } | Self::Advance { totals } => *totals,
331 }
332 }
333
334 /// Apply this write to `lane` inside `owner`'s pool `pool_id`.
335 ///
336 /// # Errors
337 ///
338 /// Returns a [`StoreError`] only on a backend or codec fault. A write the
339 /// store declines (an unknown or replaced pool, a regression) is the `Ok`
340 /// [`AdvanceOutcome`].
341 pub fn apply<S: BuyerPoolStore + ?Sized>(
342 &self,
343 store: &S,
344 owner: Address,
345 pool_id: PoolId,
346 lane: LaneKey,
347 ) -> Result<AdvanceOutcome, StoreError> {
348 match *self {
349 Self::Rebase { anchor, totals } => {
350 store.rebase_progress(owner, pool_id, lane, anchor, totals)
351 }
352 Self::Advance { totals } => {
353 store.advance_progress(owner, pool_id, lane, totals.last_bytes, totals.last_amount)
354 }
355 }
356 }
357}
358
359/// Ensure `owner` holds a sufficient USDC allowance for the `PaymentPool`
360/// `spender`, issuing at most one `approve` when the current allowance is below
361/// what `amount` requires. `amount == None` grants an unlimited (`U256::MAX`)
362/// allowance — the node/operator posture that avoids re-approve churn;
363/// `amount == Some(deposit)` grants exactly `deposit` — the client default that
364/// keeps the standing spend authority scoped to the deposit being escrowed. See
365/// `approve_decision` for the skip logic. Idempotent across runs — a wallet
366/// already at or above the required allowance skips the tx.
367///
368/// # Errors
369///
370/// Fails on the allowance read, the `approve` submission, or a reverted approve.
371pub async fn ensure_allowance<P: Provider + Clone>(
372 provider: &P,
373 token: Address,
374 owner: Address,
375 spender: Address,
376 amount: Option<U256>,
377) -> Result<()> {
378 let erc20 = Erc20::new(token, provider.clone());
379 let current = erc20
380 .allowance(owner, spender)
381 .call()
382 .await
383 .context("read USDC allowance")?;
384 let Some(approve_value) = approve_decision(current, amount) else {
385 debug!(%current, "USDC allowance already sufficient; skipping approve");
386 return Ok(());
387 };
388 let pending = erc20
389 .approve(spender, approve_value)
390 .send()
391 .await
392 .context("submit USDC approve")?;
393 // Capture the hash before `get_receipt` consumes `pending`, so a timeout
394 // error names the broadcast tx an operator needs to look up (it stays in the
395 // mempool and may still mine after we give up).
396 let approve_tx = *pending.tx_hash();
397 let receipt = tokio::time::timeout(APPROVE_RECEIPT_TIMEOUT, pending.get_receipt())
398 .await
399 .map_err(|_| {
400 anyhow::anyhow!(
401 "USDC approve receipt timed out after {APPROVE_RECEIPT_TIMEOUT:?} \
402 (tx {approve_tx}; may still mine later)"
403 )
404 })?
405 .context("await USDC approve receipt")?;
406 if !receipt.status() {
407 anyhow::bail!("USDC approve transaction reverted");
408 }
409 info!(
410 %token,
411 %spender,
412 %approve_value,
413 unlimited = amount.is_none(),
414 "issued USDC approval for PaymentPool deposits"
415 );
416 Ok(())
417}
418
419/// Open a fresh `PaymentPool` deposit for `owner`, escrowing `deposit` USDC, and
420/// decode the authoritative `poolId` from the receipt's own `PoolOpened` event.
421///
422/// `deployment` is the chain and `PaymentPool` that `contract` talks to. It tags
423/// the returned row and derives the voucher EIP-712 domain, so the row and every
424/// voucher signed on it name the same deployment. A `deployment` whose address
425/// is not `contract`'s is an error, before any transaction.
426///
427/// `openPool` names no provider and no signer — a pool is bound to no payee at
428/// open, and fans out to many `(signer, provider)` lanes off-chain (ADR 003).
429/// The returned [`OpenedPool`] carries a self-owned capability the buyer signs
430/// for its own key (`spending_cap == credited deposit`, no expiry), which the
431/// serving node registers on the first redemption.
432///
433/// Decoding from the receipt (rather than a follow-up `getPool`) is atomic with
434/// the open: a mined tx guarantees the event, so the caller can always persist —
435/// a transient read failure can never leave the deposit orphaned. The event is
436/// filtered on `owner == owner` to confirm we decoded our own open.
437///
438/// `deposit` is the final escrow amount — the caller applies any
439/// `max(hint, default)` clamping before calling. The returned [`OpenedPool`] is
440/// **not yet persisted**: the caller records [`OpenedPool::state`] in its
441/// `BuyerPoolStore`, and on a record failure should log [`OpenedPool::tx`].
442///
443/// # Errors
444///
445/// Fails on `openPool` submit/receipt, a reverted tx, a missing `PoolOpened`
446/// event in the receipt logs, or a capability-signing error. The classified
447/// failure legs attach a [`PoolOpenFailureReason`] into the `anyhow` error chain
448/// (recover it with `err.downcast_ref::<PoolOpenFailureReason>()`) so a caller
449/// can bump the matching `decdn_pool_open_failures_{reason}_total` counter: a
450/// deterministic revert (with ABI revert data, decoded against the
451/// insufficient-deposit error selectors) is split from a transport/RPC fault (no
452/// revert data) and a mined on-chain revert. The missing-`PoolOpened` leg carries
453/// no reason — the deposit is escrowed but untracked, so it surfaces as an
454/// unclassified error for manual reconciliation.
455///
456/// # The receipt wait is deliberately UNBOUNDED
457///
458/// `ensure_allowance`'s `approve` bounds its receipt wait (idempotent — giving up
459/// costs nothing). `openPool` and `top_up` **escrow funds**: giving up on the
460/// receipt does not cancel the tx, so a caller that then believes no open is in
461/// flight would escrow a second deposit against a pool the first tx is still
462/// going to mine. So while an `openPool` is outstanding, the only safe thing is
463/// to keep waiting; the node calls this inside a detached task that holds the
464/// owner's open slot for exactly as long as this future runs (#1143).
465pub async fn open_pool<P: Provider + Clone>(
466 contract: &PaymentPool::PaymentPoolInstance<P>,
467 signer: Arc<PrivateKeySigner>,
468 deployment: Deployment,
469 token: Address,
470 owner: Address,
471 deposit: U256,
472) -> Result<OpenedPool> {
473 anyhow::ensure!(
474 *contract.address() == deployment.payment_pool,
475 "openPool: contract {} is not the deployment's PaymentPool {}",
476 contract.address(),
477 deployment.payment_pool
478 );
479 let voucher_domain = deployment.voucher_domain();
480 let pending = match contract
481 .openPool(to_pool_u64(deposit, "deposit")?)
482 .send()
483 .await
484 {
485 Ok(pending) => pending,
486 Err(err) => {
487 // A deterministic revert (a zero deposit, USDC balance/allowance too
488 // low) is caught at gas estimation, so it surfaces here with ABI
489 // revert data attached; a send error *without* revert data is a
490 // transport/RPC fault.
491 let reason = PoolOpenFailureReason::classify_revert_data(err.as_revert_data().as_ref());
492 return Err(anyhow::Error::new(err))
493 .context("submit openPool")
494 .context(reason);
495 }
496 };
497 // Unbounded by design — see the `# The receipt wait is deliberately UNBOUNDED`
498 // section above.
499 let receipt = pending.get_receipt().await.map_err(|err| {
500 anyhow::Error::new(err)
501 .context("await openPool receipt")
502 .context(PoolOpenFailureReason::RpcError)
503 })?;
504 if !receipt.status() {
505 // A mined revert: the reason is not recoverable from the receipt (no
506 // trace), so it folds into `ContractRevert`.
507 return Err(anyhow::anyhow!(
508 "openPool reverted (owner {owner}, deposit {deposit}); check USDC balance/allowance"
509 )
510 .context(PoolOpenFailureReason::ContractRevert));
511 }
512 let tx = receipt.transaction_hash;
513
514 let Some(opened) = receipt
515 .inner
516 .logs()
517 .iter()
518 .filter_map(|log| log.log_decode::<PaymentPool::PoolOpened>().ok())
519 .map(|decoded| decoded.inner.data)
520 .find(|ev| ev.owner == owner)
521 else {
522 // The deposit is escrowed on-chain and we cannot name the pool it bought.
523 // Log it HERE rather than leaving it to the caller: the node's caller is a
524 // detached task whose `Err` nobody may be waiting on (#1143).
525 error!(
526 %tx,
527 %owner,
528 %deposit,
529 "openPool mined but its PoolOpened event is missing from the receipt logs; \
530 the deposit is escrowed on-chain but UNTRACKED — reconcile manually against the tx"
531 );
532 anyhow::bail!(
533 "PoolOpened event for owner {owner} not found in openPool receipt logs (tx {tx}); \
534 the deposit is escrowed on-chain but untracked — reconcile manually"
535 );
536 };
537 let pool_id = opened.poolId;
538 // Record what the contract CREDITED, not what we asked it to transfer.
539 // `openPool` sets the pool deposit to the measured balance delta and emits
540 // that in `PoolOpened.deposit`, precisely so a fee-on-transfer token cannot
541 // over-state a pool against the shared USDC balance.
542 let credited = opened.deposit;
543 let state = BuyerPoolState::new(pool_id, deployment, owner, token, credited);
544 // A self-owned capability delegates spend to the owner's OWN key, so the cap
545 // bounds nothing a delegated capability would: the pool deposit is already
546 // the real spending bound (redemption pays min(desired, cap-spent,
547 // remaining), and `remaining` is the pool balance). Leave it at the widest
548 // cap the pool's `uint64` field can hold, so a later `topUp` beyond the
549 // opening deposit is redeemable too.
550 let capability = issue_self_capability(
551 signer.as_ref(),
552 pool_id,
553 SELF_CAPABILITY_CAP,
554 SELF_CAPABILITY_EXPIRY,
555 &voucher_domain,
556 )?;
557 let ctx = PoolContext::for_pool(&state, signer, voucher_domain);
558 info!(
559 %owner,
560 %pool_id,
561 requested = %deposit,
562 credited = %credited,
563 "opened buyer payment pool"
564 );
565 Ok(OpenedPool {
566 state,
567 ctx,
568 capability,
569 tx,
570 })
571}
572
573/// Expiry stamped on the self-owned capability [`open_pool`] signs. The
574/// single-user buyer owns both keys, so there is no delegation to time-box —
575/// `u64::MAX` means "never expires", and the pool's own grace-window close is the
576/// only lifecycle gate (there is no pool expiry, ADR 003).
577const SELF_CAPABILITY_EXPIRY: u64 = u64::MAX;
578
579/// Cap stamped on a self-owned capability — the widest value the pool's
580/// `uint64 spendingCap` can hold, which is the effective "no
581/// delegated ceiling" for an owner spending against its own pool. The real
582/// bound is the deposit: redemption pays `min(desired, cap - spent, remaining)`.
583/// A `u64` by type, matching the contract field exactly, so it can never sign a
584/// cap the narrower on-chain field cannot reconstruct.
585#[doc(hidden)]
586pub const SELF_CAPABILITY_CAP: u64 = u64::MAX;
587
588/// Typed marker attached to a `top_up` submit error whose revert is an
589/// `ERC20InsufficientAllowance` shortfall: the `PaymentPool`'s standing USDC
590/// allowance is below the transfer amount. The node's `fund_pool` downcasts
591/// on it to run a just-in-time `approve` and retry once; every other revert
592/// stays terminal. CLI callers never downcast it, so it is invisible to them.
593#[derive(Debug, Clone, Copy)]
594pub struct AllowanceShortfall;
595
596impl std::fmt::Display for AllowanceShortfall {
597 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598 write!(
599 f,
600 "topUp reverted: PaymentPool USDC allowance is below the transfer amount"
601 )
602 }
603}
604
605impl std::error::Error for AllowanceShortfall {}
606
607/// Typed marker on a [`top_up`] error raised when the `topUp` may have been
608/// broadcast but is not known to have mined: its submit failed in transport
609/// (a timeout, a reset, an HTTP 5xx), or it was broadcast and its receipt
610/// could not be read. The transaction may still mine, so the escrow may have
611/// moved while no local record credits it. A caller must not treat it as
612/// "nothing escrowed"; the acquire loop classifies it as fatal to the command
613/// ([`crate::Fault::Fatal`]).
614#[derive(Debug, Clone, Copy)]
615pub struct TopUpUnconfirmed {
616 /// The broadcast `topUp` transaction, which an operator reconciles
617 /// against. `None` when the submit failed in transport: the RPC node may
618 /// have broadcast the transaction without returning its hash.
619 pub tx: Option<TxHash>,
620 /// The nonce the `topUp` was sent with. Once the account's confirmed nonce
621 /// passes it, the `topUp` has either mined or can never mine.
622 pub nonce: u64,
623}
624
625impl std::fmt::Display for TopUpUnconfirmed {
626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627 match self.tx {
628 Some(tx) => write!(
629 f,
630 "topUp tx {tx} (nonce {}) was broadcast but its receipt was not read, so it \
631 may have mined; reconcile against the tx before retrying, because a retry \
632 escrows again",
633 self.nonce
634 ),
635 None => write!(
636 f,
637 "topUp submit (nonce {}) failed in transport, so the RPC node may have \
638 broadcast it and it may mine; reconcile against the account's nonce before \
639 retrying, because a retry escrows again",
640 self.nonce
641 ),
642 }
643 }
644}
645
646impl std::error::Error for TopUpUnconfirmed {}
647
648/// Typed marker on a failed top-up whose funder found the buyer's wallet
649/// holding too little USDC for it. No retry fixes that, so the acquire loop
650/// classifies it [`crate::Fault::Unaffordable`], and a funding recovery step
651/// that fails with it ends the fetch "funding needed".
652#[derive(Debug, Clone, Copy)]
653pub struct WalletShortfall;
654
655impl std::fmt::Display for WalletShortfall {
656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657 f.write_str("the wallet holds too little USDC to top up the pool")
658 }
659}
660
661impl std::error::Error for WalletShortfall {}
662
663/// Typed marker on a [`top_up`] error whose gas estimate reverted
664/// `PoolNotOpen`: the pool is `Closing` or `Closed`, so it accepts no funds and
665/// is never reopened. The funding recovery step opens a new pool instead (ADR
666/// 003 § Funding recovery).
667#[derive(Debug, Clone, Copy)]
668pub struct PoolNotOpen;
669
670impl std::fmt::Display for PoolNotOpen {
671 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672 f.write_str("topUp reverted PoolNotOpen: the pool is closing or closed")
673 }
674}
675
676impl std::error::Error for PoolNotOpen {}
677
678/// Whether `pool_id` still accepts funds: the gas estimate of a one-unit
679/// `topUp` from `owner`, with no transaction sent. A `PoolNotOpen` revert says
680/// no. Any other estimate failure (an allowance or balance short of one unit,
681/// a transport fault, a revert the decoder cannot read) says nothing about the
682/// pool, so the pool's status is read instead: `Closing` or `Closed` says no.
683/// When that read fails too, the pool reads as open, and the warning names
684/// both failures.
685///
686/// The funding recovery step checks this when the pool already holds its
687/// working deposit and there is nothing to add: a full pool that is `Closing`
688/// or `Closed` still needs a replacement.
689pub async fn pool_accepts_funds<P: Provider + Clone>(
690 contract: &PaymentPool::PaymentPoolInstance<P>,
691 owner: Address,
692 pool_id: B256,
693) -> bool {
694 let estimate = match contract.topUp(pool_id, 1).from(owner).estimate_gas().await {
695 Ok(_) => return true,
696 Err(err) if decdn_incentive::is_pool_not_open(err.as_revert_data().as_ref()) => {
697 return false;
698 }
699 Err(err) => err,
700 };
701 match contract.getPool(pool_id).call().await {
702 Ok(pool) => !matches!(
703 pool.status,
704 PaymentPool::Status::Closing | PaymentPool::Status::Closed
705 ),
706 Err(read) => {
707 tracing::warn!(
708 %pool_id,
709 error = %format_args!("topUp estimate: {estimate}; getPool: {read}"),
710 "could not tell whether the buyer pool still accepts funds; treating it as open"
711 );
712 true
713 }
714 }
715}
716
717/// How many times [`top_up`] re-sends a submit rejected as a nonce collision.
718const TOPUP_NONCE_RETRIES: u32 = 3;
719
720/// How long [`top_up`] waits before re-sending after a nonce collision: long
721/// enough for the colliding transaction to reach the pending pool, so the next
722/// pending-nonce read skips past it.
723const TOPUP_NONCE_BACKOFF: Duration = Duration::from_millis(250);
724
725/// Add `additional` USDC to the buyer pool `pool_id` on-chain and return the
726/// credited amount and the mining transaction as a [`ToppedUpPool`] — the shared
727/// mechanism behind the node's cache-miss buyer (#744) and the CLI fetch buyer's
728/// auto-refill (#1103). `topUp` does not extend any lifecycle deadline; the caller
729/// credits `credited` into its local [`BuyerPoolState`] via `add_deposit`, and
730/// names `tx` if that credit fails — see [`escrowed_but_untracked`].
731///
732/// The amount credited is read back from `PoolToppedUp.additionalDeposit`, not
733/// assumed to equal `additional`: the contract credits a measured balance delta,
734/// so the two differ under a fee-on-transfer token and the local row must not
735/// over-state the on-chain deposit. If the event is absent (an ABI skew), the
736/// requested `additional` is returned as the best available estimate and the
737/// fallback is logged — an over-stated local row is the mirror of the hazard
738/// [`escrowed_but_untracked`] guards, so it must not be reached silently.
739///
740/// `owner` is the pool owner, which signs the `topUp` (`topUp` is owner-only
741/// on-chain). `top_up` reads `owner`'s pending nonce and sends the `topUp` with
742/// it, so an unconfirmed `topUp` names the nonce it holds. The provider's own
743/// nonce manager does not see that nonce, so `contract`'s provider must read
744/// the pending nonce on every send (alloy's `with_simple_nonce_management`).
745///
746/// A submit rejected as a same-account nonce collision
747/// ([`decdn_incentive::tx::is_nonce_collision`]) broadcast nothing, so it is
748/// re-sent up to three more times, a quarter second apart, each time on a fresh
749/// pending nonce.
750/// A node signs its sellers' redemptions and its buyer top-ups with one key, so a
751/// top-up can lose its nonce to a redemption sent at the same moment.
752///
753/// # Errors
754///
755/// Errors if the nonce read or the `topUp` transaction fails (submit, revert, or
756/// receipt). A submit that fails in transport
757/// ([`decdn_incentive::tx::send_broadcast_unknown`]) and a receipt that cannot be
758/// read both carry [`TopUpUnconfirmed`], because the `topUp` may still mine.
759pub async fn top_up<P: Provider + Clone>(
760 contract: &PaymentPool::PaymentPoolInstance<P>,
761 owner: Address,
762 pool_id: B256,
763 additional: U256,
764) -> Result<ToppedUpPool> {
765 let amount = to_pool_u64(additional, "top-up")?;
766 let mut nonce_retries = 0u32;
767 let (pending, nonce) = loop {
768 let nonce = contract
769 .provider()
770 .get_transaction_count(owner)
771 .pending()
772 .await
773 .context("read the pending nonce for topUp")?;
774 let call = contract.topUp(pool_id, amount).from(owner).nonce(nonce);
775 let err = match call.send().await {
776 Ok(pending) => break (pending, nonce),
777 Err(err) => err,
778 };
779 if nonce_retries < TOPUP_NONCE_RETRIES && decdn_incentive::tx::is_nonce_collision(&err) {
780 nonce_retries = nonce_retries.saturating_add(1);
781 debug!(
782 %pool_id, nonce_retries,
783 "topUp lost its nonce to a concurrent transaction; re-sending"
784 );
785 tokio::time::sleep(TOPUP_NONCE_BACKOFF).await;
786 continue;
787 }
788 // A transport failure may have reached the node and broadcast the
789 // `topUp` before the response was lost, so a retry may escrow twice.
790 if decdn_incentive::tx::send_broadcast_unknown(&err) {
791 return Err(anyhow::Error::new(err)
792 .context("submit topUp")
793 .context(TopUpUnconfirmed { tx: None, nonce }));
794 }
795 // A deterministic allowance shortfall is caught at gas estimation and
796 // surfaces here with ABI revert data attached. Match it so the node can
797 // re-`approve` and retry once; every other revert stays terminal (an
798 // `approve` cannot fix a balance shortfall or a paused pool). A pool
799 // that is no longer open reverts `PoolNotOpen` the same way, which the
800 // funding recovery step answers with a new pool.
801 let revert_data = err.as_revert_data();
802 let allowance_short = decdn_incentive::is_erc20_allowance_shortfall(revert_data.as_ref());
803 let not_open = decdn_incentive::is_pool_not_open(revert_data.as_ref());
804 let submit_err = anyhow::Error::new(err).context("submit topUp");
805 return Err(if allowance_short {
806 submit_err.context(AllowanceShortfall)
807 } else if not_open {
808 submit_err.context(PoolNotOpen)
809 } else {
810 submit_err
811 });
812 };
813 // Capture the hash before `get_receipt` consumes `pending`: past this point
814 // the transaction is broadcast and may mine, so every receipt failure names
815 // it (see [`TopUpUnconfirmed`]).
816 // The wait is unbounded, like `open_pool`'s: giving up does not cancel the
817 // transaction, and the node holds its top-up slot for exactly as long as
818 // this future runs.
819 let tx = *pending.tx_hash();
820 let receipt = pending.get_receipt().await.map_err(|err| {
821 anyhow::Error::new(err)
822 .context("await topUp receipt")
823 .context(TopUpUnconfirmed {
824 tx: Some(tx),
825 nonce,
826 })
827 })?;
828 if !receipt.status() {
829 anyhow::bail!("topUp reverted for pool {pool_id}");
830 }
831 // Filtered on the emitting address, not just the topic: `topUp` transfers
832 // BEFORE it emits, so a token with a transfer hook could plant a forged
833 // `PoolToppedUp` earlier in the same receipt and `find` would prefer it.
834 let credited = receipt
835 .inner
836 .logs()
837 .iter()
838 .filter(|log| log.address() == *contract.address())
839 .filter_map(|log| log.log_decode::<PaymentPool::PoolToppedUp>().ok())
840 .map(|decoded| decoded.inner.data)
841 .find(|ev| ev.poolId == pool_id)
842 .map_or_else(
843 || {
844 warn!(
845 %pool_id,
846 requested = %additional,
847 "topUp mined without a PoolToppedUp event (ABI skew); crediting the \
848 requested amount, which may over-state the on-chain deposit"
849 );
850 additional
851 },
852 |ev| ev.additionalDeposit,
853 );
854 info!(%pool_id, %credited, %tx, "topped up buyer payment pool");
855 Ok(ToppedUpPool { credited, tx })
856}
857
858/// Refill a reused buyer pool to `target_deposit` once its remaining spendable
859/// deposit falls below `1/N` of the configured working deposit.
860/// `5` → refill triggers below 20% remaining, then restores to a full deposit.
861///
862/// Shared by the CLI fetch auto-refill (#1103) and the node's node-to-node reuse
863/// path (#1146), so the refill policy has a single source of truth.
864#[doc(hidden)]
865pub const LOW_WATER_DIVISOR: u64 = 5;
866
867/// Decide how much USDC to add to a reused pool so a sustained series of fetches
868/// isn't stranded by a spent-down deposit (#1103).
869///
870/// Pure decision (no I/O) so the policy is unit-testable. `deposit` is the pool's
871/// current on-chain deposit and `prior_amount` the pool-wide cumulative amount
872/// already vouchered — the sum across all its lanes, not the lane being built —
873/// so the remaining spendable is
874/// `deposit - prior_amount`. When that remaining balance has fallen below
875/// `low_water`, return the top-up that restores it to `target_deposit`; otherwise
876/// return `U256::ZERO` (no refill). Hysteresis (`low_water < target_deposit`)
877/// keeps a busy pool from topping up on every reuse.
878///
879/// The exact cost of the *next* fetch is not known at refill time (the per-MB
880/// `rate` is only learned from the provider's probe / `StreamResponse`), so this
881/// uses a rate-independent low-water refill: keep at least `low_water` of headroom,
882/// and refill to a full `target_deposit` when it runs low.
883#[must_use]
884#[doc(hidden)]
885pub fn refill_amount(
886 deposit: U256,
887 prior_amount: U256,
888 target_deposit: U256,
889 low_water: U256,
890) -> U256 {
891 let remaining = deposit.saturating_sub(prior_amount);
892 if remaining >= low_water {
893 return U256::ZERO;
894 }
895 target_deposit.saturating_sub(remaining)
896}
897
898#[cfg(test)]
899#[allow(
900 clippy::unwrap_used,
901 clippy::expect_used,
902 clippy::indexing_slicing,
903 clippy::panic
904)]
905mod tests;