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;