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;