Skip to main content

decdn_incentive/
buyer_pool.rs

1//! Buyer-side payment-pool bookkeeping (#744).
2//!
3//! When a node pulls content from an upstream provider on a cache miss it
4//! acts as the *client*: it opens (or reuses) a `PaymentPool` deposit against
5//! itself as owner, signs cumulative vouchers per `(signer, provider)` lane
6//! as bytes arrive (the signing primitives live in [`crate::voucher`]; the
7//! requester in `decdn-node` drives them), and lets the redeeming provider —
8//! or, if abandoned, its own `closePool` + grace-window `reclaim` — recover
9//! the residual deposit (ADR 003 §node→node).
10//!
11//! This is the *buyer's* mirror of [`crate::lane::LaneState`] /
12//! [`crate::store::PoolStateStore`], which track the *seller's* view. The
13//! two are deliberately separate types:
14//!
15//! - The seller keys lane state by [`crate::lane::LaneKey`] `(pool_id,
16//!   signer, provider)` and records the latest voucher *signature* (to
17//!   submit on-chain). The buyer reuses pool state by **owner address** — the
18//!   reuse unit is "one open pool per owner", almost always the node's own
19//!   address — and does not retain signatures (the buyer is the signer; it
20//!   never submits another party's voucher on-chain). One pool fans out to
21//!   many `(signer, provider)` lanes, so `BuyerPoolState` carries a
22//!   per-[`LaneKey`] progress table alongside the pool-level fields.
23//! - A delegated client — a key that signs under an owner's capability on a
24//!   pool it does not own — holds no pool row. Its lane watermarks are stored
25//!   apart from the pool rows, keyed by `(deployment, pool_id, signer,
26//!   provider)` ([`BuyerPoolStore::delegate_lanes`]).
27//! - The seller validates inbound vouchers (replay guard, #527). The buyer
28//!   only records its own monotonically-advancing cumulative totals — priced
29//!   as `cumulative = received_bytes × rate` from its own BLAKE3-verified
30//!   bytes, with no voucher nonce — so a reused lane resumes from the right
31//!   `bytes`/`amount` after a restart.
32//!
33//! Persistence matters for two reasons: a restart must not re-`openPool`
34//! (wasting a fresh deposit + gas) when a live pool already exists, and the
35//! reclaim path must know which closed pools still hold a refundable
36//! deposit.
37
38use std::collections::HashMap;
39use std::sync::Mutex;
40
41use alloy::primitives::{Address, U256};
42
43use crate::deployment::Deployment;
44use crate::lane::{LaneKey, PoolId};
45use crate::store::StoreError;
46
47/// Buyer-tracked progress on one `(signer, provider)` lane inside a pool: the
48/// cumulative totals of the most-recently-signed voucher on that lane. The
49/// buyer prices `cumulative = received_bytes × rate` from its own
50/// BLAKE3-verified bytes — there is no voucher nonce to track, unlike the
51/// seller-side [`crate::lane::LaneState`], which additionally retains the
52/// accepted signature.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub struct BuyerLaneProgress {
55    /// Cumulative amount of the most-recently-signed voucher on this lane
56    /// (token base units). `U256::ZERO` before the first voucher.
57    pub last_amount: U256,
58    /// Cumulative bytes paid for as of the most-recently-signed voucher.
59    pub last_bytes: U256,
60}
61
62impl BuyerLaneProgress {
63    /// The progress a lane holds after advancing from `recorded` to the
64    /// cumulative `totals`. Totals may stay equal (an idempotent re-record) or
65    /// rise, never fall. A lane with no `recorded` progress accepts any totals.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`BuyerProgressError`] if either total is below the recorded
70    /// value.
71    pub(crate) fn advanced(
72        recorded: Option<Self>,
73        totals: Self,
74    ) -> Result<Self, BuyerProgressError> {
75        if let Some(existing) = recorded {
76            if totals.last_bytes < existing.last_bytes {
77                return Err(BuyerProgressError::Regressed {
78                    field: "bytes",
79                    recorded: existing.last_bytes,
80                    got: totals.last_bytes,
81                });
82            }
83            if totals.last_amount < existing.last_amount {
84                return Err(BuyerProgressError::Regressed {
85                    field: "amount",
86                    recorded: existing.last_amount,
87                    got: totals.last_amount,
88                });
89            }
90        }
91        Ok(totals)
92    }
93
94    /// The progress a lane holds after a rebase to `anchor` and an advance to
95    /// `totals`, with no check against the recorded progress. A `totals`
96    /// below `anchor` on either axis gives `anchor` alone.
97    pub(crate) fn rebased(anchor: Self, totals: Self) -> Self {
98        let advanced =
99            totals.last_amount >= anchor.last_amount && totals.last_bytes >= anchor.last_bytes;
100        if advanced { totals } else { anchor }
101    }
102}
103
104/// Buyer-held state for one pool, fanned out across every `(signer,
105/// provider)` lane the owner has signed vouchers on.
106///
107/// **Field invariant:** `pool_id` is the **store key**, and it is unique only
108/// within one deployment. The contract derives it as
109/// `keccak256(owner, ownerPoolNonce)` over neither the contract address nor
110/// the chain id, and a fresh `PaymentPool` deployment restarts
111/// `ownerPoolNonce` at zero — so the same owner's Nth pool carries the same
112/// `pool_id` in every deployment.
113///
114/// `deployment` — the chain id and the `PaymentPool` address — is the
115/// **deployment tag** that tells two such pools apart. It is not part of the
116/// store key: a [`BuyerPoolStore`] keys its pool rows by `pool_id`
117/// alone, so it cannot hold rows for two deployments under one id — recording
118/// the second overwrites the first. (Its delegate lanes carry the deployment
119/// in their key, [`BuyerPoolStore::delegate_lanes`].) What the tag buys is detection, and every
120/// path that reuses a persisted row owes it a check (see [`Self::is_on`]).
121///
122/// A row from a deployment the node is not configured against carries two numbers
123/// that are wrong here, in opposite directions. Its lane progress would seed
124/// the first voucher at a cumulative the live pool has never redeemed against,
125/// so the provider collects the whole of it for bytes it never delivered here.
126/// Its `deposit` may over-state what the live pool can fund, so vouchers signed
127/// past the real balance stop redeeming. Neither is recoverable by resuming
128/// more carefully; the row has to go.
129///
130/// `owner` is a **secondary reuse index** — the
131/// open-pool trigger looks it up to decide whether to reuse an existing pool
132/// instead of opening a new one — not the identity key: two pools can
133/// (transiently) exist for the same owner, e.g. across a rotate. Per-lane
134/// progress MUST only advance — through [`BuyerPoolState::advance_lane`] (the
135/// validated mutator), [`BuyerPoolState::seed_lane`] (a lane's on-chain
136/// watermark), or hydration from a [`BuyerPoolStore`];
137/// [`BuyerPoolState::rebase_lane`] is the one sanctioned overwrite. On an
138/// adopted row, a lane with a nonzero on-chain watermark enters through
139/// `seed_lane`, so its share of the adopted `totalRedeemed` moves out of
140/// [`BuyerPoolState::redeemed_elsewhere`] and counts once. `lanes` is
141/// private so nothing outside this module can insert a lane's progress
142/// without going through the monotonicity guard; the cross-crate hydration
143/// path (`decdn-node` decoding the redb record) uses
144/// [`BuyerPoolState::hydrate`] instead of a struct literal.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct BuyerPoolState {
147    /// On-chain `poolId` (`keccak256(owner, ownerPoolNonce)`) — learned by
148    /// decoding the `PoolOpened` event from the open tx receipt (atomic with
149    /// the open; no follow-up `getPool` read). The store's key, and unique
150    /// only within one `deployment` — see the field invariant.
151    pub pool_id: PoolId,
152    /// The chain and `PaymentPool` contract this pool lives on — the deployment
153    /// tag, not part of the store key (see the field invariant above).
154    /// `pool_id` repeats across deployments, so this is the only field that
155    /// distinguishes a live pool from a same-id pool on a deployment the node
156    /// is not configured against. The contract address alone is not enough:
157    /// the same deployer nonce yields the same address on every chain.
158    pub deployment: Deployment,
159    /// The on-chain pool owner: put up the deposit, receives the refund, and
160    /// the only address `topUp`/`closePool`/`reclaim` accept. Equals the
161    /// local key for a self-funded pool. The reuse index in
162    /// [`BuyerPoolStore`] (see the field invariant above).
163    pub owner: Address,
164    /// `ERC-20` token bound by the pool (`USDC`).
165    pub token: Address,
166    /// On-chain deposited amount in token base units (initial + any
167    /// top-ups).
168    pub deposit: U256,
169    /// Per-`(signer, provider)` lane progress. Private — mutate only through
170    /// [`Self::advance_lane`], [`Self::seed_lane`], [`Self::rebase_lane`] or
171    /// [`Self::hydrate`].
172    lanes: HashMap<LaneKey, BuyerLaneProgress>,
173    /// Redeemed spend no tracked lane accounts for: the pool's on-chain
174    /// `totalRedeemed` when this row adopted the pool from chain, less the
175    /// watermark of each lane seeded since ([`Self::seed_lane`]). Zero for a
176    /// pool this row opened.
177    redeemed_elsewhere: U256,
178}
179
180impl BuyerPoolState {
181    /// Construct fresh state for a newly-opened buyer pool, with no lanes
182    /// touched yet.
183    #[must_use]
184    pub fn new(
185        pool_id: PoolId,
186        deployment: Deployment,
187        owner: Address,
188        token: Address,
189        deposit: U256,
190    ) -> Self {
191        Self {
192            pool_id,
193            deployment,
194            owner,
195            token,
196            deposit,
197            lanes: HashMap::new(),
198            redeemed_elsewhere: U256::ZERO,
199        }
200    }
201
202    /// Construct the row for a live pool adopted from chain, with no lanes
203    /// tracked yet. `total_redeemed` is the pool's on-chain `totalRedeemed`:
204    /// what lanes outside the row already paid out, which the row counts as
205    /// [`Self::redeemed_elsewhere`] until seeded lanes take their shares.
206    #[must_use]
207    pub fn adopt(
208        pool_id: PoolId,
209        deployment: Deployment,
210        owner: Address,
211        token: Address,
212        deposit: U256,
213        total_redeemed: U256,
214    ) -> Self {
215        Self {
216            redeemed_elsewhere: total_redeemed,
217            ..Self::new(pool_id, deployment, owner, token, deposit)
218        }
219    }
220
221    /// The redeemed spend no tracked lane accounts for (see the field).
222    #[must_use]
223    pub const fn redeemed_elsewhere(&self) -> U256 {
224        self.redeemed_elsewhere
225    }
226
227    /// What the pool has spent as far as this row knows: every tracked lane's
228    /// cumulative amount plus the redeemed spend no tracked lane accounts for.
229    /// A lower bound: a voucher another row signed and no provider redeemed
230    /// yet is in neither.
231    #[must_use]
232    pub fn pool_spend(&self) -> U256 {
233        self.committed_amount()
234            .saturating_add(self.redeemed_elsewhere)
235    }
236
237    /// Record `lane`'s on-chain watermark `(bytes, amount)` as its progress.
238    ///
239    /// A lane new to the row moves `amount` out of
240    /// [`Self::redeemed_elsewhere`]: the adopted `totalRedeemed` holds at most
241    /// it (less, if the lane redeemed more after adoption), and the lane
242    /// accounts for it from then on. So the pool spend counts it once,
243    /// and keeps counting the other lanes' redeemed spend as this lane
244    /// advances. A lane the row already tracks advances monotonically, as
245    /// through [`Self::advance_lane`].
246    ///
247    /// # Errors
248    ///
249    /// Returns [`BuyerProgressError`] if a tracked lane's `bytes` or `amount`
250    /// is below its recorded value.
251    pub fn seed_lane(
252        &mut self,
253        lane: LaneKey,
254        bytes: U256,
255        amount: U256,
256    ) -> Result<(), BuyerProgressError> {
257        let new = !self.lanes.contains_key(&lane);
258        self.advance_lane(lane, bytes, amount)?;
259        if new {
260            self.redeemed_elsewhere = self.redeemed_elsewhere.saturating_sub(amount);
261        }
262        Ok(())
263    }
264
265    /// Whether this row describes a pool on `deployment`: the same chain and
266    /// the same `PaymentPool` contract.
267    ///
268    /// Every path that reuses a persisted row must check this first, and treat
269    /// a `false` as "no row": it means the row was written against a different
270    /// `PaymentPool` deployment, and its `pool_id` may nonetheless name an
271    /// existing, unrelated pool here (see the field invariant).
272    #[must_use]
273    pub fn is_on(&self, deployment: Deployment) -> bool {
274        self.deployment == deployment
275    }
276
277    /// Reconstruct pool state from a trusted persistent store — the one
278    /// cross-crate path allowed to seed per-lane progress directly (mirrors
279    /// [`crate::lane::LaneState::hydrate`]). `redeemed_elsewhere` is the stored
280    /// [`Self::redeemed_elsewhere`].
281    #[must_use]
282    pub fn hydrate(
283        pool_id: PoolId,
284        deployment: Deployment,
285        owner: Address,
286        token: Address,
287        deposit: U256,
288        lanes: Vec<(LaneKey, BuyerLaneProgress)>,
289        redeemed_elsewhere: U256,
290    ) -> Self {
291        Self {
292            pool_id,
293            deployment,
294            owner,
295            token,
296            deposit,
297            lanes: lanes.into_iter().collect(),
298            redeemed_elsewhere,
299        }
300    }
301
302    /// The tracked progress on `lane`, or `None` if this pool has never
303    /// signed a voucher on it.
304    #[must_use]
305    pub fn lane_progress(&self, lane: LaneKey) -> Option<BuyerLaneProgress> {
306        self.lanes.get(&lane).copied()
307    }
308
309    /// Every tracked lane and its progress, in no particular order. Callers
310    /// that need a stable order (e.g. a deterministic on-disk encoding) sort
311    /// the result themselves.
312    pub fn lanes(&self) -> impl Iterator<Item = (LaneKey, BuyerLaneProgress)> + '_ {
313        self.lanes.iter().map(|(k, v)| (*k, *v))
314    }
315
316    /// The cumulative amount vouchered across every `(signer, provider)` lane
317    /// this row tracks. It leaves out [`Self::redeemed_elsewhere`];
318    /// [`Self::pool_spend`] is the row's view of what the shared deposit has
319    /// spent.
320    #[must_use]
321    pub fn committed_amount(&self) -> U256 {
322        self.lanes
323            .values()
324            .fold(U256::ZERO, |acc, p| acc.saturating_add(p.last_amount))
325    }
326
327    /// Number of lanes with tracked progress.
328    #[must_use]
329    pub fn lane_count(&self) -> usize {
330        self.lanes.len()
331    }
332
333    /// Overwrite `lane`'s progress with `anchor`, then advance it to `totals`,
334    /// with no monotonicity check against the recorded progress. Only for a
335    /// payer whose ledger rebased to the node's authenticated watermark, behind
336    /// the record on `amount` (`PoolLedger::rebase` in `decdn-client`): the
337    /// recorded progress holds vouchers the node never accepted, or the anchor
338    /// is ahead of it on bytes, and [`Self::advance_lane`] would refuse either
339    /// move. `totals` is what the ledger has paid since the rebase;
340    /// a `totals` below `anchor` on either axis records `anchor` alone.
341    pub fn rebase_lane(
342        &mut self,
343        lane: LaneKey,
344        anchor: BuyerLaneProgress,
345        totals: BuyerLaneProgress,
346    ) {
347        self.lanes
348            .insert(lane, BuyerLaneProgress::rebased(anchor, totals));
349    }
350
351    /// Advance `lane`'s cumulative totals to the reported values, enforcing
352    /// monotonicity (vouchers are cumulative over a lane's lifetime, so
353    /// totals may stay equal — an idempotent re-record — or rise, never
354    /// fall). A lane with no prior progress accepts any totals (there is no
355    /// watermark to regress against). This is the sanctioned lane mutator;
356    /// prefer it over reaching into `lanes` directly (see the type's field
357    /// invariant).
358    ///
359    /// # Errors
360    ///
361    /// Returns [`BuyerProgressError`] if `bytes` or `amount` is below the
362    /// lane's currently-recorded value.
363    pub fn advance_lane(
364        &mut self,
365        lane: LaneKey,
366        bytes: U256,
367        amount: U256,
368    ) -> Result<(), BuyerProgressError> {
369        let progress = BuyerLaneProgress::advanced(
370            self.lanes.get(&lane).copied(),
371            BuyerLaneProgress {
372                last_amount: amount,
373                last_bytes: bytes,
374            },
375        )?;
376        self.lanes.insert(lane, progress);
377        Ok(())
378    }
379}
380
381/// Failure mode for [`BuyerPoolState::advance_lane`]: a reported cumulative
382/// total regressed below the recorded one (a caller bug — vouchers never
383/// decrease).
384#[derive(Debug, thiserror::Error, PartialEq, Eq)]
385pub enum BuyerProgressError {
386    /// `field`'s reported value `got` is below the recorded value.
387    #[error("buyer lane {field} regressed: recorded {recorded}, got {got}")]
388    Regressed {
389        /// Which cumulative field regressed (`bytes` / `amount`).
390        field: &'static str,
391        /// The currently-recorded (higher) value.
392        recorded: U256,
393        /// The reported (lower) value that was rejected.
394        got: U256,
395    },
396}
397
398/// Result of hydrating the buyer-pool store.
399///
400/// A disk-backed store keeps hydration available when one row cannot be
401/// decoded: healthy pools remain usable and reclaimable, while the row's
402/// primary key (`pool_id`) is retained as the only available repair handle —
403/// the primary table is keyed by `pool_id`, so that is the one piece of
404/// identity an undecodable row still exposes; the owner lives inside the
405/// bytes that failed to decode.
406#[derive(Debug, Clone, Default, PartialEq, Eq)]
407pub struct BuyerLoad {
408    /// Successfully decoded buyer pools.
409    pub pools: Vec<BuyerPoolState>,
410    /// Pool ids whose persisted rows could not be decoded. Their deposits
411    /// remain escrowed but untracked until the rows are repaired.
412    pub skipped: Vec<PoolId>,
413}
414
415/// Outcome of an atomic lane write: [`BuyerPoolStore::advance_progress`],
416/// [`BuyerPoolStore::rebase_progress`], [`BuyerPoolStore::seed_progress`], or a
417/// delegate-lane write. A delegate-lane write returns only `Advanced` or
418/// `Regressed`: it has no pool row and no owner index to miss.
419///
420/// The read-check-advance-write happens inside one serialized write
421/// transaction, so these variants describe the committed-row decision rather
422/// than a backend fault (those surface as [`StoreError`], as with
423/// [`BuyerPoolStore::forget_if_pool`]).
424///
425/// `#[must_use]`: the variant is the only signal that nothing was persisted
426/// (`UnknownPool` / `PoolMismatch`) or that the totals regressed — a dropped
427/// outcome silently looks like success.
428#[derive(Debug, PartialEq, Eq)]
429#[must_use]
430pub enum AdvanceOutcome {
431    /// The committed row's lane was advanced and re-persisted durably.
432    Advanced,
433    /// No row is reachable for the owner/pool (the owner's secondary index
434    /// has no entry, or the pool it names is absent — never recorded, or
435    /// the table does not exist yet).
436    UnknownPool,
437    /// The owner's secondary index names a *different* pool than the one the
438    /// caller expected — the owner's slot was replaced by a newer open. The
439    /// caller should treat this as stale and must NOT escalate (writing
440    /// would clobber the live replacement).
441    PoolMismatch,
442    /// The reported totals would regress the lane's committed watermark — a
443    /// real caller bug (vouchers never decrease). Carries the rejecting
444    /// error.
445    Regressed(BuyerProgressError),
446}
447
448/// Outcome of an atomic [`BuyerPoolStore::add_deposit`].
449///
450/// `#[must_use]` for the same reason as [`AdvanceOutcome`]: a dropped
451/// `PoolMismatch` / `UnknownPool` silently looks like a successful credit.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453#[must_use]
454pub enum DepositOutcome {
455    /// `additional` was added to the committed deposit; carries the new
456    /// total.
457    Added(U256),
458    /// No row is reachable for the owner/pool (see
459    /// [`AdvanceOutcome::UnknownPool`]).
460    UnknownPool,
461    /// The committed row is for a different pool (stale; NOT escalated).
462    PoolMismatch,
463}
464
465/// Durable backing store for [`BuyerPoolState`], keyed by `pool_id` (its
466/// on-chain identity) with `owner` as a secondary reuse index.
467///
468/// Mirrors [`crate::store::PoolStateStore`] but for the buyer's view. The
469/// reuse unit is one open pool per owner, so `get_by_owner` is the hot path
470/// the pool-open trigger consults before deciding to reuse vs. open — it
471/// resolves through the owner index to the primary `pool_id`-keyed row.
472/// Implementations MUST persist `record`/`forget` durably (fsync, for
473/// disk-backed impls) before returning `Ok`.
474///
475/// The store also keeps a delegate's lane watermarks, apart from the pool rows
476/// and keyed by deployment and [`LaneKey`] ([`Self::delegate_lanes`]).
477pub trait BuyerPoolStore: Send + Sync {
478    /// Load every persisted buyer pool. Called once at bring-up to hydrate
479    /// the in-memory pool map and seed the reclaim sweep.
480    ///
481    /// # Errors
482    ///
483    /// Returns a [`StoreError`] if the backing store itself is unreadable.
484    /// Individual undecodable rows are reported in [`BuyerLoad::skipped`] so
485    /// they do not prevent healthy pools from loading.
486    fn load_all(&self) -> Result<BuyerLoad, StoreError>;
487
488    /// Persist (insert or overwrite) the state for one pool, keyed by
489    /// `state.pool_id` (primary) with `state.owner` maintained as a
490    /// secondary reuse index. MUST be durable before returning `Ok`.
491    ///
492    /// Callers SHOULD pass a `state` whose lane progress is a non-strict
493    /// monotonic successor of any previously-recorded state for
494    /// `state.pool_id` (advance via [`BuyerPoolState::advance_lane`]). The
495    /// trait does not re-validate this — it is a dumb writer; the
496    /// monotonicity invariant is owned upstream.
497    ///
498    /// # Errors
499    ///
500    /// Returns a [`StoreError`] if the write or fsync fails.
501    fn record(&self, state: &BuyerPoolState) -> Result<(), StoreError>;
502
503    /// Drop the persisted entry the owner index currently maps `owner` to
504    /// (after the pool is reclaimed or closed), removing it from both the
505    /// primary table and the owner index. A no-op if no record exists. MUST
506    /// commit durably.
507    ///
508    /// # Errors
509    ///
510    /// Returns a [`StoreError`] if the delete or durable commit fails.
511    fn forget(&self, owner: Address) -> Result<(), StoreError>;
512
513    /// Point-lookup the live pool by its primary key, or `None` if none is
514    /// tracked. Unlike [`Self::get_by_owner`] this needs no secondary index
515    /// hop.
516    ///
517    /// # Errors
518    ///
519    /// Returns a [`StoreError`] if the backing store is unreadable or the
520    /// record is corrupt.
521    fn get_by_pool_id(&self, pool_id: PoolId) -> Result<Option<BuyerPoolState>, StoreError>;
522
523    /// Compare-and-delete: drop `owner`'s entry **only if** the stored
524    /// record's `pool_id` still equals `pool_id`. Returns `true` if a row was
525    /// deleted, `false` if the stored row was for a different pool (already
526    /// replaced by a newer open) or no row exists.
527    ///
528    /// This guards the reclaim sweep against a lost update: between the
529    /// sweep loading a closed pool and forgetting it, a concurrent
530    /// `open_or_reuse` may have opened a replacement under the same owner
531    /// key. An unconditional [`Self::forget`] would delete the live
532    /// replacement; this CAS deletes only the pool the sweep actually
533    /// reclaimed. MUST commit durably.
534    ///
535    /// # Errors
536    ///
537    /// Returns a [`StoreError`] if the read, delete, or durable commit
538    /// fails.
539    fn forget_if_pool(&self, owner: Address, pool_id: PoolId) -> Result<bool, StoreError>;
540
541    /// Point-lookup the live pool for `owner`, or `None` if none is tracked.
542    /// The open-pool trigger uses this to reuse an existing pool instead of
543    /// opening a new one.
544    ///
545    /// # Errors
546    ///
547    /// Returns a [`StoreError`] if the backing store is unreadable or the
548    /// record is corrupt.
549    fn get_by_owner(&self, owner: Address) -> Result<Option<BuyerPoolState>, StoreError>;
550
551    /// Atomically advance the committed progress for `lane` inside `owner`'s
552    /// pool.
553    ///
554    /// Reads the row, verifies its `pool_id` still equals `pool_id` (the pool
555    /// the caller actually paid on), runs [`BuyerPoolState::advance_lane`]
556    /// against the **committed** lane watermark, and writes the advanced row
557    /// back — all inside one serialized write transaction. This closes the
558    /// lost-update / watermark-regression race that a separate
559    /// `get_by_owner` → mutate → [`Self::record`] sequence exposes when a
560    /// concurrent writer (e.g. [`Self::add_deposit`]) touches the same row
561    /// in the gap. MUST commit durably.
562    ///
563    /// # Errors
564    ///
565    /// Returns a [`StoreError`] only on a backend/codec fault. The
566    /// committed-row decision (advanced / unknown / replaced / regressed) is
567    /// the `Ok` value.
568    fn advance_progress(
569        &self,
570        owner: Address,
571        pool_id: PoolId,
572        lane: LaneKey,
573        bytes: U256,
574        amount: U256,
575    ) -> Result<AdvanceOutcome, StoreError>;
576
577    /// Atomically overwrite the committed progress for `lane` inside `owner`'s
578    /// pool with `anchor` and advance it to `totals`
579    /// ([`BuyerPoolState::rebase_lane`]) — the same owner-index and `pool_id`
580    /// checks and the same single durable write transaction as
581    /// [`Self::advance_progress`], without the monotonicity check against the
582    /// recorded progress. Never returns [`AdvanceOutcome::Regressed`].
583    ///
584    /// # Errors
585    ///
586    /// Returns a [`StoreError`] only on a backend/codec fault.
587    fn rebase_progress(
588        &self,
589        owner: Address,
590        pool_id: PoolId,
591        lane: LaneKey,
592        anchor: BuyerLaneProgress,
593        totals: BuyerLaneProgress,
594    ) -> Result<AdvanceOutcome, StoreError>;
595
596    /// Atomically record `lane`'s on-chain watermark in `owner`'s pool
597    /// ([`BuyerPoolState::seed_lane`]): the same owner-index and `pool_id`
598    /// checks and the same single durable write transaction as
599    /// [`Self::advance_progress`]. A lane new to the row moves its amount out
600    /// of [`BuyerPoolState::redeemed_elsewhere`].
601    ///
602    /// # Errors
603    ///
604    /// Returns a [`StoreError`] only on a backend/codec fault.
605    fn seed_progress(
606        &self,
607        owner: Address,
608        pool_id: PoolId,
609        lane: LaneKey,
610        bytes: U256,
611        amount: U256,
612    ) -> Result<AdvanceOutcome, StoreError>;
613
614    /// Atomically add `additional` to the committed deposit for `owner`'s
615    /// pool.
616    ///
617    /// Reads the **committed** deposit inside the write transaction (never a
618    /// stale snapshot), verifies the row's `pool_id` still equals `pool_id`,
619    /// `saturating_add`s `additional`, and writes back. Used after the
620    /// on-chain `topUp` receipt lands so the persisted deposit is derived
621    /// from the committed row even if a concurrent writer advanced it during
622    /// the RPC. MUST commit durably.
623    ///
624    /// # Errors
625    ///
626    /// Returns a [`StoreError`] only on a backend/codec fault; the
627    /// committed-row decision is the `Ok` value.
628    fn add_deposit(
629        &self,
630        owner: Address,
631        pool_id: PoolId,
632        additional: U256,
633    ) -> Result<DepositOutcome, StoreError>;
634
635    /// The recorded progress of every lane `signer` holds in `pool_id` on
636    /// `deployment`, as `(provider, progress)` pairs ordered by provider.
637    ///
638    /// These are a delegate's lanes: a key that signs under an owner-signed
639    /// capability (ADR 003 § Capability delegation) on a pool another owner
640    /// funds. The store holds no pool row for such a pool, so delegate lanes
641    /// live apart from the pool rows and every other method here ignores them.
642    /// The lanes are keyed by `deployment` because a `pool_id` repeats across
643    /// `PaymentPool` deployments.
644    ///
645    /// # Errors
646    ///
647    /// Returns a [`StoreError`] if the backing store is unreadable or a
648    /// record is corrupt.
649    fn delegate_lanes(
650        &self,
651        deployment: Deployment,
652        pool_id: PoolId,
653        signer: Address,
654    ) -> Result<Vec<(Address, BuyerLaneProgress)>, StoreError>;
655
656    /// Atomically advance the delegate lane `lane` on `deployment` to
657    /// `totals`, with the monotonicity rule of [`BuyerPoolState::advance_lane`].
658    /// A lane with no recorded progress takes the totals as its first record.
659    /// Returns [`AdvanceOutcome::Advanced`] or [`AdvanceOutcome::Regressed`].
660    /// MUST commit durably.
661    ///
662    /// # Errors
663    ///
664    /// Returns a [`StoreError`] on a backend/codec fault, or if the recorded
665    /// lane cannot be decoded.
666    fn advance_delegate_progress(
667        &self,
668        deployment: Deployment,
669        lane: LaneKey,
670        totals: BuyerLaneProgress,
671    ) -> Result<AdvanceOutcome, StoreError>;
672
673    /// Atomically overwrite the delegate lane `lane` on `deployment` with
674    /// `anchor` and advance it to `totals`, with the rule of
675    /// [`BuyerPoolState::rebase_lane`]. The recorded lane is not read, so the
676    /// overwrite also replaces a record that cannot be decoded. Always returns
677    /// [`AdvanceOutcome::Advanced`]. MUST commit durably.
678    ///
679    /// # Errors
680    ///
681    /// Returns a [`StoreError`] only on a backend/codec fault.
682    fn rebase_delegate_progress(
683        &self,
684        deployment: Deployment,
685        lane: LaneKey,
686        anchor: BuyerLaneProgress,
687        totals: BuyerLaneProgress,
688    ) -> Result<AdvanceOutcome, StoreError>;
689}
690
691/// In-memory backing for [`MemoryBuyerPoolStore`]: the primary
692/// `pool_id`-keyed map plus the `owner → pool_id` secondary reuse index,
693/// held behind one mutex so the pair updates atomically (mirrors the redb
694/// table's single-write-transaction discipline).
695#[derive(Debug, Default)]
696struct MemoryInner {
697    pools: HashMap<PoolId, BuyerPoolState>,
698    owner_index: HashMap<Address, PoolId>,
699    delegate_lanes: HashMap<(Deployment, LaneKey), BuyerLaneProgress>,
700}
701
702/// In-memory [`BuyerPoolStore`] for tests and the trait's reference
703/// semantics. Not durable — drops with the process. The runtime uses the
704/// redb-backed impl in `crates/node`.
705#[derive(Debug, Default)]
706pub struct MemoryBuyerPoolStore {
707    inner: Mutex<MemoryInner>,
708}
709
710impl MemoryBuyerPoolStore {
711    /// Construct an empty store.
712    #[must_use]
713    pub fn new() -> Self {
714        Self::default()
715    }
716
717    /// Snapshot the current entry count (test helper).
718    #[must_use]
719    pub fn len(&self) -> usize {
720        self.inner.lock().map_or(0, |m| m.pools.len())
721    }
722
723    /// `true` when no pools are tracked.
724    #[must_use]
725    pub fn is_empty(&self) -> bool {
726        self.len() == 0
727    }
728}
729
730impl BuyerPoolStore for MemoryBuyerPoolStore {
731    fn load_all(&self) -> Result<BuyerLoad, StoreError> {
732        let guard = self
733            .inner
734            .lock()
735            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
736        Ok(BuyerLoad {
737            pools: guard.pools.values().cloned().collect(),
738            skipped: Vec::new(),
739        })
740    }
741
742    fn record(&self, state: &BuyerPoolState) -> Result<(), StoreError> {
743        let mut guard = self
744            .inner
745            .lock()
746            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
747        guard.owner_index.insert(state.owner, state.pool_id);
748        guard.pools.insert(state.pool_id, state.clone());
749        Ok(())
750    }
751
752    fn forget(&self, owner: Address) -> Result<(), StoreError> {
753        let mut guard = self
754            .inner
755            .lock()
756            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
757        if let Some(pool_id) = guard.owner_index.remove(&owner) {
758            guard.pools.remove(&pool_id);
759        }
760        Ok(())
761    }
762
763    fn forget_if_pool(&self, owner: Address, pool_id: PoolId) -> Result<bool, StoreError> {
764        let mut guard = self
765            .inner
766            .lock()
767            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
768        if guard.owner_index.get(&owner) == Some(&pool_id) {
769            guard.owner_index.remove(&owner);
770            guard.pools.remove(&pool_id);
771            return Ok(true);
772        }
773        Ok(false)
774    }
775
776    fn get_by_pool_id(&self, pool_id: PoolId) -> Result<Option<BuyerPoolState>, StoreError> {
777        let guard = self
778            .inner
779            .lock()
780            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
781        Ok(guard.pools.get(&pool_id).cloned())
782    }
783
784    fn get_by_owner(&self, owner: Address) -> Result<Option<BuyerPoolState>, StoreError> {
785        let guard = self
786            .inner
787            .lock()
788            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
789        Ok(guard
790            .owner_index
791            .get(&owner)
792            .and_then(|pool_id| guard.pools.get(pool_id))
793            .cloned())
794    }
795
796    fn advance_progress(
797        &self,
798        owner: Address,
799        pool_id: PoolId,
800        lane: LaneKey,
801        bytes: U256,
802        amount: U256,
803    ) -> Result<AdvanceOutcome, StoreError> {
804        let mut guard = self
805            .inner
806            .lock()
807            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
808        let Some(mapped) = guard.owner_index.get(&owner).copied() else {
809            return Ok(AdvanceOutcome::UnknownPool);
810        };
811        if mapped != pool_id {
812            return Ok(AdvanceOutcome::PoolMismatch);
813        }
814        let Some(state) = guard.pools.get_mut(&pool_id) else {
815            return Ok(AdvanceOutcome::UnknownPool);
816        };
817        match state.advance_lane(lane, bytes, amount) {
818            Ok(()) => Ok(AdvanceOutcome::Advanced),
819            Err(err) => Ok(AdvanceOutcome::Regressed(err)),
820        }
821    }
822
823    fn rebase_progress(
824        &self,
825        owner: Address,
826        pool_id: PoolId,
827        lane: LaneKey,
828        anchor: BuyerLaneProgress,
829        totals: BuyerLaneProgress,
830    ) -> Result<AdvanceOutcome, StoreError> {
831        let mut guard = self
832            .inner
833            .lock()
834            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
835        let Some(mapped) = guard.owner_index.get(&owner).copied() else {
836            return Ok(AdvanceOutcome::UnknownPool);
837        };
838        if mapped != pool_id {
839            return Ok(AdvanceOutcome::PoolMismatch);
840        }
841        let Some(state) = guard.pools.get_mut(&pool_id) else {
842            return Ok(AdvanceOutcome::UnknownPool);
843        };
844        state.rebase_lane(lane, anchor, totals);
845        Ok(AdvanceOutcome::Advanced)
846    }
847
848    fn seed_progress(
849        &self,
850        owner: Address,
851        pool_id: PoolId,
852        lane: LaneKey,
853        bytes: U256,
854        amount: U256,
855    ) -> Result<AdvanceOutcome, StoreError> {
856        let mut guard = self
857            .inner
858            .lock()
859            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
860        let Some(mapped) = guard.owner_index.get(&owner).copied() else {
861            return Ok(AdvanceOutcome::UnknownPool);
862        };
863        if mapped != pool_id {
864            return Ok(AdvanceOutcome::PoolMismatch);
865        }
866        let Some(state) = guard.pools.get_mut(&pool_id) else {
867            return Ok(AdvanceOutcome::UnknownPool);
868        };
869        match state.seed_lane(lane, bytes, amount) {
870            Ok(()) => Ok(AdvanceOutcome::Advanced),
871            Err(err) => Ok(AdvanceOutcome::Regressed(err)),
872        }
873    }
874
875    fn add_deposit(
876        &self,
877        owner: Address,
878        pool_id: PoolId,
879        additional: U256,
880    ) -> Result<DepositOutcome, StoreError> {
881        let mut guard = self
882            .inner
883            .lock()
884            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
885        let Some(mapped) = guard.owner_index.get(&owner).copied() else {
886            return Ok(DepositOutcome::UnknownPool);
887        };
888        if mapped != pool_id {
889            return Ok(DepositOutcome::PoolMismatch);
890        }
891        let Some(state) = guard.pools.get_mut(&pool_id) else {
892            return Ok(DepositOutcome::UnknownPool);
893        };
894        state.deposit = state.deposit.saturating_add(additional);
895        Ok(DepositOutcome::Added(state.deposit))
896    }
897
898    fn delegate_lanes(
899        &self,
900        deployment: Deployment,
901        pool_id: PoolId,
902        signer: Address,
903    ) -> Result<Vec<(Address, BuyerLaneProgress)>, StoreError> {
904        let guard = self
905            .inner
906            .lock()
907            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
908        let mut lanes: Vec<(Address, BuyerLaneProgress)> = guard
909            .delegate_lanes
910            .iter()
911            .filter(|((on, lane), _)| {
912                *on == deployment && lane.pool_id == pool_id && lane.signer == signer
913            })
914            .map(|((_, lane), progress)| (lane.provider, *progress))
915            .collect();
916        lanes.sort_by_key(|(provider, _)| *provider);
917        Ok(lanes)
918    }
919
920    fn advance_delegate_progress(
921        &self,
922        deployment: Deployment,
923        lane: LaneKey,
924        totals: BuyerLaneProgress,
925    ) -> Result<AdvanceOutcome, StoreError> {
926        let mut guard = self
927            .inner
928            .lock()
929            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
930        let key = (deployment, lane);
931        match BuyerLaneProgress::advanced(guard.delegate_lanes.get(&key).copied(), totals) {
932            Ok(progress) => {
933                guard.delegate_lanes.insert(key, progress);
934                Ok(AdvanceOutcome::Advanced)
935            }
936            Err(err) => Ok(AdvanceOutcome::Regressed(err)),
937        }
938    }
939
940    fn rebase_delegate_progress(
941        &self,
942        deployment: Deployment,
943        lane: LaneKey,
944        anchor: BuyerLaneProgress,
945        totals: BuyerLaneProgress,
946    ) -> Result<AdvanceOutcome, StoreError> {
947        let mut guard = self
948            .inner
949            .lock()
950            .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
951        guard.delegate_lanes.insert(
952            (deployment, lane),
953            BuyerLaneProgress::rebased(anchor, totals),
954        );
955        Ok(AdvanceOutcome::Advanced)
956    }
957}
958
959#[cfg(test)]
960mod tests;