Skip to main content

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 get`, #940).
3//!
4//! The genuinely duplication-prone part — the `openPool` transaction, the
5//! authoritative `PoolOpened`-from-receipt decode, the self-owned capability the
6//! owner key signs for its lane key
7//! ([`OwnedPayer`](crate::buyer_pool::OwnedPayer)), and the [`BuyerPoolState`] /
8//! [`PoolContext`] construction for a self-owned lane
9//! ([`self_owned_lane_ctx`](crate::buyer_pool::self_owned_lane_ctx)) —
10//! lives here, with the rule that picks a lane's progress write
11//! ([`ProgressWrite`](crate::buyer_pool::ProgressWrite)). Pool *reuse* is a thin
12//! composition over [`decdn_incentive::BuyerPoolStore::get_by_owner`] that each
13//! caller does directly: a one-shot CLI fetch needs neither the node service's
14//! per-owner concurrency guard nor its background reclaim/reconcile machinery.
15
16use std::sync::{Arc, Mutex, PoisonError};
17use std::time::Duration;
18
19use alloy::dyn_abi::Eip712Domain;
20use alloy::network::Ethereum;
21use alloy::primitives::{Address, B256, TxHash, U256};
22use alloy::providers::{PendingTransactionBuilder, Provider};
23use alloy::signers::local::PrivateKeySigner;
24use anyhow::{Context, Result};
25use decdn_incentive::erc20::Erc20;
26use decdn_incentive::lane_signer::derive_lane_signer;
27use decdn_incentive::payment_pool::{PaymentPool, to_pool_u64};
28use decdn_incentive::tx::{RECEIPT_TIMEOUT, await_receipt};
29use decdn_incentive::{
30    AdvanceOutcome, BuyerLaneProgress, BuyerPoolState, BuyerPoolStore, Capability, Deployment,
31    DepositOutcome, LaneKey, PoolOpenFailureReason, SignedCapability, StoreError,
32};
33use tokio_util::sync::CancellationToken;
34use tracing::{debug, error, info, warn};
35
36use crate::{PoolContext, VoucherProgress};
37
38/// Re-approve the `PaymentPool` spender for the *unlimited* case when the
39/// standing USDC allowance has fallen below this floor. Half of `U256::MAX` so
40/// one max approval covers effectively unlimited deposits and a re-run with the
41/// approval already in place skips the redundant `approve`, while a
42/// never-approved wallet (allowance `0`) trips it. Only consulted for the
43/// unlimited mode; the exact mode compares against the requested amount instead
44/// (see [`approve_decision`]).
45fn approval_floor() -> U256 {
46    U256::MAX >> 1
47}
48
49/// Decide, purely, what `approve` value (if any) to issue given the wallet's
50/// `current` standing allowance and the requested approval `amount`:
51///
52/// - `amount == None` (unlimited): skip when `current >= approval_floor()`,
53///   otherwise approve `U256::MAX` — the node/operator posture that avoids
54///   re-approve churn.
55/// - `amount == Some(needed)` (exact, the client default): skip when
56///   `current >= needed`, otherwise approve exactly `needed`. Because the skip
57///   threshold *is* `needed`, a wallet that previously granted an unlimited
58///   (`U256::MAX`) allowance always skips — switching a client to exact never
59///   re-approves the allowance *downward*.
60fn approve_decision(current: U256, amount: Option<U256>) -> Option<U256> {
61    let (threshold, approve_value) = match amount {
62        None => (approval_floor(), U256::MAX),
63        Some(needed) => (needed, needed),
64    };
65    if current >= threshold {
66        None
67    } else {
68        Some(approve_value)
69    }
70}
71
72/// A freshly opened buyer pool: the persistable [`BuyerPoolState`] and the open
73/// transaction hash. A lane on it signs under [`self_owned_lane_ctx`]. The
74/// deposit is on-chain the moment `openPool` mines, so a caller whose subsequent
75/// `store.record` fails names that tx for manual reconciliation — an error in the
76/// CLI, an `error!` in the daemon.
77#[derive(Debug)]
78#[non_exhaustive]
79#[must_use = "the open tx is the only handle to an escrowed-but-untracked deposit"]
80pub struct OpenedPool {
81    /// Persist this via `BuyerPoolStore::record`. A lane on the new pool
82    /// signs under [`self_owned_lane_ctx`].
83    pub state: BuyerPoolState,
84    /// The `openPool` transaction hash.
85    pub tx: TxHash,
86}
87
88/// The outcome of a mined [`top_up`]: what the contract actually credited, and
89/// the transaction that credited it.
90///
91/// The tx hash rides along for the same reason [`OpenedPool::tx`] does. The
92/// funds are escrowed the moment this returns, so a caller whose local credit
93/// then fails has to name the transaction an operator reconciles against — see
94/// [`escrowed_but_untracked`].
95#[derive(Debug, Clone, Copy)]
96#[non_exhaustive]
97#[must_use = "the top-up tx is the only handle to an escrowed-but-untracked deposit"]
98pub struct ToppedUpPool {
99    /// The amount the contract credited, read back from the receipt.
100    pub credited: U256,
101    /// The `topUp` transaction hash.
102    pub tx: TxHash,
103}
104
105/// Build the error for "the on-chain effect landed, the local record did not".
106///
107/// The funds have already moved when this is reached, so a non-zero exit is the
108/// only outcome that guarantees anyone reconciles: a caller that logs and
109/// returns success is indistinguishable from one that did the work, and a shell
110/// wrapper (`if decdn pool top-up …; then mark_funded; fi`) records the money as
111/// tracked when it is not.
112///
113/// `effect` names what landed on-chain in the operator's vocabulary, as a
114/// past-tense clause that reads correctly with ` on-chain` appended
115/// (`buyer pool opened`, `pool 0x… topped up by 5 µUSDC`). `tx` is the handle to
116/// reconcile against, and `cause` is the local failure.
117///
118/// The message steers the operator away from the obvious response to a non-zero
119/// exit: re-running an escrow that already landed escrows a second time.
120#[must_use]
121pub fn escrowed_but_untracked(
122    effect: &str,
123    tx: TxHash,
124    cause: impl std::fmt::Display,
125) -> anyhow::Error {
126    anyhow::Error::new(EscrowUntracked {
127        tx,
128        text: format!(
129            "{effect} on-chain (tx {tx}) but the local record did not survive; the deposit is \
130             escrowed but untracked — reconcile against the tx before retrying, because a retry \
131             escrows again: {cause}"
132        ),
133    })
134}
135
136/// The error [`escrowed_but_untracked`] builds, or an [`UnconfirmedEscrows`]
137/// rebuilds from the escrow it holds: USDC moved on-chain and the local record
138/// does not account for it. A retry escrows again, so the acquire
139/// loop classifies it as fatal to the command ([`crate::Fault::Fatal`]) instead
140/// of retrying the lane build.
141#[derive(Debug)]
142pub struct EscrowUntracked {
143    /// The `openPool` or `topUp` transaction that escrowed.
144    tx: TxHash,
145    /// What landed, the tx, and the local failure.
146    text: String,
147}
148
149impl EscrowUntracked {
150    /// The `openPool` or `topUp` transaction that escrowed.
151    #[must_use]
152    pub const fn tx(&self) -> TxHash {
153        self.tx
154    }
155}
156
157impl std::fmt::Display for EscrowUntracked {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.write_str(&self.text)
160    }
161}
162
163impl std::error::Error for EscrowUntracked {}
164
165/// The `effect` clause for a landed `topUp`, in the past-tense shape
166/// [`escrowed_but_untracked`] expects.
167///
168/// Every caller that grades a top-up credit names the same effect, and the
169/// amount and pool are what an operator reconciles the escrow against — so the
170/// wording lives here rather than being written out per call site.
171#[must_use]
172pub fn topped_up_effect(pool_id: B256, credited: U256) -> String {
173    format!("pool {pool_id} topped up by {credited} µUSDC")
174}
175
176/// Grade the local credit that follows a mined [`top_up`], turning every
177/// not-credited outcome into an [`escrowed_but_untracked`] error.
178///
179/// [`decdn_incentive::BuyerPoolStore::add_deposit`] splits its failures across two channels by
180/// design: a backend or codec fault is the `Err`, while a committed-row
181/// mismatch (the row vanished, or now tracks a different pool) is a non-`Added`
182/// `Ok`. For a caller standing over freshly escrowed USDC the distinction does
183/// not change the disposition — either way the deposit moved on-chain and the
184/// local row is short by `credited` — so both collapse here. `DepositOutcome`
185/// is `#[must_use]` for exactly this reason: a dropped `PoolMismatch` looks
186/// identical to a successful credit.
187///
188/// Returns the new committed deposit on success.
189///
190/// # Errors
191///
192/// Errors on any outcome other than [`DepositOutcome::Added`].
193pub fn grade_deposit_credit(
194    outcome: Result<DepositOutcome, StoreError>,
195    effect: &str,
196    tx: TxHash,
197) -> Result<U256> {
198    match outcome {
199        Ok(DepositOutcome::Added(new_deposit)) => Ok(new_deposit),
200        Ok(other) => Err(escrowed_but_untracked(
201            effect,
202            tx,
203            format!("the local record was not updated: {other:?}"),
204        )),
205        Err(e) => Err(escrowed_but_untracked(effect, tx, e)),
206    }
207}
208
209/// The two keys a buyer pays its own pool with.
210///
211/// - The **owner** key owns the pool. It sends `openPool`, `topUp` and the
212///   USDC approval, keys the buyer-store row, and signs the capability that
213///   authorizes the lane key.
214/// - The **lane** key signs the vouchers and the client identity binding, so
215///   every lane of this payer is `(pool, lane key, provider)`.
216///
217/// A client data dir uses [`OwnedPayer::for_store`]: its lane key is derived
218/// from the owner key and the store's salt ([`decdn_incentive::lane_signer`]),
219/// so two data dirs on one wallet sign on disjoint lanes of one pool (ADR 003
220/// § Capability delegation, ADR 012 § Ethereum Key). The node's upstream buyer
221/// uses [`OwnedPayer::sole`]: one process per node data dir, so the node key
222/// signs both roles.
223#[derive(Clone)]
224pub struct OwnedPayer {
225    owner: Arc<PrivateKeySigner>,
226    lane: Arc<PrivateKeySigner>,
227}
228
229impl OwnedPayer {
230    /// The payer for `store`'s data dir: `owner` owns the pool, and the lane
231    /// key derives from `owner` and the store's lane salt.
232    ///
233    /// # Errors
234    ///
235    /// The store cannot read or record its salt, marked [`crate::LocalPullFault`]
236    /// because only a repair of the store clears it, or the derivation gives an
237    /// invalid key.
238    pub fn for_store(owner: Arc<PrivateKeySigner>, store: &dyn BuyerPoolStore) -> Result<Self> {
239        let salt = store.lane_salt().map_err(|e| {
240            anyhow::anyhow!(
241                "read or record this data dir's lane salt in the buyer store: {e}; the store \
242                 needs repair before a fetch on the wallet's own pool can run"
243            )
244            .context(crate::LocalPullFault)
245        })?;
246        let lane = derive_lane_signer(owner.as_ref(), &salt)?;
247        Ok(Self {
248            owner,
249            lane: Arc::new(lane),
250        })
251    }
252
253    /// The payer whose one key both owns the pool and signs its lanes. Use it
254    /// only when one process ever signs for this owner, as the node does. Two
255    /// processes on one sole key share every lane and fold over each other's
256    /// watermarks, so a client data dir uses [`Self::for_store`].
257    #[must_use]
258    pub fn sole(owner: Arc<PrivateKeySigner>) -> Self {
259        Self {
260            lane: Arc::clone(&owner),
261            owner,
262        }
263    }
264
265    /// The key that owns the pool.
266    #[must_use]
267    pub const fn owner(&self) -> &Arc<PrivateKeySigner> {
268        &self.owner
269    }
270
271    /// The key that signs this payer's vouchers.
272    #[must_use]
273    pub const fn lane(&self) -> &Arc<PrivateKeySigner> {
274        &self.lane
275    }
276}
277
278// Addresses only: the keys never reach a log.
279impl std::fmt::Debug for OwnedPayer {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("OwnedPayer")
282            .field("owner", &self.owner.address())
283            .field("lane", &self.lane.address())
284            .finish()
285    }
286}
287
288/// Sign a self-owned [`SignedCapability`]: the pool owner delegates spend on
289/// `pool_id` to the payer's lane key, up to `spending_cap`, until `expiry`.
290/// The lane key derives from the owner key or is the owner key, so the
291/// capability is regenerated per connection and never stored.
292///
293/// The serving node registers it on the buyer's first on-chain redemption
294/// (`redeemMany`'s `CapabilityReg`), then accepts vouchers from the lane key up
295/// to the cap (ADR 003 §Capability delegation).
296///
297/// # Errors
298///
299/// Propagates a signing error from the owner signer.
300#[doc(hidden)]
301pub fn issue_self_capability(
302    payer: &OwnedPayer,
303    pool_id: B256,
304    spending_cap: u64,
305    expiry: u64,
306    voucher_domain: &Eip712Domain,
307) -> Result<SignedCapability> {
308    Capability {
309        signer: payer.lane().address(),
310        spending_cap,
311        pool_id,
312        expiry,
313    }
314    .sign(payer.owner().as_ref(), voucher_domain)
315    .map_err(|e| anyhow::anyhow!("sign self capability: {e}"))
316}
317
318/// The [`PoolContext`] one lane of a self-owned pool signs under: `payer`'s
319/// lane key pays `provider` from `state`'s pool, resuming at the lane's prior
320/// cumulative `(prior_bytes, prior_amount)`.
321///
322/// The context carries a freshly issued self-owned capability. The capability
323/// is node-agnostic (valid at every provider this pool pays) and cheap to
324/// regenerate, so a fresh open and a reused pool both present one without a
325/// stored copy. An upstream registers the lane key from it on the first
326/// on-chain redemption.
327///
328/// The cap is [`SELF_CAPABILITY_CAP`], not the deposit. The lane key is the
329/// owner key or derives from it, so the pool deposit is the real spending
330/// bound. A cap at the deposit would freeze the on-chain cap at the opening
331/// deposit (`_registerCapability` is idempotent past the first redemption) and
332/// reject spend past a later `topUp`. It is not `U256::MAX` either: the pool's
333/// `spendingCap` is a `uint64`, so a wider cap hashes to a word the contract
334/// cannot reconstruct and every redemption on the lane reverts.
335///
336/// The provider pin is mandatory: a voucher signed with `provider ==
337/// Address::ZERO` fails at signing, so a context left at
338/// [`PoolContext::for_pool`]'s zero provider cannot pay.
339///
340/// # Errors
341///
342/// Propagates a signing error from the owner key while issuing the capability.
343pub fn self_owned_lane_ctx(
344    state: &BuyerPoolState,
345    payer: &OwnedPayer,
346    voucher_domain: &Eip712Domain,
347    provider: Address,
348    prior_bytes: U256,
349    prior_amount: U256,
350) -> Result<PoolContext> {
351    let capability = issue_self_capability(
352        payer,
353        state.pool_id,
354        SELF_CAPABILITY_CAP,
355        SELF_CAPABILITY_EXPIRY,
356        voucher_domain,
357    )?;
358    Ok(
359        PoolContext::for_pool(state, Arc::clone(payer.lane()), voucher_domain.clone())
360            .with_provider(provider, prior_bytes, prior_amount)
361            .with_capability(capability),
362    )
363}
364
365/// The buyer-store write that records one lane's [`VoucherProgress`], so a later
366/// reuse or a restart resumes the lane at the right cumulative `bytes` /
367/// `amount`.
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369#[non_exhaustive]
370pub enum ProgressWrite {
371    /// The lane's ledger rebased to the upstream's authenticated watermark
372    /// `anchor`, behind the record on `amount` (`PoolLedger::rebase`), and no write has recorded it yet. The
373    /// record is overwritten with `anchor` and then advanced to `totals`. A
374    /// monotone advance refuses a lower total, and the next reuse would then sign
375    /// from the anchor the upstream already refused.
376    Rebase {
377        /// The upstream watermark the ledger rebased to.
378        anchor: BuyerLaneProgress,
379        /// The lane's cumulative totals after the rebase.
380        totals: BuyerLaneProgress,
381    },
382    /// A monotone advance to `totals`.
383    Advance {
384        /// The lane's new cumulative totals.
385        totals: BuyerLaneProgress,
386    },
387}
388
389impl ProgressWrite {
390    /// The write `progress` calls for, or `None` when it neither advanced past
391    /// its seed nor carries a rebase anchor. A pending anchor is written even
392    /// when the totals did not advance: the write exists to move the record
393    /// down.
394    #[must_use]
395    pub fn of(progress: &VoucherProgress) -> Option<Self> {
396        let (last_bytes, last_amount) = progress.totals();
397        let totals = BuyerLaneProgress {
398            last_amount,
399            last_bytes,
400        };
401        match progress.rebase_anchor() {
402            Some(anchor) => Some(Self::Rebase {
403                anchor: BuyerLaneProgress {
404                    last_amount: anchor.amount,
405                    last_bytes: anchor.bytes,
406                },
407                totals,
408            }),
409            None => progress.advanced().map(|_| Self::Advance { totals }),
410        }
411    }
412
413    /// The lane's cumulative totals after this write.
414    #[must_use]
415    pub const fn totals(&self) -> BuyerLaneProgress {
416        match self {
417            Self::Rebase { totals, .. } | Self::Advance { totals } => *totals,
418        }
419    }
420
421    /// Apply this write to `lane` in the record `row` names. A
422    /// [`WatermarkRow::Owned`] write goes to the owner's pool row for
423    /// `lane.pool_id`; a [`WatermarkRow::Delegated`] write goes to the delegate
424    /// lane `lane` on the row's deployment.
425    ///
426    /// # Errors
427    ///
428    /// Returns a [`StoreError`] only on a backend or codec fault. A write the
429    /// store declines (an unknown or replaced pool, a regression) is the `Ok`
430    /// [`AdvanceOutcome`].
431    pub fn apply<S: BuyerPoolStore + ?Sized>(
432        &self,
433        store: &S,
434        row: WatermarkRow,
435        lane: LaneKey,
436    ) -> Result<AdvanceOutcome, StoreError> {
437        match (row, *self) {
438            (WatermarkRow::Owned(owner), Self::Rebase { anchor, totals }) => {
439                store.rebase_progress(owner, lane.pool_id, lane, anchor, totals)
440            }
441            (WatermarkRow::Owned(owner), Self::Advance { totals }) => store.advance_progress(
442                owner,
443                lane.pool_id,
444                lane,
445                totals.last_bytes,
446                totals.last_amount,
447            ),
448            (WatermarkRow::Delegated(deployment), Self::Rebase { anchor, totals }) => {
449                store.rebase_delegate_progress(deployment, lane, anchor, totals)
450            }
451            (WatermarkRow::Delegated(deployment), Self::Advance { totals }) => {
452                store.advance_delegate_progress(deployment, lane, totals)
453            }
454        }
455    }
456}
457
458/// Which buyer-store record holds a lane's watermark.
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
460#[non_exhaustive]
461pub enum WatermarkRow {
462    /// The pool row of this owner: a lane on the buyer's own pool.
463    Owned(Address),
464    /// The delegate lanes on this deployment: a lane a key signs under an
465    /// owner-signed capability on a pool another owner funds
466    /// ([`BuyerPoolStore::delegate_lanes`]).
467    Delegated(Deployment),
468}
469
470/// Ensure `owner` holds a sufficient USDC allowance for the `PaymentPool`
471/// `spender`, issuing at most one `approve` when the current allowance is below
472/// what `amount` requires. `amount == None` grants an unlimited (`U256::MAX`)
473/// allowance — the node/operator posture that avoids re-approve churn;
474/// `amount == Some(deposit)` grants exactly `deposit` — the client default that
475/// keeps the standing spend authority scoped to the deposit being escrowed. See
476/// `approve_decision` for the skip logic. Idempotent across runs — a wallet
477/// already at or above the required allowance skips the tx.
478///
479/// # Errors
480///
481/// Fails on the allowance read, the `approve` submission, a receipt that is not
482/// read (a failed read, or none within [`RECEIPT_TIMEOUT`]; the error names the
483/// tx, which may still mine), or a reverted approve. A submission that fails in
484/// transport
485/// ([`decdn_incentive::tx::send_broadcast_unknown`]) says the `approve` may have
486/// been broadcast.
487pub async fn ensure_allowance<P: Provider + Clone>(
488    provider: &P,
489    token: Address,
490    owner: Address,
491    spender: Address,
492    amount: Option<U256>,
493) -> Result<()> {
494    let erc20 = Erc20::new(token, provider.clone());
495    let current = erc20
496        .allowance(owner, spender)
497        .call()
498        .await
499        .context("read USDC allowance")?;
500    let Some(approve_value) = approve_decision(current, amount) else {
501        debug!(%current, "USDC allowance already sufficient; skipping approve");
502        return Ok(());
503    };
504    let pending = match erc20.approve(spender, approve_value).send().await {
505        Ok(pending) => pending,
506        Err(err) => {
507            // A transport failure may have reached the node and broadcast the
508            // `approve` before the response was lost, with no hash returned.
509            let note = if decdn_incentive::tx::send_broadcast_unknown(&err) {
510                ": the request may have reached the node and broadcast the transaction — \
511                 check this signer's pending and mined transactions before re-running"
512            } else {
513                ""
514            };
515            return Err(anyhow::Error::new(err).context(format!("submit USDC approve{note}")));
516        }
517    };
518    // Capture the hash before the wait consumes `pending`, so a timeout error
519    // names the broadcast tx an operator needs to look up: it stays in the
520    // mempool and may still mine after the wait ends. The wait is bounded, so a
521    // stuck or underpriced tx cannot wedge the buyer path, in the CLI or the
522    // node (#1109). Ending the wait is safe: the allowance read at the top of
523    // the next run makes the re-approve idempotent, so no funds are stranded.
524    let approve_tx = *pending.tx_hash();
525    let receipt = await_receipt(pending, Some(RECEIPT_TIMEOUT))
526        .await
527        .with_context(|| {
528            format!("await USDC approve receipt (tx {approve_tx:#x}; it may still mine later)")
529        })?;
530    if !receipt.status() {
531        anyhow::bail!("USDC approve transaction reverted (tx {approve_tx:#x})");
532    }
533    info!(
534        %token,
535        %spender,
536        %approve_value,
537        unlimited = amount.is_none(),
538        "issued USDC approval for PaymentPool deposits"
539    );
540    Ok(())
541}
542
543/// Open a fresh `PaymentPool` deposit for `owner`, escrowing `deposit` USDC, and
544/// decode the authoritative `poolId` from the receipt's own `PoolOpened` event.
545///
546/// `deployment` is the chain and `PaymentPool` that `contract` talks to. It tags
547/// the returned row and derives the voucher EIP-712 domain, so the row and every
548/// voucher signed on it name the same deployment. A `deployment` whose address
549/// is not `contract`'s is an error, before any transaction.
550///
551/// `openPool` names no provider and no signer — a pool is bound to no payee at
552/// open, and fans out to many `(signer, provider)` lanes off-chain (ADR 003).
553/// Build a lane on the new pool with [`self_owned_lane_ctx`], which signs the
554/// self-owned capability the serving node registers on the first redemption.
555///
556/// Decoding from the receipt (rather than a follow-up `getPool`) is atomic with
557/// the open: a mined tx guarantees the event, so the caller can always persist —
558/// a transient read failure can never leave the deposit orphaned. The event is
559/// filtered on `owner == owner` to confirm we decoded our own open.
560///
561/// `deposit` is the final escrow amount — the caller applies any
562/// `max(hint, default)` clamping before calling. The returned [`OpenedPool`] is
563/// **not yet persisted**: the caller records [`OpenedPool::state`] in its
564/// `BuyerPoolStore`, and on a record failure names [`OpenedPool::tx`]
565/// ([`escrowed_but_untracked`]). It then resolves the [`UnrecordedEscrow`]
566/// returned beside it.
567///
568/// # Errors
569///
570/// Fails on the pending-nonce read (nothing sent, so no [`OpenUnconfirmed`]),
571/// `openPool` submit/receipt, a reverted tx, a missing `PoolOpened`
572/// event in the receipt logs, or a capability-signing error. The classified
573/// failure legs attach a [`PoolOpenFailureReason`] into the `anyhow` error chain
574/// (recover it with `err.downcast_ref::<PoolOpenFailureReason>()`) so a caller
575/// can bump the matching `decdn_pool_open_failures_{reason}_total` counter: a
576/// deterministic revert (with ABI revert data, decoded against the
577/// insufficient-deposit error selectors) is split from a transport/RPC fault (no
578/// revert data) and a mined on-chain revert. The missing-`PoolOpened` leg carries
579/// no reason: the deposit is escrowed but untracked, so it surfaces as
580/// [`EscrowUntracked`] for manual reconciliation, which the acquire loop
581/// classifies as fatal to the command.
582///
583/// # The caller picks the receipt bound
584///
585/// `receipt_timeout` bounds the receipt wait when it is `Some`. `openPool`
586/// **escrows funds**, and giving up on the receipt does not cancel the tx. A
587/// caller that then believes no open is in flight would escrow a second deposit
588/// against a pool the first tx is still going to mine. So a caller that bounds
589/// the wait must resolve the unconfirmed open before it sends another one.
590/// Both binaries pass [`RECEIPT_TIMEOUT`]. In the CLI a lapse ends the command
591/// and names the tx (#2433). The node calls this inside a detached task that
592/// holds the owner's open slot while this future runs, and after it while the
593/// open is unconfirmed (#1143, #2415). That task settles an unconfirmed open
594/// against the chain and re-sends one dropped from the mempool.
595///
596/// The wait ends without a receipt when the receipt read fails or the bound
597/// lapses. That error names the broadcast tx, which may still mine and escrow the deposit.
598/// The error is classified [`PoolOpenFailureReason::RpcError`] and carries an
599/// [`OpenUnconfirmed`] with the hash and nonce. A submit that fails in
600/// transport ([`decdn_incentive::tx::send_broadcast_unknown`]) carries one too,
601/// with the nonce and no hash. The caller must resolve that transaction before
602/// it sends another `openPool`, and hands a receipt it reads later to
603/// [`opened_from_receipt`].
604///
605/// A caller can also drop this future: a Ctrl-C, or a fault that ends the
606/// command. No error is returned then, and a caller can also drop an error a
607/// sibling task returned. So `escrows` holds the `openPool` from before its
608/// submit until the caller resolves the returned [`UnrecordedEscrow`]: with no
609/// hash while the submit is in flight or failed in transport, with the hash
610/// after. A caller that may drop the future or its error passes `Some` and
611/// reads [`UnconfirmedEscrows::pending`] when it ends, so it can still name the
612/// tx. An open whose receipt holds no `PoolOpened` event is held as untracked
613/// ([`UnconfirmedEscrows::untracked`]) instead.
614///
615/// An `escrows` that already holds an unconfirmed open refuses a new one: the
616/// call fails with that [`OpenUnconfirmed`] before any chain read, because the
617/// earlier tx may still mine and a second open would escrow a second deposit.
618/// A held `topUp` does not refuse an open.
619/// A caller that serializes its opens sees this only after an earlier open
620/// ended without a receipt, or left its [`UnrecordedEscrow`] unresolved.
621///
622/// # The nonce is explicit
623///
624/// `open_pool` reads `owner`'s pending nonce and sends the `openPool` with it,
625/// so an unconfirmed open names the nonce it holds. The provider's own nonce
626/// manager does not see that nonce, so `contract`'s provider must read the
627/// pending nonce on every send (alloy's `with_simple_nonce_management`), as for
628/// [`top_up`].
629pub async fn open_pool<P: Provider + Clone>(
630    contract: &PaymentPool::PaymentPoolInstance<P>,
631    deployment: Deployment,
632    token: Address,
633    owner: Address,
634    deposit: U256,
635    escrows: Option<&UnconfirmedEscrows>,
636    receipt_timeout: Option<Duration>,
637) -> Result<(OpenedPool, UnrecordedEscrow)> {
638    anyhow::ensure!(
639        *contract.address() == deployment.payment_pool,
640        "openPool: contract {} is not the deployment's PaymentPool {}",
641        contract.address(),
642        deployment.payment_pool
643    );
644    let amount = to_pool_u64(deposit, "deposit")?;
645    if let Some(earlier) = escrows.and_then(|escrows| escrows.opens().first().copied()) {
646        return Err(anyhow::Error::new(earlier)
647            .context("refusing a second openPool while an earlier one is unconfirmed"));
648    }
649    let nonce = contract
650        .provider()
651        .get_transaction_count(owner)
652        .pending()
653        .await
654        .context("read the pending nonce for openPool")
655        .context(PoolOpenFailureReason::RpcError)?;
656    // A caller with no handle holds the entries in one nobody reads.
657    let escrows = escrows.cloned().unwrap_or_default();
658    let submitted = OpenUnconfirmed { tx: None, nonce };
659    // Held across the submit: a drop while it is in flight leaves an
660    // `openPool` the RPC node may have broadcast with no hash returned.
661    escrows.insert(submitted);
662    let sent = contract
663        .openPool(amount)
664        .from(owner)
665        .nonce(nonce)
666        .send()
667        .await;
668    let pending = match sent {
669        Ok(pending) => pending,
670        Err(err) => {
671            // A deterministic revert (a zero deposit, USDC balance/allowance too
672            // low) is caught at gas estimation, so it surfaces here with ABI
673            // revert data attached; a send error *without* revert data is a
674            // transport/RPC fault.
675            let reason = PoolOpenFailureReason::classify_revert_data(err.as_revert_data().as_ref());
676            // A transport failure may have reached the node and broadcast the
677            // `openPool` before the response was lost.
678            let broadcast_unknown = decdn_incentive::tx::send_broadcast_unknown(&err);
679            let mut err = anyhow::Error::new(err).context("submit openPool");
680            if broadcast_unknown {
681                err = err.context(submitted);
682            } else {
683                escrows.remove(submitted);
684            }
685            return Err(err.context(reason));
686        }
687    };
688    // Capture the hash before `get_receipt` consumes `pending`: past this point
689    // the deposit may escrow, so a receipt failure names the tx to reconcile.
690    let tx = *pending.tx_hash();
691    info!(%tx, %owner, %deposit, "openPool broadcast; awaiting its receipt");
692    let unconfirmed = OpenUnconfirmed {
693        tx: Some(tx),
694        nonce,
695    };
696    escrows.replace(submitted, unconfirmed);
697
698    // Bounded only when the caller asks — see `# The caller picks the receipt
699    // bound` above. A drop here, a failed read or a lapse leaves the entry,
700    // which is the point.
701    let receipt = await_receipt(pending, receipt_timeout)
702        .await
703        .map_err(|err| {
704            err.context(format!(
705                "await openPool receipt (tx {tx:#x}; it may still mine and escrow the deposit)"
706            ))
707            .context(unconfirmed)
708            .context(PoolOpenFailureReason::RpcError)
709        })?;
710    let unrecorded = UnrecordedEscrow {
711        escrows,
712        entry: unconfirmed.into(),
713    };
714    match opened_from_receipt(&receipt, tx, deployment, token, owner, deposit) {
715        Ok(opened) => Ok((opened, unrecorded)),
716        // An open with no `PoolOpened` event is held untracked before its
717        // entry is released.
718        Err(err) if err.downcast_ref::<EscrowUntracked>().is_some() => {
719            Err(unrecorded.untracked(err))
720        }
721        // A revert escrowed nothing.
722        Err(err) => {
723            unrecorded.release();
724            Err(err)
725        }
726    }
727}
728
729/// Build the [`OpenedPool`] an `openPool` tx bought, from its mined `receipt`.
730///
731/// [`open_pool`] calls this once its receipt arrives. A caller that holds an
732/// [`OpenUnconfirmed`] calls it with the receipt it read later, so a pool
733/// whose first receipt read failed is tracked like any other. `deposit` is the
734/// amount the open requested; the returned state records what the contract
735/// credited.
736///
737/// # Errors
738///
739/// The tx reverted ([`PoolOpenFailureReason::ContractRevert`]), or the receipt
740/// holds no `PoolOpened` event for `owner`: the deposit is escrowed but
741/// untracked ([`EscrowUntracked`], logged here), so a retry escrows again.
742pub fn opened_from_receipt(
743    receipt: &alloy::rpc::types::TransactionReceipt,
744    tx: TxHash,
745    deployment: Deployment,
746    token: Address,
747    owner: Address,
748    deposit: U256,
749) -> Result<OpenedPool> {
750    if !receipt.status() {
751        // A mined revert: the reason is not recoverable from the receipt (no
752        // trace), so it folds into `ContractRevert`.
753        return Err(anyhow::anyhow!(
754            "openPool reverted (tx {tx:#x}, owner {owner}, deposit {deposit}); check USDC \
755             balance/allowance"
756        )
757        .context(PoolOpenFailureReason::ContractRevert));
758    }
759
760    let Some(opened) = receipt
761        .inner
762        .logs()
763        .iter()
764        .filter_map(|log| log.log_decode::<PaymentPool::PoolOpened>().ok())
765        .map(|decoded| decoded.inner.data)
766        .find(|ev| ev.owner == owner)
767    else {
768        // The deposit is escrowed on-chain and we cannot name the pool it bought.
769        // Log it HERE rather than leaving it to the caller: the node's caller is a
770        // detached task whose `Err` nobody may be waiting on (#1143).
771        error!(
772            %tx,
773            %owner,
774            %deposit,
775            "openPool mined but its PoolOpened event is missing from the receipt logs; \
776             the deposit is escrowed on-chain but UNTRACKED — reconcile manually against the tx"
777        );
778        return Err(escrowed_but_untracked(
779            "buyer pool opened",
780            tx,
781            format!("its receipt holds no PoolOpened event for owner {owner}"),
782        ));
783    };
784    let pool_id = opened.poolId;
785    // Record what the contract CREDITED, not what we asked it to transfer.
786    // `openPool` sets the pool deposit to the measured balance delta and emits
787    // that in `PoolOpened.deposit`, precisely so a fee-on-transfer token cannot
788    // over-state a pool against the shared USDC balance.
789    let credited = opened.deposit;
790    let state = BuyerPoolState::new(pool_id, deployment, owner, token, credited);
791    info!(
792        %owner,
793        %pool_id,
794        requested = %deposit,
795        credited = %credited,
796        "opened buyer payment pool"
797    );
798    Ok(OpenedPool { state, tx })
799}
800
801/// Expiry stamped on the self-owned capability [`self_owned_lane_ctx`] signs.
802/// The buyer holds the owner key, and the lane key is that key or derives from
803/// it, so there is no delegation to time-box: `u64::MAX` means "never expires",
804/// and the pool's own grace-window close is the only lifecycle gate (there is
805/// no pool expiry, ADR 003).
806const SELF_CAPABILITY_EXPIRY: u64 = u64::MAX;
807
808/// Cap stamped on a self-owned capability — the widest value the pool's
809/// `uint64 spendingCap` can hold, which is the effective "no
810/// delegated ceiling" for an owner spending against its own pool. The real
811/// bound is the deposit: redemption pays `min(desired, cap - spent, remaining)`.
812/// A `u64` by type, matching the contract field exactly, so it can never sign a
813/// cap the narrower on-chain field cannot reconstruct.
814#[doc(hidden)]
815pub const SELF_CAPABILITY_CAP: u64 = u64::MAX;
816
817/// Typed marker on an [`open_pool`] error raised when the `openPool` may have
818/// been broadcast but is not known to have mined: its submit failed in
819/// transport, or it was broadcast and its receipt was not read (a failed read,
820/// or none within the caller's bound). An [`UnconfirmedEscrows`] entry also
821/// stands for one that mined while its record has not committed. The
822/// transaction may still mine and escrow the deposit, so a caller must
823/// resolve it before it sends another `openPool`. Recover it with
824/// `err.downcast_ref::<OpenUnconfirmed>()`.
825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826pub struct OpenUnconfirmed {
827    /// The broadcast `openPool` transaction. `None` when the submit returned
828    /// no hash (it failed in transport, or was dropped in flight): the RPC
829    /// node may have broadcast the transaction without returning its hash.
830    pub tx: Option<TxHash>,
831    /// The nonce the `openPool` was sent with. Once the account's confirmed
832    /// nonce passes it, the `openPool` has either mined or can never mine.
833    pub nonce: u64,
834}
835
836impl std::fmt::Display for OpenUnconfirmed {
837    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838        match self.tx {
839            Some(tx) => write!(
840                f,
841                "openPool tx {tx} (nonce {n}) was broadcast and no local record credits it, so \
842                 it may have mined and escrowed the deposit. An open sent before the tx confirms \
843                 or the account's nonce passes {n} escrows a second deposit; once it mines, \
844                 `decdn pool list --all` shows its pool on chain",
845                n = self.nonce
846            ),
847            None => write!(
848                f,
849                "openPool submit (nonce {n}) returned no tx hash, so the RPC node may have \
850                 broadcast it and it may mine. An open sent before the account's nonce passes \
851                 {n} escrows a second deposit; once it mines, `decdn pool list --all` shows its \
852                 pool on chain",
853                n = self.nonce
854            ),
855        }
856    }
857}
858
859impl std::error::Error for OpenUnconfirmed {}
860
861/// An `openPool` or `topUp` a command submitted that no local record credits
862/// yet: its receipt was not read, or it mined and its record has not
863/// resolved ([`UnrecordedEscrow`]). One entry of [`UnconfirmedEscrows`].
864/// Either may still mine and escrow USDC.
865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
866pub enum EscrowUnconfirmed {
867    /// An `openPool`, which may escrow a new pool's deposit.
868    Open(OpenUnconfirmed),
869    /// A `topUp`, which may add to a pool's deposit.
870    TopUp(TopUpUnconfirmed),
871}
872
873impl From<OpenUnconfirmed> for EscrowUnconfirmed {
874    fn from(open: OpenUnconfirmed) -> Self {
875        Self::Open(open)
876    }
877}
878
879impl From<TopUpUnconfirmed> for EscrowUnconfirmed {
880    fn from(top_up: TopUpUnconfirmed) -> Self {
881        Self::TopUp(top_up)
882    }
883}
884
885impl EscrowUnconfirmed {
886    /// The pending escrow as the error that reports it ([`OpenUnconfirmed`]
887    /// or [`TopUpUnconfirmed`]).
888    pub(crate) fn into_error(self) -> anyhow::Error {
889        match self {
890            Self::Open(open) => anyhow::Error::new(open),
891            Self::TopUp(top_up) => anyhow::Error::new(top_up),
892        }
893    }
894}
895
896impl std::fmt::Display for EscrowUnconfirmed {
897    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
898        match self {
899            Self::Open(open) => open.fmt(f),
900            Self::TopUp(top_up) => top_up.fmt(f),
901        }
902    }
903}
904
905/// The `openPool` and `topUp` transactions a command submitted that no local
906/// record credits yet, and those that mined with no local record crediting
907/// them.
908///
909/// [`open_pool`] and [`top_up`] add an entry before their submit. A submit
910/// rejected without a broadcast or a reverted receipt removes it. A mined one
911/// keeps it until the caller's record or credit resolves the
912/// [`UnrecordedEscrow`] the call returns. A future dropped mid-submit or
913/// mid-wait, or a receipt read that fails, leaves its entry. A command that
914/// ends while an entry remains names it, because the transaction may escrow
915/// after the command exits and a re-run before it confirms escrows again.
916///
917/// The handle also holds every `openPool` and `topUp` that mined with no local
918/// record crediting it ([`UnconfirmedEscrows::untracked`]): a record that
919/// failed ([`UnrecordedEscrow::untracked`]), or an `openPool` whose receipt
920/// holds no `PoolOpened` event. Each is held before its pending entry is
921/// released, so the handle names the transaction at every moment, and a
922/// command that ends while the handle holds one names it too: a re-run reads
923/// the uncredited row and escrows again.
924///
925/// A clone shares the same lists, so one handle can outlive the per-pass state
926/// that the transactions run under.
927#[derive(Debug, Default, Clone)]
928pub struct UnconfirmedEscrows(Arc<Shared>);
929
930/// What every clone of one [`UnconfirmedEscrows`] shares.
931#[derive(Debug, Default)]
932struct Shared {
933    pending: Mutex<Vec<EscrowUnconfirmed>>,
934    /// Oldest first, one per tx. An entry is never removed.
935    untracked: Mutex<Vec<UntrackedEscrow>>,
936}
937
938/// An `openPool` or `topUp` that mined and escrowed USDC that no local record
939/// credits ([`EscrowUntracked`]), as an [`UnconfirmedEscrows`] holds it for
940/// its command to name when it ends. Displays its error chain.
941#[derive(Debug, Clone, PartialEq, Eq)]
942#[non_exhaustive]
943pub struct UntrackedEscrow {
944    /// The transaction that escrowed.
945    pub tx: TxHash,
946    /// The sanitized error chain that reported it, which names the tx.
947    pub text: String,
948}
949
950impl std::fmt::Display for UntrackedEscrow {
951    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
952        f.write_str(&self.text)
953    }
954}
955
956impl UntrackedEscrow {
957    /// The held escrow as the error that reported it ([`EscrowUntracked`]).
958    pub(crate) fn into_error(self) -> anyhow::Error {
959        anyhow::Error::new(EscrowUntracked {
960            tx: self.tx,
961            text: self.text,
962        })
963    }
964}
965
966/// A mined `openPool` or `topUp` whose escrow no local record credits yet:
967/// [`open_pool`] and [`top_up`] return it beside the outcome, and their
968/// [`UnconfirmedEscrows`] keeps the transaction pending until the caller
969/// resolves it. The caller writes its record or credit, then calls
970/// [`UnrecordedEscrow::recorded`] once the write commits, or
971/// [`UnrecordedEscrow::untracked`] with the error once it fails.
972///
973/// A command that ends during the write, such as a process exit on a second
974/// Ctrl-C, still finds the transaction on the handle and names it. One that is
975/// dropped unresolved leaves the transaction pending, which is the safe side
976/// of a lost record: the command names it, a later [`open_pool`] on the handle
977/// refuses while it holds an `openPool`, and a run's later `topUp`
978/// ([`crate::funding::RunFunding`]) refuses while it holds a `topUp`.
979#[derive(Debug)]
980#[must_use = "the escrow stays pending until its record is resolved"]
981pub struct UnrecordedEscrow {
982    escrows: UnconfirmedEscrows,
983    entry: EscrowUnconfirmed,
984}
985
986impl UnrecordedEscrow {
987    /// The record or credit committed: release the transaction from the
988    /// handle.
989    pub fn recorded(self) {
990        self.release();
991    }
992
993    /// The record or credit failed with `err`: hold `err` as untracked, then
994    /// release the transaction, and return `err`. The handle names the
995    /// transaction at every moment between the two, so a command that ends
996    /// there still names it. An `err` that is not an [`EscrowUntracked`]
997    /// ([`escrowed_but_untracked`]) names no tx the handle can hold, so the
998    /// transaction stays pending instead.
999    pub fn untracked(self, err: anyhow::Error) -> anyhow::Error {
1000        if self.escrows.hold_untracked(&err) {
1001            self.release();
1002        }
1003        err
1004    }
1005
1006    /// Release the transaction from the handle: its record committed, or its
1007    /// receipt shows a revert that escrowed nothing.
1008    fn release(self) {
1009        self.escrows.remove(self.entry);
1010    }
1011}
1012
1013impl UnconfirmedEscrows {
1014    /// The `openPool` and `topUp` transactions that escrowed USDC no local
1015    /// record credits, oldest first.
1016    #[must_use]
1017    pub fn untracked(&self) -> Vec<UntrackedEscrow> {
1018        self.0
1019            .untracked
1020            .lock()
1021            .unwrap_or_else(PoisonError::into_inner)
1022            .clone()
1023    }
1024
1025    /// Hold `err` when it is an untracked escrow ([`EscrowUntracked`]) whose
1026    /// tx the handle does not hold yet. Returns whether the handle holds
1027    /// `err`'s escrow now.
1028    pub(crate) fn hold_untracked(&self, err: &anyhow::Error) -> bool {
1029        let Some(escrow) = err.downcast_ref::<EscrowUntracked>() else {
1030            return false;
1031        };
1032        let mut held = self
1033            .0
1034            .untracked
1035            .lock()
1036            .unwrap_or_else(PoisonError::into_inner);
1037        if !held.iter().any(|known| known.tx == escrow.tx) {
1038            held.push(UntrackedEscrow {
1039                tx: escrow.tx,
1040                text: decdn_common::redact::sanitize_err_chain(err),
1041            });
1042        }
1043        true
1044    }
1045
1046    /// Hold `err` as a run on the handle holds it when it is an untracked
1047    /// escrow, for a test that builds the error with
1048    /// [`escrowed_but_untracked`].
1049    #[cfg(any(test, feature = "test-util"))]
1050    pub fn hold_untracked_for_test(&self, err: &anyhow::Error) {
1051        self.hold_untracked(err);
1052    }
1053
1054    /// The transactions no local record credits yet, oldest first: each
1055    /// receipt that was not read, and each mined one whose record has not
1056    /// resolved ([`UnrecordedEscrow`]).
1057    #[must_use]
1058    pub fn pending(&self) -> Vec<EscrowUnconfirmed> {
1059        self.0
1060            .pending
1061            .lock()
1062            .unwrap_or_else(PoisonError::into_inner)
1063            .clone()
1064    }
1065
1066    /// The pending `openPool` transactions ([`Self::pending`]), oldest first.
1067    #[must_use]
1068    pub(crate) fn opens(&self) -> Vec<OpenUnconfirmed> {
1069        self.pending()
1070            .into_iter()
1071            .filter_map(|held| match held {
1072                EscrowUnconfirmed::Open(open) => Some(open),
1073                EscrowUnconfirmed::TopUp(_) => None,
1074            })
1075            .collect()
1076    }
1077
1078    /// The pending `topUp` transactions ([`Self::pending`]), oldest first.
1079    #[must_use]
1080    pub(crate) fn top_ups(&self) -> Vec<TopUpUnconfirmed> {
1081        self.pending()
1082            .into_iter()
1083            .filter_map(|held| match held {
1084                EscrowUnconfirmed::TopUp(top_up) => Some(top_up),
1085                EscrowUnconfirmed::Open(_) => None,
1086            })
1087            .collect()
1088    }
1089
1090    /// Hold `escrow` from its submit.
1091    fn insert(&self, escrow: impl Into<EscrowUnconfirmed>) {
1092        self.0
1093            .pending
1094            .lock()
1095            .unwrap_or_else(PoisonError::into_inner)
1096            .push(escrow.into());
1097    }
1098
1099    /// Release `escrow` once its record resolves, its receipt shows a revert,
1100    /// or its submit was rejected.
1101    /// Releases one entry: two calls that hold equal entries each release
1102    /// their own.
1103    fn remove(&self, escrow: impl Into<EscrowUnconfirmed>) {
1104        let escrow = escrow.into();
1105        let mut held = self
1106            .0
1107            .pending
1108            .lock()
1109            .unwrap_or_else(PoisonError::into_inner);
1110        if let Some(at) = held.iter().position(|entry| *entry == escrow) {
1111            held.remove(at);
1112        }
1113    }
1114
1115    /// Swap `submitted` for `broadcast` under one lock, once the submit
1116    /// returns a hash, so a reader never sees the transaction held as
1117    /// neither.
1118    fn replace(
1119        &self,
1120        submitted: impl Into<EscrowUnconfirmed>,
1121        broadcast: impl Into<EscrowUnconfirmed>,
1122    ) {
1123        let (submitted, broadcast) = (submitted.into(), broadcast.into());
1124        let mut held = self
1125            .0
1126            .pending
1127            .lock()
1128            .unwrap_or_else(PoisonError::into_inner);
1129        match held.iter_mut().find(|entry| **entry == submitted) {
1130            Some(entry) => *entry = broadcast,
1131            None => held.push(broadcast),
1132        }
1133    }
1134}
1135
1136/// Send `openPool(deposit)` from `owner` at exactly `nonce`, and return its
1137/// hash without waiting for a receipt.
1138///
1139/// A caller resolving an [`OpenUnconfirmed`] whose nonce no transaction holds
1140/// re-sends with that same nonce. Two transactions at one nonce cannot both
1141/// mine, so the re-send and a first open that resurfaces escrow at most one
1142/// deposit between them. A receipt wait here would never end if the other
1143/// transaction took the nonce, so the caller reads receipts itself.
1144///
1145/// # Errors
1146///
1147/// The deposit overflows `uint64`, or the send fails. A send failure carries a
1148/// [`PoolOpenFailureReason`] classified from its revert data.
1149pub async fn send_open_pool<P: Provider + Clone>(
1150    contract: &PaymentPool::PaymentPoolInstance<P>,
1151    owner: Address,
1152    deposit: U256,
1153    nonce: u64,
1154) -> Result<TxHash> {
1155    let amount = to_pool_u64(deposit, "deposit")?;
1156    match contract
1157        .openPool(amount)
1158        .from(owner)
1159        .nonce(nonce)
1160        .send()
1161        .await
1162    {
1163        Ok(pending) => Ok(*pending.tx_hash()),
1164        Err(err) => {
1165            let reason = PoolOpenFailureReason::classify_revert_data(err.as_revert_data().as_ref());
1166            Err(anyhow::Error::new(err)
1167                .context(format!("re-send openPool at nonce {nonce}"))
1168                .context(reason))
1169        }
1170    }
1171}
1172
1173/// Typed marker attached to a `top_up` submit error whose revert is an
1174/// `ERC20InsufficientAllowance` shortfall: the `PaymentPool`'s standing USDC
1175/// allowance is below the transfer amount. The node's `fund_pool` downcasts
1176/// on it to run a just-in-time `approve` and retry once; every other revert
1177/// stays terminal. CLI callers never downcast it, so it is invisible to them.
1178#[derive(Debug, Clone, Copy)]
1179pub struct AllowanceShortfall;
1180
1181impl std::fmt::Display for AllowanceShortfall {
1182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1183        write!(
1184            f,
1185            "topUp reverted: PaymentPool USDC allowance is below the transfer amount"
1186        )
1187    }
1188}
1189
1190impl std::error::Error for AllowanceShortfall {}
1191
1192/// Typed marker on a [`top_up`] error raised when the `topUp` may have been
1193/// broadcast but is not known to have mined: its submit failed in transport
1194/// (a timeout, a reset, an HTTP 5xx), or it was broadcast and its receipt was
1195/// not read (a failed read, or none within the caller's bound). An
1196/// [`UnconfirmedEscrows`] entry has the same meaning, and also holds a `topUp`
1197/// whose future was dropped before either. The transaction may still mine, so
1198/// the escrow may have moved while no local record credits it. A caller must
1199/// not treat it as "nothing escrowed"; the acquire loop classifies it as fatal
1200/// to the command ([`crate::Fault::Fatal`]).
1201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1202pub struct TopUpUnconfirmed {
1203    /// The broadcast `topUp` transaction, which an operator reconciles
1204    /// against. `None` when the submit returned no hash (it failed in
1205    /// transport, or was dropped in flight): the RPC node may have broadcast
1206    /// the transaction without returning its hash.
1207    pub tx: Option<TxHash>,
1208    /// The nonce the `topUp` was sent with. Once the account's confirmed nonce
1209    /// passes it, the `topUp` has either mined or can never mine.
1210    pub nonce: u64,
1211}
1212
1213impl std::fmt::Display for TopUpUnconfirmed {
1214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1215        match self.tx {
1216            Some(tx) => write!(
1217                f,
1218                "topUp tx {tx} (nonce {}) was broadcast and no local record credits it, so it \
1219                 may have mined; reconcile against the tx before retrying, because a retry \
1220                 escrows again",
1221                self.nonce
1222            ),
1223            None => write!(
1224                f,
1225                "topUp submit (nonce {}) returned no tx hash, so the RPC node may have \
1226                 broadcast it and it may mine; reconcile against the account's nonce before \
1227                 retrying, because a retry escrows again",
1228                self.nonce
1229            ),
1230        }
1231    }
1232}
1233
1234impl std::error::Error for TopUpUnconfirmed {}
1235
1236/// Typed marker on a failed top-up whose funder found the buyer's wallet
1237/// holding too little USDC for it. No retry fixes that, so the acquire loop
1238/// classifies it [`crate::Fault::Unaffordable`], and a funding recovery step
1239/// that fails with it ends the fetch "funding needed".
1240#[derive(Debug, Clone, Copy)]
1241pub struct WalletShortfall;
1242
1243impl std::fmt::Display for WalletShortfall {
1244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1245        f.write_str("the wallet holds too little USDC to top up the pool")
1246    }
1247}
1248
1249impl std::error::Error for WalletShortfall {}
1250
1251/// Typed marker on a [`top_up`] error whose gas estimate reverted
1252/// `PoolNotOpen`: the pool is `Closing` or `Closed`, so it accepts no funds and
1253/// is never reopened. The funding recovery step opens a new pool instead (ADR
1254/// 003 § Funding recovery).
1255#[derive(Debug, Clone, Copy)]
1256pub struct PoolNotOpen;
1257
1258impl std::fmt::Display for PoolNotOpen {
1259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1260        f.write_str("topUp reverted PoolNotOpen: the pool is closing or closed")
1261    }
1262}
1263
1264impl std::error::Error for PoolNotOpen {}
1265
1266/// Whether `pool_id` still accepts funds: the gas estimate of a one-unit
1267/// `topUp` from `owner`, with no transaction sent. A `PoolNotOpen` revert says
1268/// no. Any other estimate failure (an allowance or balance short of one unit,
1269/// a transport fault, a revert the decoder cannot read) says nothing about the
1270/// pool, so the pool's status is read instead: `Closing` or `Closed` says no.
1271/// When that read fails too, the pool reads as open, and the warning names
1272/// both failures.
1273///
1274/// The funding recovery step checks this when the pool already holds its
1275/// working deposit and there is nothing to add: a full pool that is `Closing`
1276/// or `Closed` still needs a replacement.
1277pub async fn pool_accepts_funds<P: Provider + Clone>(
1278    contract: &PaymentPool::PaymentPoolInstance<P>,
1279    owner: Address,
1280    pool_id: B256,
1281) -> bool {
1282    let estimate = match contract.topUp(pool_id, 1).from(owner).estimate_gas().await {
1283        Ok(_) => return true,
1284        Err(err) if decdn_incentive::is_pool_not_open(err.as_revert_data().as_ref()) => {
1285            return false;
1286        }
1287        Err(err) => err,
1288    };
1289    match contract.getPool(pool_id).call().await {
1290        Ok(pool) => !matches!(
1291            pool.status,
1292            PaymentPool::Status::Closing | PaymentPool::Status::Closed
1293        ),
1294        Err(read) => {
1295            tracing::warn!(
1296                %pool_id,
1297                error = %format_args!("topUp estimate: {estimate}; getPool: {read}"),
1298                "could not tell whether the buyer pool still accepts funds; treating it as open"
1299            );
1300            true
1301        }
1302    }
1303}
1304
1305/// How many times [`top_up`] re-sends a submit rejected as a nonce collision.
1306const TOPUP_NONCE_RETRIES: u32 = 3;
1307
1308/// How long [`top_up`] waits before re-sending after a nonce collision: long
1309/// enough for the colliding transaction to reach the pending pool, so the next
1310/// pending-nonce read skips past it.
1311const TOPUP_NONCE_BACKOFF: Duration = Duration::from_millis(250);
1312
1313/// Send `topUp(pool_id, amount)` from `owner` at its pending nonce, re-sent on
1314/// a fresh nonce after a same-account nonce collision, and return the pending
1315/// transaction with the nonce it holds. The errors are [`top_up`]'s submit
1316/// errors, and [`FetchEnded`](crate::funding::FetchEnded) once `ended` fires
1317/// before a send or during the backoff before a re-send.
1318///
1319/// `escrows` holds each submit while it is in flight, and keeps one that
1320/// failed in transport: either may have been broadcast with no hash returned.
1321/// A submit that returns a hash stays held for [`top_up`] to swap for the
1322/// broadcast `topUp`; a rejected one is released.
1323async fn send_top_up<P: Provider + Clone>(
1324    contract: &PaymentPool::PaymentPoolInstance<P>,
1325    owner: Address,
1326    pool_id: B256,
1327    amount: u64,
1328    escrows: &UnconfirmedEscrows,
1329    ended: Option<&CancellationToken>,
1330) -> Result<(PendingTransactionBuilder<Ethereum>, u64)> {
1331    let mut nonce_retries = 0u32;
1332    loop {
1333        // Refuse a send once the fetch ended: its lane would never run. Before
1334        // a re-send this costs nothing, since the collision broadcast nothing.
1335        crate::funding::refuse_after_end(ended)?;
1336        let nonce = contract
1337            .provider()
1338            .get_transaction_count(owner)
1339            .pending()
1340            .await
1341            .context("read the pending nonce for topUp")?;
1342        let call = contract.topUp(pool_id, amount).from(owner).nonce(nonce);
1343        let submitted = TopUpUnconfirmed { tx: None, nonce };
1344        // Held across the submit: a drop while it is in flight leaves a
1345        // `topUp` the RPC node may have broadcast with no hash returned.
1346        escrows.insert(submitted);
1347        let err = match call.send().await {
1348            Ok(pending) => return Ok((pending, nonce)),
1349            Err(err) => err,
1350        };
1351        if nonce_retries < TOPUP_NONCE_RETRIES && decdn_incentive::tx::is_nonce_collision(&err) {
1352            nonce_retries = nonce_retries.saturating_add(1);
1353            escrows.remove(submitted);
1354            debug!(
1355                %pool_id, nonce_retries,
1356                "topUp lost its nonce to a concurrent transaction; re-sending"
1357            );
1358            let backoff = tokio::time::sleep(TOPUP_NONCE_BACKOFF);
1359            match ended {
1360                Some(ended) => {
1361                    tokio::select! {
1362                        () = ended.cancelled() => {}
1363                        () = backoff => {}
1364                    }
1365                }
1366                None => backoff.await,
1367            }
1368            continue;
1369        }
1370        // A transport failure may have reached the node and broadcast the
1371        // `topUp` before the response was lost, so a retry may escrow twice.
1372        if decdn_incentive::tx::send_broadcast_unknown(&err) {
1373            return Err(anyhow::Error::new(err)
1374                .context("submit topUp")
1375                .context(submitted));
1376        }
1377        escrows.remove(submitted);
1378        return Err(top_up_submit_error(err, "submit topUp"));
1379    }
1380}
1381
1382/// Wrap a failed `topUp` submit in `context`, marked by its revert.
1383///
1384/// A deterministic allowance shortfall is caught at gas estimation and
1385/// surfaces with ABI revert data attached. It is marked [`AllowanceShortfall`]
1386/// so the node can re-`approve` and retry once; every other revert stays
1387/// terminal (an `approve` cannot fix a balance shortfall or a paused pool). A
1388/// pool that is no longer open reverts `PoolNotOpen` the same way and is marked
1389/// [`PoolNotOpen`], which the funding recovery step answers with a new pool.
1390fn top_up_submit_error(err: alloy::contract::Error, context: impl Into<String>) -> anyhow::Error {
1391    let revert_data = err.as_revert_data();
1392    let allowance_short = decdn_incentive::is_erc20_allowance_shortfall(revert_data.as_ref());
1393    let not_open = decdn_incentive::is_pool_not_open(revert_data.as_ref());
1394    let submit_err = anyhow::Error::new(err).context(context.into());
1395    if allowance_short {
1396        submit_err.context(AllowanceShortfall)
1397    } else if not_open {
1398        submit_err.context(PoolNotOpen)
1399    } else {
1400        submit_err
1401    }
1402}
1403
1404/// Send `topUp(pool_id, additional)` from `owner` at exactly `nonce`, and
1405/// return its hash without waiting for a receipt.
1406///
1407/// A caller resolving a [`TopUpUnconfirmed`] whose nonce no transaction holds
1408/// re-sends with that same nonce. Two transactions at one nonce cannot both
1409/// mine, so the re-send and a first `topUp` that resurfaces escrow at most one
1410/// top-up between them. A receipt wait here would never end if the other
1411/// transaction took the nonce, so the caller reads receipts itself
1412/// ([`topped_up_from_receipt`]).
1413///
1414/// # Errors
1415///
1416/// The amount overflows `uint64`, or the send fails. A revert carries the
1417/// markers [`top_up`]'s submit errors carry ([`AllowanceShortfall`],
1418/// [`PoolNotOpen`]). A transport failure is not marked [`TopUpUnconfirmed`]:
1419/// the caller already holds the nonce, and reads it again to learn the
1420/// outcome.
1421pub async fn send_top_up_at<P: Provider + Clone>(
1422    contract: &PaymentPool::PaymentPoolInstance<P>,
1423    owner: Address,
1424    pool_id: B256,
1425    additional: U256,
1426    nonce: u64,
1427) -> Result<TxHash> {
1428    let amount = to_pool_u64(additional, "top-up")?;
1429    match contract
1430        .topUp(pool_id, amount)
1431        .from(owner)
1432        .nonce(nonce)
1433        .send()
1434        .await
1435    {
1436        Ok(pending) => Ok(*pending.tx_hash()),
1437        Err(err) => Err(top_up_submit_error(
1438            err,
1439            format!("re-send topUp at nonce {nonce}"),
1440        )),
1441    }
1442}
1443
1444/// Add `additional` USDC to the buyer pool `pool_id` on-chain and return the
1445/// credited amount and the mining transaction as a [`ToppedUpPool`] — the shared
1446/// mechanism behind the node's cache-miss buyer (#744) and the CLI fetch buyer's
1447/// auto-refill (#1103). `topUp` does not extend any lifecycle deadline; the caller
1448/// credits `credited` into its local [`BuyerPoolState`] via `add_deposit`, and
1449/// names `tx` if that credit fails — see [`escrowed_but_untracked`]. It then
1450/// resolves the [`UnrecordedEscrow`] returned beside it.
1451///
1452/// The amount credited is read back from `PoolToppedUp.additionalDeposit`, not
1453/// assumed to equal `additional`: the contract credits a measured balance delta,
1454/// so the two differ under a fee-on-transfer token and the local row must not
1455/// over-state the on-chain deposit. If the event is absent (an ABI skew), the
1456/// requested `additional` is returned as the best available estimate and the
1457/// fallback is logged — an over-stated local row is the mirror of the hazard
1458/// [`escrowed_but_untracked`] guards, so it must not be reached silently.
1459///
1460/// `owner` is the pool owner, which signs the `topUp` (`topUp` is owner-only
1461/// on-chain). `top_up` reads `owner`'s pending nonce and sends the `topUp` with
1462/// it, so an unconfirmed `topUp` names the nonce it holds. The provider's own
1463/// nonce manager does not see that nonce, so `contract`'s provider must read
1464/// the pending nonce on every send (alloy's `with_simple_nonce_management`).
1465///
1466/// A submit rejected as a same-account nonce collision
1467/// ([`decdn_incentive::tx::is_nonce_collision`]) broadcast nothing, so it is
1468/// re-sent up to three more times, a quarter second apart, each time on a fresh
1469/// pending nonce. `top_up` re-sends regardless of any fetch; the crate's lane
1470/// builds use a variant that re-sends none once their fetch ends.
1471/// A node signs its sellers' redemptions and its buyer top-ups with one key, so a
1472/// top-up can lose its nonce to a redemption sent at the same moment.
1473///
1474/// `receipt_timeout` bounds the receipt wait when it is `Some`, as for
1475/// [`open_pool`]. A caller that bounds the wait must not send another `topUp`
1476/// on the pool while this one is unconfirmed. The run funding of `decdn get`
1477/// refuses the rest while its `escrows` holds one
1478/// ([`crate::funding::RunFunding`]). The node keeps its top-up slot and settles
1479/// the unconfirmed `topUp` against the chain ([`send_top_up_at`],
1480/// [`topped_up_from_receipt`]).
1481///
1482/// `escrows` holds the `topUp` from before its submit until the caller
1483/// resolves the returned [`UnrecordedEscrow`], as [`open_pool`] holds an
1484/// `openPool`: with no hash while the submit is in flight or failed in
1485/// transport, with the hash after. A drop, a failed
1486/// read or a lapse leaves the entry, so a caller that may drop the future or
1487/// its error passes `Some` and reads [`UnconfirmedEscrows::pending`] when it
1488/// ends. `top_up` itself refuses nothing on what `escrows` holds.
1489///
1490/// # Errors
1491///
1492/// Errors if the nonce read or the `topUp` transaction fails (submit, revert, or
1493/// receipt). A submit that fails in transport
1494/// ([`decdn_incentive::tx::send_broadcast_unknown`]) and a receipt that is not
1495/// read (a failed read, or none within `receipt_timeout`) both carry
1496/// [`TopUpUnconfirmed`], because the `topUp` may still mine.
1497pub async fn top_up<P: Provider + Clone>(
1498    contract: &PaymentPool::PaymentPoolInstance<P>,
1499    owner: Address,
1500    pool_id: B256,
1501    additional: U256,
1502    escrows: Option<&UnconfirmedEscrows>,
1503    receipt_timeout: Option<Duration>,
1504) -> Result<(ToppedUpPool, UnrecordedEscrow)> {
1505    top_up_unless_ended(
1506        contract,
1507        owner,
1508        pool_id,
1509        additional,
1510        escrows,
1511        receipt_timeout,
1512        None,
1513    )
1514    .await
1515}
1516
1517/// [`top_up`] for a lane build, which sends no `topUp` once `ended` fires
1518/// ([`crate::funding::FetchEnded`]): neither the first send nor a re-send
1519/// after a nonce collision. A collision broadcast nothing, so the refused
1520/// re-send escrows nothing. A `topUp` already sent runs to its receipt.
1521/// `None` refuses nothing, as for [`top_up`].
1522///
1523/// # Errors
1524///
1525/// As [`top_up`], and [`crate::funding::FetchEnded`] once `ended` fires
1526/// before a send.
1527pub(crate) async fn top_up_unless_ended<P: Provider + Clone>(
1528    contract: &PaymentPool::PaymentPoolInstance<P>,
1529    owner: Address,
1530    pool_id: B256,
1531    additional: U256,
1532    escrows: Option<&UnconfirmedEscrows>,
1533    receipt_timeout: Option<Duration>,
1534    ended: Option<&CancellationToken>,
1535) -> Result<(ToppedUpPool, UnrecordedEscrow)> {
1536    let amount = to_pool_u64(additional, "top-up")?;
1537    // A caller with no handle holds the entries in one nobody reads.
1538    let escrows = escrows.cloned().unwrap_or_default();
1539    let (pending, nonce) = send_top_up(contract, owner, pool_id, amount, &escrows, ended).await?;
1540    // Capture the hash before the wait consumes `pending`: past this point the
1541    // transaction is broadcast and may mine, so every receipt failure names it
1542    // (see [`TopUpUnconfirmed`]). A lapse does not cancel the transaction.
1543    let tx = *pending.tx_hash();
1544    info!(%tx, %owner, %pool_id, %additional, nonce, "topUp broadcast; awaiting its receipt");
1545    let unconfirmed = TopUpUnconfirmed {
1546        tx: Some(tx),
1547        nonce,
1548    };
1549    escrows.replace(TopUpUnconfirmed { tx: None, nonce }, unconfirmed);
1550    // A drop here, a failed read or a lapse leaves the entry.
1551    let receipt = await_receipt(pending, receipt_timeout)
1552        .await
1553        .map_err(|err| err.context("await topUp receipt").context(unconfirmed))?;
1554    let unrecorded = UnrecordedEscrow {
1555        escrows,
1556        entry: unconfirmed.into(),
1557    };
1558    match topped_up_from_receipt(&receipt, *contract.address(), pool_id, additional) {
1559        Ok(topped_up) => Ok((topped_up, unrecorded)),
1560        // A revert escrowed nothing.
1561        Err(err) => {
1562            unrecorded.release();
1563            Err(err)
1564        }
1565    }
1566}
1567
1568/// Build the [`ToppedUpPool`] a `topUp` tx bought, from its mined `receipt`.
1569///
1570/// [`top_up`] calls this once its receipt arrives. A caller that holds a
1571/// [`TopUpUnconfirmed`] calls it with the receipt it read later, so a `topUp`
1572/// whose first receipt read failed is credited like any other. `payment_pool`
1573/// is the `PaymentPool` the `topUp` was sent to, and `additional` the amount it
1574/// requested. See [`top_up`] for how the credited amount is read.
1575///
1576/// # Errors
1577///
1578/// The tx reverted: nothing was escrowed.
1579pub fn topped_up_from_receipt(
1580    receipt: &alloy::rpc::types::TransactionReceipt,
1581    payment_pool: Address,
1582    pool_id: B256,
1583    additional: U256,
1584) -> Result<ToppedUpPool> {
1585    let tx = receipt.transaction_hash;
1586    if !receipt.status() {
1587        anyhow::bail!("topUp reverted for pool {pool_id} (tx {tx:#x})");
1588    }
1589    // Filtered on the emitting address, not just the topic: `topUp` transfers
1590    // BEFORE it emits, so a token with a transfer hook could plant a forged
1591    // `PoolToppedUp` earlier in the same receipt and `find` would prefer it.
1592    let credited = receipt
1593        .inner
1594        .logs()
1595        .iter()
1596        .filter(|log| log.address() == payment_pool)
1597        .filter_map(|log| log.log_decode::<PaymentPool::PoolToppedUp>().ok())
1598        .map(|decoded| decoded.inner.data)
1599        .find(|ev| ev.poolId == pool_id)
1600        .map_or_else(
1601            || {
1602                warn!(
1603                    %pool_id,
1604                    requested = %additional,
1605                    "topUp mined without a PoolToppedUp event (ABI skew); crediting the \
1606                     requested amount, which may over-state the on-chain deposit"
1607                );
1608                additional
1609            },
1610            |ev| ev.additionalDeposit,
1611        );
1612    info!(%pool_id, %credited, %tx, "topped up buyer payment pool");
1613    Ok(ToppedUpPool { credited, tx })
1614}
1615
1616/// Refill a reused buyer pool to `target_deposit` once its remaining spendable
1617/// deposit falls below `1/N` of the configured working deposit.
1618/// `5` → refill triggers below 20% remaining, then restores to a full deposit.
1619///
1620/// Shared by the CLI fetch auto-refill (#1103) and the node's node-to-node reuse
1621/// path (#1146), so the refill policy has a single source of truth.
1622#[doc(hidden)]
1623pub const LOW_WATER_DIVISOR: u64 = 5;
1624
1625/// Decide how much USDC to add to a reused pool so a sustained series of fetches
1626/// isn't stranded by a spent-down deposit (#1103).
1627///
1628/// Pure decision (no I/O) so the policy is unit-testable. `deposit` is the pool's
1629/// current on-chain deposit and `prior_amount` the pool-wide cumulative amount
1630/// already vouchered — the sum across all its lanes, not the lane being built —
1631/// so the remaining spendable is
1632/// `deposit - prior_amount`. When that remaining balance has fallen below
1633/// `low_water`, return the top-up that restores it to `target_deposit`; otherwise
1634/// return `U256::ZERO` (no refill). Hysteresis (`low_water < target_deposit`)
1635/// keeps a busy pool from topping up on every reuse.
1636///
1637/// The exact cost of the *next* fetch is not known at refill time (the per-MB
1638/// `rate` is only learned from the provider's probe / `StreamResponse`), so this
1639/// uses a rate-independent low-water refill: keep at least `low_water` of headroom,
1640/// and refill to a full `target_deposit` when it runs low.
1641#[must_use]
1642#[doc(hidden)]
1643pub fn refill_amount(
1644    deposit: U256,
1645    prior_amount: U256,
1646    target_deposit: U256,
1647    low_water: U256,
1648) -> U256 {
1649    let remaining = deposit.saturating_sub(prior_amount);
1650    if remaining >= low_water {
1651        return U256::ZERO;
1652    }
1653    target_deposit.saturating_sub(remaining)
1654}
1655
1656#[cfg(test)]
1657#[allow(
1658    clippy::unwrap_used,
1659    clippy::expect_used,
1660    clippy::indexing_slicing,
1661    clippy::panic
1662)]
1663mod tests;