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;