decdn_incentive/store.rs
1//! Persistent storage for per-lane voucher state.
2//!
3//! `LaneState` (see [`crate::lane`]) tracks the latest accepted voucher in
4//! memory. Without a backing store, a node restart loses `last_amount` and a
5//! client can resubmit a previously-accepted voucher for a second byte
6//! delivery — see issue #527 and
7//! [ADR 003 §Off-chain voucher state persistence](https://github.com/decdn/decdn/blob/main/adr/003-payments.md).
8//!
9//! This module defines the [`PoolStateStore`] seam: a sync trait that takes each
10//! accepted lane state into a working set and makes it durable on an explicit
11//! [`PoolStateStore::flush`]. The wiring layer (`crates/node`) provides a
12//! `redb`-backed persistent implementation; tests use [`MemoryPoolStateStore`].
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17use alloy::primitives::{B256, U256};
18
19use crate::lane::{LaneKey, LaneState, PoolId};
20
21/// Durable backing store for [`LaneState`], keyed by [`LaneKey`].
22///
23/// The trait is intentionally synchronous. `record` and `forget` mutate a working
24/// set and MAY defer durability to [`flush`](PoolStateStore::flush); an
25/// implementation is equally free to commit inside `record` instead, so the cost
26/// of any one call is the implementation's to document. A caller on a Tokio
27/// runtime therefore decides per implementation what to run from
28/// `tokio::task::spawn_blocking`: against the runtime's redb store, `record` and
29/// `forget` are map writes cheap enough to call inline, and `flush` is the one
30/// that fsyncs.
31pub trait PoolStateStore: Send + Sync {
32 /// Load every persisted lane. Called once during node bring-up so the
33 /// runtime can hydrate its in-memory map before the voucher-accepting
34 /// handler starts.
35 ///
36 /// # Errors
37 ///
38 /// Returns a [`StoreError`] if the backing store is unreadable or contains
39 /// corrupt entries.
40 fn load_all(&self) -> Result<Vec<LaneState>, StoreError>;
41
42 /// Record the post-acceptance state for one lane in the store's working set.
43 /// A disk-backed store MAY buffer this in memory and make it durable later
44 /// via [`flush`](PoolStateStore::flush); it is not required to fsync before
45 /// returning `Ok`. Frontier state is safe to lose on a crash (an honest
46 /// client resumes forward; an un-redeemed replay is still on-chain-payable).
47 ///
48 /// Implementations MUST NOT block: this is called inline on the per-stream
49 /// open path, on a runtime worker, with no timeout around it. An
50 /// implementation that does I/O here stalls a worker for the duration and
51 /// the latency is attributed nowhere.
52 ///
53 /// Callers MUST pass a `LaneState` whose `last_*` tuple is a non-strict
54 /// monotonic successor of any previously-recorded state for the same
55 /// [`LaneKey`]. The trait does not re-validate this —
56 /// [`LaneState::apply_voucher`] is the canonical caller and enforces
57 /// monotonicity upstream.
58 ///
59 /// # Errors
60 ///
61 /// Returns a [`StoreError`] if the write fails. Callers MUST NOT
62 /// advance their in-memory state on failure.
63 fn record(&self, state: &LaneState) -> Result<(), StoreError>;
64
65 /// Drop the persisted entry for a settled lane. A no-op if the lane has no
66 /// record.
67 ///
68 /// A disk-backed store MAY buffer the removal and apply it on the next
69 /// [`flush`](PoolStateStore::flush); it is not required to fsync before
70 /// returning `Ok`.
71 ///
72 /// # Errors
73 ///
74 /// Returns a [`StoreError`] if the underlying delete fails.
75 fn forget(&self, key: LaneKey) -> Result<(), StoreError>;
76
77 /// Point-lookup the persisted state for one lane, or `None` if no record
78 /// exists. Used by the on-chain seller redemption path to read the latest
79 /// accepted voucher (`last_amount` / `last_bytes_delivered` /
80 /// `last_signature`) when deciding whether an accrued claim crosses the
81 /// redemption threshold — always the freshest committed voucher.
82 ///
83 /// # Errors
84 ///
85 /// Returns a [`StoreError`] if the backing store is unreadable or the record
86 /// is corrupt.
87 fn get(&self, key: LaneKey) -> Result<Option<LaneState>, StoreError>;
88
89 /// Force all buffered lane state durable (fsync, for disk-backed impls).
90 ///
91 /// The default is a no-op: a volatile store ([`MemoryPoolStateStore`]) holds
92 /// nothing to flush, and a store that commits inside `record` has nothing
93 /// buffered. A disk-backed implementation MAY override this to buffer
94 /// `record` and `forget` in memory and make them durable here in one fsynced
95 /// transaction.
96 ///
97 /// # Errors
98 ///
99 /// Returns a [`StoreError`] if the durable write fails.
100 fn flush(&self) -> Result<(), StoreError> {
101 Ok(())
102 }
103
104 /// Raise this lane's observed on-chain registration expiry
105 /// (`LaneState::registered_until`) to `registered_until`, monotonically — a
106 /// lower or equal value is a no-op, and `0` (unknown) never lowers a known
107 /// value. Touches ONLY that field, never the replay-critical `last_*` tuple,
108 /// so the seller redeemer can record a fresh registration without racing a
109 /// concurrent voucher advance into a lost update. A no-op for a lane with no
110 /// record. Buffered like `record`; durability is the next `flush`'s job.
111 ///
112 /// # Errors
113 /// Returns a [`StoreError`] if the backing store is unwritable.
114 fn set_registered_until(&self, key: LaneKey, registered_until: u64) -> Result<(), StoreError> {
115 let _ = (key, registered_until);
116 Ok(())
117 }
118
119 /// Raise this lane's paid cumulative (`LaneState::paid_cumulative`) to
120 /// `paid_cumulative`, monotonically — a lower or equal value is a no-op.
121 /// Touches ONLY that field, never the replay-critical `last_*` tuple, so the
122 /// seller `PoolRedeemed` watcher can record an observed redemption without
123 /// racing a concurrent voucher advance into a lost update. A no-op for a lane
124 /// with no record. Buffered like `record`; durability is the next `flush`'s
125 /// job. Persisting it is what stops a restarted node from forgetting prior
126 /// redemptions and re-submitting already-redeemed lanes (#2052).
127 ///
128 /// # Errors
129 /// Returns a [`StoreError`] if the backing store is unwritable.
130 fn set_paid_cumulative(&self, key: LaneKey, paid_cumulative: U256) -> Result<(), StoreError> {
131 let _ = (key, paid_cumulative);
132 Ok(())
133 }
134}
135
136/// A pool this node closed on-chain (graceful shutdown or grace-window sweep)
137/// that is awaiting the grace window to elapse before the provider's accrued
138/// claim finalizes (#327).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct PendingSettle {
141 /// The pool awaiting finalization.
142 pub pool_id: B256,
143 /// Unix seconds after which the grace window has elapsed and the settle may
144 /// proceed. The sweep does not submit until `now >= settle_after`.
145 pub settle_after: u64,
146}
147
148/// Durable set of pools closed by this node that await finalization (#327).
149/// Separate from [`PoolStateStore`] because a settle needs only the pool id and
150/// a timestamp gate — not the voucher state — and the lifecycles differ.
151///
152/// Implementations MUST commit durably (fsync, on disk-backed impls) before
153/// returning `Ok` from `record_pending` / `forget_pending`.
154pub trait PendingSettleStore: Send + Sync {
155 /// Persist a closed pool awaiting settlement. Overwrites any existing entry
156 /// for the same pool (a re-close re-stamps the deadline).
157 ///
158 /// # Errors
159 ///
160 /// Returns a [`StoreError`] if the durable write fails.
161 fn record_pending(&self, entry: &PendingSettle) -> Result<(), StoreError>;
162
163 /// Load every pool awaiting settlement. Called on each sweep tick (and once
164 /// at bring-up) to find pools whose grace window has elapsed.
165 ///
166 /// # Errors
167 ///
168 /// Returns a [`StoreError`] if the backing store is unreadable.
169 fn load_pending(&self) -> Result<Vec<PendingSettle>, StoreError>;
170
171 /// Drop the pending-settle entry for a finalized pool. Idempotent; a no-op
172 /// for an unknown pool.
173 ///
174 /// # Errors
175 ///
176 /// Returns a [`StoreError`] if the durable delete fails.
177 fn forget_pending(&self, pool_id: B256) -> Result<(), StoreError>;
178}
179
180/// Identifies which on-chain event watcher a persisted scan checkpoint belongs
181/// to (#1092). Every variant maps to a stable string key in the single
182/// `watcher_checkpoint_v1` table, so one concrete store holds every watcher's
183/// high-water mark (redb forbids two `Database` handles to one file).
184///
185/// The [`as_str`](CheckpointKey::as_str) literals are **on-disk identifiers**:
186/// renaming one silently forfeits that watcher's resume (its next boot re-scans
187/// from the configured floor instead of the stored block), so each one stays
188/// fixed for as long as the on-disk format does.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum CheckpointKey {
191 /// Redemption watcher `PoolOpened` high-water block.
192 PoolOpened,
193}
194
195impl CheckpointKey {
196 /// The stable on-disk key for this checkpoint — see the type-level doc.
197 #[must_use]
198 pub const fn as_str(self) -> &'static str {
199 match self {
200 Self::PoolOpened => "pool_opened_last_block",
201 }
202 }
203}
204
205/// Durable high-water marks of the last block each on-chain watcher scanned,
206/// keyed by [`CheckpointKey`] (#751, generalized in #1092/#1108). Persisting
207/// them across restarts is what lets a watcher's bring-up backfill cover the
208/// **downtime gap**: an event landing while the node is *down* sits in a block
209/// before the next boot's head, so without a persisted floor a head-anchored
210/// backfill never sees it. On boot the watcher backfills from the stored block
211/// (minus a small reorg margin) up to head; the per-log sinks are idempotent, so
212/// re-scanning the overlap is harmless.
213///
214/// A directly disk-backed implementation MUST commit durably (fsync) before
215/// returning `Ok` from [`record_checkpoint`](Self::record_checkpoint). A debouncing *decorator* MAY relax
216/// that per-call fsync — buffering in memory and coarsening the durable write
217/// cadence — provided it preserves the two invariants this contract rests on,
218/// **per key**: the persisted block is **monotonic** (never lowered) and the
219/// latest buffered block is forced out on graceful shutdown via
220/// [`flush_checkpoint`](KeyedCheckpointStore::flush_checkpoint). Both relaxations
221/// are safe because a lost or lagging checkpoint only ever silently widens the
222/// rescan (more RPC), never narrows it.
223pub trait KeyedCheckpointStore: Send + Sync {
224 /// The last block scanned for `key`, or `None` on a never-written key
225 /// (first-ever boot for that watcher — the caller applies its configured
226 /// none-fallback floor).
227 ///
228 /// # Errors
229 ///
230 /// Returns a [`StoreError`] if the backing store is unreadable.
231 fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError>;
232
233 /// Persist the last block scanned for `key`. Overwrites the prior value.
234 /// Called as each backfill window and live tick advances, so the next boot
235 /// resumes from here.
236 ///
237 /// A debouncing decorator MAY buffer the value in memory and defer the
238 /// durable write to a coarser cadence (the floor only ever lags the true
239 /// scan position, which is safe — see the type-level contract above); such a
240 /// decorator overrides [`flush_checkpoint`](Self::flush_checkpoint) to force
241 /// the buffered value out on graceful shutdown. A directly disk-backed
242 /// implementation commits durably before returning `Ok`.
243 ///
244 /// # Errors
245 ///
246 /// Returns a [`StoreError`] if the durable write fails.
247 fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError>;
248
249 /// Force any buffered checkpoint for `key` to durable storage. The default
250 /// is a no-op: implementations that already commit durably inside
251 /// [`record_checkpoint`](Self::record_checkpoint) have nothing buffered. A
252 /// debouncing decorator overrides this to fsync the latest deferred block,
253 /// and the runtime calls it on graceful shutdown so the most recent scan
254 /// progress is not lost to the next boot's rescan.
255 ///
256 /// # Errors
257 ///
258 /// Returns a [`StoreError`] if the durable write fails.
259 fn flush_checkpoint(&self, key: CheckpointKey) -> Result<(), StoreError> {
260 let _ = key;
261 Ok(())
262 }
263}
264
265/// Failure modes shared by every [`PoolStateStore`] implementation.
266///
267/// Disk-backed impls map their backend errors into [`StoreError::Backend`]
268/// (preserving the underlying message); decoding failures use
269/// [`StoreError::Codec`]; structurally-invalid on-disk records (wrong magic,
270/// truncated value bytes) use [`StoreError::Corrupt`]. The
271/// [`StoreError::UnsupportedSchema`] variant is reserved for the case where an
272/// on-disk record carries a newer `schema_version` than this binary understands.
273#[derive(Debug, thiserror::Error)]
274pub enum StoreError {
275 /// I/O failure (open, read, write, fsync, rename).
276 #[error("pool store I/O: {0}")]
277 Io(#[from] std::io::Error),
278 /// Encoding or decoding failed for an in-store record.
279 #[error("pool store encoding: {0}")]
280 Codec(String),
281 /// Backend-specific error from the underlying KV (e.g. redb transaction
282 /// error). The string captures the backend's own description.
283 #[error("pool store backend: {0}")]
284 Backend(String),
285 /// On-disk record carries a `schema_version` higher than `supported`. The
286 /// node MUST refuse to start rather than silently ignoring fields it does
287 /// not understand — see ADR 003 §Off-chain voucher state persistence.
288 #[error("pool store on-disk schema version {found} not supported (max {supported})")]
289 UnsupportedSchema {
290 /// Version number read from disk.
291 found: u32,
292 /// Highest version this binary understands.
293 supported: u32,
294 },
295 /// Record bytes are structurally invalid — missing magic, truncated, or
296 /// otherwise unparseable. Includes the affected id (when it can be
297 /// recovered) and a free-form detail.
298 #[error("corrupt record for {pool_id:?}: {detail}")]
299 Corrupt {
300 /// Id of the corrupt record, or `None` if the corruption is at the
301 /// table / file level and no key could be recovered.
302 pool_id: Option<PoolId>,
303 /// Human-readable detail for the operator log.
304 detail: String,
305 },
306 /// Post-create permission tightening (chmod) failed. Distinct from
307 /// [`StoreError::Io`] because the operator remediation differs: a
308 /// `PermissionTighten` typically means "the file mode is not `0o600` and the
309 /// OS refused to fix it" (read-only mount, missing capability, EPERM on a
310 /// foreign-owned inode) — recovery requires manual intervention on the
311 /// filesystem, not just retrying the open.
312 #[error("failed to tighten permissions on {path}: {source}")]
313 PermissionTighten {
314 /// Filesystem path of the affected file.
315 path: std::path::PathBuf,
316 /// Underlying I/O error returned by the chmod syscall.
317 source: std::io::Error,
318 },
319 /// The store's database file is already opened (write-locked) by another
320 /// process — typically a second `decdn get` against the same
321 /// `--data-dir`. redb holds a process-exclusive lock for the lifetime of the
322 /// open `Database`, so the second opener fails fast rather than letting two
323 /// processes corrupt the shared voucher watermark (#942). Run one process
324 /// per data dir, give each a distinct `--data-dir`, or let one process
325 /// multiplex internally.
326 #[error(
327 "another decdn process is using the pool store at {path} — run one fetch/pull \
328 at a time per --data-dir, or give each invocation a separate --data-dir"
329 )]
330 AlreadyOpen {
331 /// Filesystem path of the locked database file.
332 path: std::path::PathBuf,
333 },
334 /// The database was not closed cleanly and needs the repair pass that only
335 /// a writable open performs, so a read-only open cannot proceed. Reached by
336 /// the post-mortem read of a crashed daemon's store (#2084): the very case
337 /// that read exists for is also the one that can leave the file unrepaired.
338 ///
339 /// The remedy is a writable open, and which process performs it depends on
340 /// who owns the file — so the caller names it, not this variant.
341 #[error(
342 "the pool store at {path} was not shut down cleanly and needs a repair pass that only \
343 a writable open can run"
344 )]
345 NeedsRepair {
346 /// Filesystem path of the unrepaired database file.
347 path: std::path::PathBuf,
348 },
349 /// No database file exists at the path. Distinct from
350 /// [`StoreError::Backend`] because it is a routine state, not a fault: a
351 /// data dir whose store was deleted to force re-adoption has no file, and
352 /// the honest report is "there is no store here", not an errno.
353 ///
354 /// Only a read-only open produces this. A writable open creates the file.
355 #[error("no pool store exists at {path}")]
356 Absent {
357 /// Filesystem path that holds no database.
358 path: std::path::PathBuf,
359 },
360}
361
362/// In-memory [`PoolStateStore`] for tests and the trait's reference semantics.
363/// Not durable — drops with the process. The runtime uses the redb-backed impl
364/// in `crates/node`.
365#[derive(Debug, Default)]
366pub struct MemoryPoolStateStore {
367 inner: Mutex<HashMap<LaneKey, LaneState>>,
368}
369
370impl MemoryPoolStateStore {
371 /// Construct an empty store.
372 #[must_use]
373 pub fn new() -> Self {
374 Self::default()
375 }
376
377 /// Snapshot the current entry count. Useful in tests to assert that rejected
378 /// vouchers did not write through. A poisoned mutex reports `0` rather than
379 /// panicking.
380 #[must_use]
381 pub fn len(&self) -> usize {
382 self.inner.lock().map_or(0, |m| m.len())
383 }
384
385 /// `true` when no lanes are tracked.
386 #[must_use]
387 pub fn is_empty(&self) -> bool {
388 self.len() == 0
389 }
390}
391
392impl PoolStateStore for MemoryPoolStateStore {
393 fn load_all(&self) -> Result<Vec<LaneState>, StoreError> {
394 let guard = self
395 .inner
396 .lock()
397 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
398 Ok(guard.values().cloned().collect())
399 }
400
401 fn record(&self, state: &LaneState) -> Result<(), StoreError> {
402 let mut guard = self
403 .inner
404 .lock()
405 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
406 let mut next = state.clone();
407 if let Some(existing) = guard.get(&next.key()) {
408 next.registered_until = next.registered_until.max(existing.registered_until);
409 // The voucher-record path carries `paid_cumulative` from the live
410 // in-memory lane, which never learns the redeemed watermark (that
411 // rides the `PoolRedeemed` watcher). Preserve the persisted value so a
412 // voucher advance cannot clobber it back to zero, mirroring
413 // `registered_until`/`owner_sig` (#2052).
414 next.paid_cumulative = next.paid_cumulative.max(existing.paid_cumulative);
415 }
416 guard.insert(next.key(), next);
417 Ok(())
418 }
419
420 fn set_registered_until(&self, key: LaneKey, registered_until: u64) -> Result<(), StoreError> {
421 let mut guard = self
422 .inner
423 .lock()
424 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
425 if let Some(state) = guard.get_mut(&key) {
426 state.registered_until = state.registered_until.max(registered_until);
427 }
428 Ok(())
429 }
430
431 fn set_paid_cumulative(&self, key: LaneKey, paid_cumulative: U256) -> Result<(), StoreError> {
432 let mut guard = self
433 .inner
434 .lock()
435 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
436 if let Some(state) = guard.get_mut(&key) {
437 state.paid_cumulative = state.paid_cumulative.max(paid_cumulative);
438 }
439 Ok(())
440 }
441
442 fn forget(&self, key: LaneKey) -> Result<(), StoreError> {
443 let mut guard = self
444 .inner
445 .lock()
446 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
447 guard.remove(&key);
448 Ok(())
449 }
450
451 fn get(&self, key: LaneKey) -> Result<Option<LaneState>, StoreError> {
452 let guard = self
453 .inner
454 .lock()
455 .map_err(|err| StoreError::Backend(format!("memory store mutex poisoned: {err}")))?;
456 Ok(guard.get(&key).cloned())
457 }
458}
459
460#[cfg(test)]
461mod tests;