Skip to main content

decdn_node/
pool_view.rs

1//! Event-fed on-chain pool view for the serve path (ADR 003 §Sizing).
2//!
3//! The `cdn/client/v1` serve handler holds no RPC client, but two of its gates
4//! need a pool-level chain quantity per request:
5//!
6//! - **Floor-`M` solvency** — the pool's **remaining** balance
7//!   (`getPool.deposit − getPool.totalRedeemed`) minus the refundable floor `M`
8//!   must still cover the credit window, or the node refuses `Unfunded` before
9//!   signing `ok: true`.
10//! - **ADR 011 funder gate** — the pool **owner** (`getPool.owner`) is the funding
11//!   address the origin-blacklist gate evaluates, at open time and mid-stream.
12//!
13//! Both quantities are fully determined by the `PaymentPool` event log, so the
14//! serve path reads them from an in-memory projection the settlement watcher folds
15//! from that log ([`crate::payment_settlement`]) — the common case costs no
16//! `getPool` `eth_call`. The two reads split by whether the caller can block:
17//!
18//! - **Admission** ([`PoolView::status`]) MAY block. The production wrapper
19//!   ([`crate::payment_settlement::ResolvingPoolView`]) reads the projection first
20//!   and, on a miss (a pool opened before the watcher's cold-start head), does ONE
21//!   `getPool` to confirm the pool exists and is solvent BEFORE a serve is
22//!   admitted. An absent, closed, or errored pool yields `None` and the admit gate
23//!   refuses — it does not fail open.
24//! - **Mid-stream** ([`PoolView::cached_status`]) MUST NOT block: it reads the
25//!   projection only. An admitted stream's pool was seeded at admission and a
26//!   projection entry persists until the pool is forgotten (a `PoolReclaimed`
27//!   removes it), so a `None` here is not expected for a live stream; if it does
28//!   occur it fails open, since the on-chain `redeem` is the backstop and a chain
29//!   read at a voucher boundary would stall delivery.
30//!
31//! The bare [`PoolProjection`] is the projection itself: its `status` returns
32//! `None` for a pool it has not folded and never touches the network. Tests wire
33//! it (or a fake) directly; production wraps it in the `getPool`-on-miss view.
34
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::Duration;
38
39use alloy::primitives::{Address, B256, U256};
40use arc_swap::ArcSwap;
41use decdn_incentive::payment_pool::{PaymentPool, SignerAuthorization};
42
43/// Wall-clock cadence for the serve loops' mid-stream pool-solvency re-check
44/// (ADR 003 §Pool solvency policy).
45///
46/// The re-check re-reads this projection to catch a pool other lanes drain
47/// `remaining` down on mid-stream. Two facts make a wall-clock cadence — not a
48/// per-voucher-boundary one — the right frequency:
49///
50/// - The projection only advances as the settlement watcher folds
51///   `PoolRedeemed` logs (roughly the event-poll cadence), so re-reading it
52///   faster than that returns the same value — wasted work on exactly the fast
53///   streams that cross voucher boundaries most often.
54/// - Per-stream throughput is bounded (credit window + voucher pacing), so a
55///   wall-clock interval `T` bounds worst-case over-delivery on a drained pool
56///   to `T × per-stream-rate` — an explicit, bounded exposure. The on-chain
57///   `redeem` (`min(desired, remaining)`, partial-on-drain) remains the backstop.
58///
59/// At ~2 s a ~1 Gbps stream over-delivers at most ~250 MB before it stops —
60/// sub-percent of a multi-GB blob, the core large-file workload.
61pub const POOL_RECHECK_INTERVAL: Duration = Duration::from_secs(2);
62
63/// A pool's on-chain lifecycle, folded from the `PaymentPool` event log. `Open`
64/// pools accept top-ups and redemptions; a `Closing` pool accepts redemptions
65/// only until `deadline` (the on-chain dispute deadline), after which
66/// `redeemMany` reverts `PoolClosed`. A pool never returns to `Open` once
67/// `Closing`, and a `Closing` pool cannot be topped up, so its `remaining` only
68/// falls.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
70pub enum Lifecycle {
71    /// Accepts top-ups and redemptions.
72    #[default]
73    Open,
74    /// Accepts redemptions only, and only until the deadline. A pool never
75    /// returns to `Open`.
76    Closing {
77        /// Unix seconds after which redemption reverts `PoolClosed`.
78        deadline: u64,
79    },
80}
81
82/// The per-pool chain quantities the serve gates read.
83#[derive(Clone, Copy, Debug)]
84pub struct PoolStatus {
85    /// The pool owner — the ADR 011 funder subject and refund destination.
86    pub owner: Address,
87    /// `deposit − totalRedeemed`, the balance the floor-`M` guard reserves against.
88    pub remaining: U256,
89    /// The pool's lifecycle: `Open`, or `Closing` with its dispute deadline.
90    pub lifecycle: Lifecycle,
91}
92
93/// A per-request source of [`PoolStatus`]. Trait so the handler holds it behind an
94/// `Arc<dyn PoolView>` and tests pass a fake (or `None`) without a chain.
95#[async_trait::async_trait]
96pub trait PoolView: Send + Sync + std::fmt::Debug {
97    /// The pool's status, or `None` if the pool cannot be confirmed. The
98    /// stream-admission read: a caller here may tolerate a blocking source, so an
99    /// implementation MAY do slow work. The bare [`PoolProjection`] reads from
100    /// memory and returns `None` for a pool it has not folded; the production
101    /// wrapper ([`crate::payment_settlement::ResolvingPoolView`]) confirms such a
102    /// pool with one `getPool` before returning, so the admit gate refuses a
103    /// pool it still cannot confirm rather than failing open.
104    async fn status(&self, pool_id: B256) -> Option<PoolStatus>;
105
106    /// A read that MUST NOT block: it returns `None` rather than doing any slow or
107    /// remote work. The mid-stream serve re-check calls it at each voucher
108    /// boundary, where a blocking read would stall delivery. The default delegates
109    /// to [`Self::status`] for in-memory test doubles; [`PoolProjection`] serves
110    /// both from the same in-memory map, so neither ever touches the network.
111    async fn cached_status(&self, pool_id: B256) -> Option<PoolStatus> {
112        self.status(pool_id).await
113    }
114
115    /// The admit-path signer confirm: the request's voucher `signer` on-chain
116    /// authorization in `pool_id` (ADR 003 §Capability delegation, §Pool
117    /// solvency). A signer's `cap` is shared across every provider, so a signer
118    /// that has drawn its full `cap` at other nodes is uncashable here. Its
119    /// registered `cap` and `expiry` are write-once, so they bound every voucher
120    /// whatever capability the client presents.
121    ///
122    /// - `Some(SignerAuthorization::Unregistered)` — no on-chain constraint. An
123    ///   unregistered signer (it has spent nothing on-chain, so it admits on its
124    ///   presented capability) or no chain wired.
125    /// - `Some(SignerAuthorization::Registered { .. })` — the registered terms and
126    ///   the signer's current `spent`.
127    /// - `None` — the on-chain read faulted and no registered read of this
128    ///   signer is held. The caller refuses it as unconfirmed rather than fail
129    ///   open. An earlier `Unregistered` read is not trusted after a fault,
130    ///   because a registration can land at any time.
131    ///
132    /// The default reports every signer unregistered: the bare [`PoolProjection`]
133    /// and test doubles hold no chain. The production wrapper
134    /// ([`crate::payment_settlement::ResolvingPoolView`]) overrides it with one
135    /// `getAuthorization` per `(pool, signer)`, held for good once it reads
136    /// `Registered` and with `spent` brought up to the projection's fold.
137    async fn signer_authorization(
138        &self,
139        pool_id: B256,
140        signer: Address,
141    ) -> Option<SignerAuthorization> {
142        let _ = (pool_id, signer);
143        Some(SignerAuthorization::Unregistered)
144    }
145
146    /// The mid-stream signer-drain read: a `signer`'s total on-chain `spent` across
147    /// every provider in `pool_id`, from the event-fed projection ONLY — never a
148    /// chain call. The serve loop pairs it with the `cap` the node already holds on
149    /// the lane to test `held_cap − spent` headroom at the [`POOL_RECHECK_INTERVAL`]
150    /// cadence, so a signer draining its shared `cap` at another node mid-stream stops
151    /// this stream before it over-delivers unredeemable bytes (ADR 003 §Pool
152    /// solvency).
153    ///
154    /// - `Some(spent)` — the folded cross-provider total, `0` for a signer that has
155    ///   redeemed nothing in a pool the projection knows.
156    /// - `None` — no projection is wired (a chain-free test double), so the re-check
157    ///   is skipped and delivery continues; the on-chain `redeemMany` `min(desired,
158    ///   cap − spent)` remains the backstop.
159    ///
160    /// The default returns `None`. [`PoolProjection`] and the production wrapper
161    /// ([`crate::payment_settlement::ResolvingPoolView`]) override it to read the
162    /// projection, matching how [`Self::cached_status`] reads a pool's `remaining`
163    /// without blocking.
164    async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
165        let _ = (pool_id, signer);
166        None
167    }
168}
169
170/// Per-pool state the projection folds from the `PaymentPool` event log.
171#[derive(Clone, Debug, Default)]
172struct PoolEntry {
173    /// The pool owner, from `PoolOpened`.
174    owner: Address,
175    /// The current on-chain `deposit` (token base units): set by `PoolOpened`, then
176    /// re-set to `newDeposit` by each `PoolToppedUp`.
177    deposit: u64,
178    /// `Σ` of every lane's latest `newPaidCumulative` across all providers. This
179    /// equals on-chain `totalRedeemed`, which accumulates only per-lane deltas — so
180    /// summing each lane's *latest* cumulative reconstructs the same total.
181    total_redeemed: u64,
182    /// Each `(signer, provider)` lane's latest paid cumulative, so a re-delivered
183    /// `PoolRedeemed` (watcher retry / reorg rewind) folds only the positive
184    /// advance and the total stays exact under replay.
185    lanes: HashMap<(Address, Address), u64>,
186    /// Per-signer `Σ` of that signer's lane advances across every provider — its
187    /// total on-chain `spent`, the same positive deltas `total_redeemed` folds but
188    /// bucketed by signer. Maintained in `record_redeemed` so the mid-stream signer
189    /// cap-headroom read ([`PoolProjection::signer_spent`]) is O(1) rather than a
190    /// scan of `lanes` on every voucher-boundary re-check. Idempotent and monotone
191    /// for the same reason `total_redeemed` is: only a lane's positive advance folds.
192    signer_spent: HashMap<Address, u64>,
193    /// The pool's lifecycle: `Open` by default, `Closing { deadline }` after a
194    /// `PoolCloseInitiated`. `PoolReclaimed` removes the whole entry.
195    lifecycle: Lifecycle,
196}
197
198impl PoolEntry {
199    fn status(&self) -> PoolStatus {
200        PoolStatus {
201            owner: self.owner,
202            remaining: U256::from(self.deposit.saturating_sub(self.total_redeemed)),
203            lifecycle: self.lifecycle,
204        }
205    }
206}
207
208/// An event-fed [`PoolView`]: the serve gates read `{owner, remaining}` from an
209/// in-memory projection the settlement watcher folds from the `PaymentPool` event
210/// log, so no serve request costs a `getPool` `eth_call`.
211///
212/// The map is published through an [`ArcSwap`] so a read (`status` at admission,
213/// `cached_status` at every voucher boundary) is a single atomic load — no lock,
214/// no read-modify-write — and never contends with a concurrent reader on the serve
215/// hot path. The settlement watcher's sink is the projection's single writer and
216/// each fold clones-then-publishes the whole map; writes are rare (one per
217/// `PaymentPool` event) next to the per-voucher-boundary reads.
218///
219/// Cheaply cloneable (an `Arc` around the cell). The settlement watcher's sink
220/// holds one clone to WRITE the projection; the serve handler holds another as an
221/// `Arc<dyn PoolView>` to READ it, both sharing the one `ArcSwap`.
222#[derive(Clone, Default)]
223pub struct PoolProjection {
224    pools: Arc<ArcSwap<HashMap<B256, PoolEntry>>>,
225}
226
227impl std::fmt::Debug for PoolProjection {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("PoolProjection")
230            .field("pools", &self.pools.load().len())
231            .finish()
232    }
233}
234
235impl PoolProjection {
236    /// A fresh, empty projection.
237    #[must_use]
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Clone the current map, let `mutate` fold one event into it, and publish the
243    /// result. `rcu` retries `mutate` under a compare-and-swap so an update is never
244    /// lost — the projection has one writer today, but this keeps the fold correct
245    /// without banking on that invariant.
246    fn update(&self, mutate: impl Fn(&mut HashMap<B256, PoolEntry>)) {
247        self.pools.rcu(|current| {
248            let mut next = HashMap::clone(current);
249            mutate(&mut next);
250            next
251        });
252    }
253
254    /// Apply a `PoolOpened(poolId, owner, deposit)`: record the owner and the
255    /// initial deposit. Absolute, so a re-delivered log is idempotent. The deposit
256    /// is a `uint256` on the wire but a `uint64` on-chain; an out-of-range value
257    /// (impossible by construction) saturates to `u64::MAX`, which over-states
258    /// remaining and so fails toward serving — the fail-open direction.
259    pub fn record_opened(&self, pool_id: B256, owner: Address, deposit: U256) {
260        self.update(|pools| {
261            let entry = pools.entry(pool_id).or_default();
262            entry.owner = owner;
263            entry.deposit = u64::try_from(deposit).unwrap_or(u64::MAX);
264        });
265    }
266
267    /// Apply a `PoolToppedUp(poolId, _, newDeposit)`: `deposit = newDeposit`.
268    /// Absolute, so idempotent. Skipped for a pool never opened in the projection's
269    /// scan window — there is no owner to serve, and the serve gate fails open.
270    pub fn record_topup(&self, pool_id: B256, new_deposit: U256) {
271        if !self.pools.load().contains_key(&pool_id) {
272            return;
273        }
274        self.update(|pools| {
275            if let Some(entry) = pools.get_mut(&pool_id) {
276                entry.deposit = u64::try_from(new_deposit).unwrap_or(u64::MAX);
277            }
278        });
279    }
280
281    /// Apply a `PoolRedeemed(poolId, provider, lanes)` for ANY provider: fold each
282    /// lane's positive advance into `total_redeemed`, matching on-chain
283    /// `totalRedeemed += Σ delta`. Monotone and idempotent — a replayed event
284    /// advances no lane and adds nothing. Skipped for a pool the projection has not
285    /// opened (a redemption whose `PoolOpened` predates the scan window): without a
286    /// deposit there is no remaining to reserve, and the serve gate fails open.
287    pub fn record_redeemed(
288        &self,
289        pool_id: B256,
290        provider: Address,
291        lanes: &[PaymentPool::LaneSettled],
292    ) {
293        if !self.pools.load().contains_key(&pool_id) {
294            return;
295        }
296        self.update(|pools| {
297            let Some(entry) = pools.get_mut(&pool_id) else {
298                return;
299            };
300            for lane in lanes {
301                let key = (lane.signer, provider);
302                let prev = entry.lanes.get(&key).copied().unwrap_or(0);
303                if lane.newPaidCumulative > prev {
304                    let delta = lane.newPaidCumulative - prev;
305                    entry.total_redeemed = entry.total_redeemed.saturating_add(delta);
306                    let signer_total = entry.signer_spent.entry(lane.signer).or_insert(0);
307                    *signer_total = signer_total.saturating_add(delta);
308                    entry.lanes.insert(key, lane.newPaidCumulative);
309                }
310            }
311        });
312    }
313
314    /// Apply a `PoolCloseInitiated(poolId, _, disputeDeadline)`: mark the pool
315    /// `Closing`. Skipped for a pool the projection has not opened — there is no
316    /// entry to serve, and reads fail open. A `Closing` pool keeps its
317    /// `remaining` and stays redeemable until `deadline`; `PoolReclaimed` later
318    /// removes it via `forget`.
319    pub fn record_closing(&self, pool_id: B256, deadline: u64) {
320        if !self.pools.load().contains_key(&pool_id) {
321            return;
322        }
323        self.update(|pools| {
324            if let Some(entry) = pools.get_mut(&pool_id) {
325                entry.lifecycle = Lifecycle::Closing { deadline };
326            }
327        });
328    }
329
330    /// Apply a `PoolReclaimed(poolId, ..)`: the pool is `Closed` and its remainder
331    /// refunded. Drop it — a later read returns `None` and the serve gate fails
332    /// open, exactly as for a pool the projection has not yet seen. A
333    /// `PoolCloseInitiated` is folded by [`Self::record_closing`], which marks the
334    /// pool `Closing` but keeps it redeemable until the dispute deadline; `forget`
335    /// removes the whole entry, lifecycle included, once the reclaim actually
336    /// lands.
337    pub fn forget(&self, pool_id: B256) {
338        if !self.pools.load().contains_key(&pool_id) {
339            return;
340        }
341        self.update(|pools| {
342            pools.remove(&pool_id);
343        });
344    }
345
346    /// Seed a pool the projection has NOT observed through the event log, from a
347    /// direct `getPool` snapshot (the lazy cold-start backfill). The settlement
348    /// watcher anchors at head (`ColdStart::Head`), so a pool opened before this
349    /// node's first-ever boot never appears in the
350    /// forward-only `PoolOpened` scan. A node onboarding as a NEW provider to such
351    /// a pool would then have no owner to verify a presented capability against and
352    /// could never register the serve lane; the background pool-owner resolver
353    /// resolves the owner off the serve hot path and folds it here.
354    ///
355    /// Inserts ONLY IF the pool is still absent, so a concurrent event fold — the
356    /// projection's authoritative writer — always wins and this never clobbers a
357    /// live entry. `deposit` is the pool's FULL cumulative `getPool.deposit` (not
358    /// its remaining), seeded with `total_redeemed == 0`, exactly as
359    /// [`Self::record_opened`] seeds an event-observed open: the projection then
360    /// rebuilds `total_redeemed` from post-seed `PoolRedeemed` deltas off a zero
361    /// per-lane baseline, so no redemption is ever double-counted. Redemptions that
362    /// predate the snapshot are simply not folded, which only OVER-states
363    /// `remaining` — the fail-toward-serving direction this module commits to. An
364    /// out-of-range deposit (impossible by construction) saturates to `u64::MAX`,
365    /// the same fail-open direction as [`Self::record_opened`].
366    pub fn record_resolved(
367        &self,
368        pool_id: B256,
369        owner: Address,
370        deposit: U256,
371        lifecycle: Lifecycle,
372    ) {
373        // Fast path: a pool already known (a race where an event fold inserted
374        // between the resolve hint and here) needs no clone-and-publish, matching
375        // `record_topup`/`record_redeemed`. The `update` closure repeats the check
376        // because `rcu` may retry it against a map another writer changed.
377        if self.pools.load().contains_key(&pool_id) {
378            return;
379        }
380        self.update(|pools| {
381            if pools.contains_key(&pool_id) {
382                return;
383            }
384            pools.insert(
385                pool_id,
386                PoolEntry {
387                    owner,
388                    deposit: u64::try_from(deposit).unwrap_or(u64::MAX),
389                    total_redeemed: 0,
390                    lanes: HashMap::new(),
391                    signer_spent: HashMap::new(),
392                    lifecycle,
393                },
394            );
395        });
396    }
397
398    /// A non-blocking snapshot read for callers outside an async trait object.
399    #[must_use]
400    pub fn snapshot(&self, pool_id: B256) -> Option<PoolStatus> {
401        self.pools.load().get(&pool_id).map(PoolEntry::status)
402    }
403
404    /// A `signer`'s total on-chain `spent` in this pool: `Σ` of its lane advances
405    /// across EVERY provider. A signer's `cap` is shared across all providers (ADR
406    /// 003 §Deposit Economics), and on-chain `spent` for a signer is exactly what it
407    /// has redeemed at each provider summed. The projection folds this per-signer
408    /// total in `record_redeemed` alongside `total_redeemed`, so this read is an O(1)
409    /// map lookup — the serve path's mid-stream signer cap-headroom re-check calls it
410    /// every voucher-boundary interval per live stream (ADR 003 §Pool solvency), and
411    /// a scan of the lane map here would cost `streams × lanes` on a busy pool.
412    ///
413    /// `0` for a pool the projection has not folded, or one in which the signer has
414    /// redeemed nothing — both make the re-check's `held_cap − spent` headroom its
415    /// widest, so the re-check fails toward serving on a projection gap (the
416    /// admit-time `getAuthorization` already caught an already-exhausted signer
417    /// authoritatively; this only bounds drain SINCE admit). The folded total cannot
418    /// exceed `total_redeemed`, which is itself bounded by the deposit.
419    #[must_use]
420    pub fn signer_spent(&self, pool_id: B256, signer: Address) -> u64 {
421        self.pools.load().get(&pool_id).map_or(0, |entry| {
422            entry.signer_spent.get(&signer).copied().unwrap_or(0)
423        })
424    }
425}
426
427#[async_trait::async_trait]
428impl PoolView for PoolProjection {
429    async fn status(&self, pool_id: B256) -> Option<PoolStatus> {
430        self.pools.load().get(&pool_id).map(PoolEntry::status)
431    }
432
433    async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
434        Some(self.signer_spent(pool_id, signer))
435    }
436}
437
438#[cfg(test)]
439#[allow(clippy::unwrap_used, clippy::expect_used)]
440mod tests;