Skip to main content

decdn_node/
channel_store.rs

1//! Disk-backed `PoolStateStore` for the node runtime.
2//!
3//! Implements [`PoolStateStore`] against a set of `redb` database files under
4//! `<data_dir>`, one file per durable write family:
5//!
6//! - `lanes.redb` — seller lane frontier (`lane_state_v1`). Each record also
7//!   carries its lane's owner-signed capability material (`owner_sig`), so the
8//!   redeemer's registration payload lands in the same durable write as the
9//!   frontier it backs. The file also holds the deployment stamp
10//!   (`deployment_v1`).
11//! - `settle.redb` — the seller and buyer pending-settle sets.
12//! - `checkpoint.redb` — the settlement watcher's scan checkpoints.
13//! - `buyer.redb` — the buyer pool state and its owner index.
14//!
15//! Each file has its own `redb` write-transaction slot, so a commit in one
16//! family never waits on an unrelated commit in another: the periodic lane
17//! flush, a settlement checkpoint, and a buyer top-up all proceed on
18//! independent writer slots. Families are
19//! never written in one transaction — `redb` forbids a transaction spanning two
20//! `Database` handles — so a crash between two family commits can leave them at
21//! different watermarks, which every family already tolerates (each records and
22//! recovers on its own terms).
23//!
24//! The seller-side state is bound to one `PaymentPool` deployment
25//! ([`Deployment`]). Pool ids repeat across deployments, so `open()` drops the
26//! lane rows, pending settles and watcher checkpoints a different deployment
27//! wrote before it reads any of them. The buyer pool table carries its own
28//! per-row deployment tag (the same chain id and contract address) and is not
29//! touched.
30//!
31//! The lane table is buffered in memory: `open()`
32//! hydrates the working set from disk, `record`/`forget` mutate that working
33//! set only, and an explicit `flush()` call writes every dirty lane and
34//! applies every tombstone in one fsynced commit (redb's default
35//! [`redb::Durability::Immediate`], set explicitly here so a future redb
36//! default change doesn't silently weaken the durability guarantee). A crash
37//! between two flushes loses the unflushed lane advances; the caller decides
38//! the flush cadence.
39//!
40//! The **owner-signed capability material** (`owner_sig`) is a field of the lane
41//! record, not a side table. It is written by the same `record`/`flush` path as
42//! the frontier, in the same fsynced transaction, so it can never be durable-out-
43//! of-step with the frontier it backs: whenever a lane's frontier is on disk, its
44//! `owner_sig` is too, and a `forget` deletes both in one row removal. A crash
45//! between two flushes loses the lane's unflushed advances AND its `owner_sig`
46//! together — never one without the other — and both recover the same way: the
47//! frontier is superseded by the signer's next, higher voucher, and the material
48//! is re-supplied because the client re-sends its capability on the next request
49//! and intake re-writes it onto the lane (#1906).
50//!
51//! See [`decdn_incentive::store`] for the trait contract and
52//! [ADR 003 §Off-chain voucher state persistence] for the protocol rule
53//! this implements: a node MUST advance `(last_amount, last_bytes_delivered)`
54//! for the lane before it continues delivery, and mirror the advance to disk on
55//! a background timer. Without the on-disk mirror a restart re-opens the lane at
56//! amount zero and a client can replay a previously-accepted voucher for a
57//! second byte delivery (issue #527).
58//!
59//! The record persists the latest voucher's signature (so the seller
60//! redemption path can submit it to the on-chain `PaymentPool.redeem` after a
61//! restart without forfeiting the claim), the capability's spending cap, its
62//! expiry (so the node stops serving once the grant lapses), the owner's
63//! signature over the capability (`owner_sig`, the `ownerSig` the redeemer
64//! submits to register the signer on-chain), and the lane's paid cumulative
65//! (`paid_cumulative`, the on-chain redeemed watermark the redeemer subtracts
66//! from owed — persisting it stops a restart from re-submitting already-redeemed
67//! lanes, #2052). All of these are plain fields of the one `StoredLaneState`
68//! record — there is a single on-disk format and no older shape to decode.
69//!
70//! The seller table above is defined here. The **buyer** pool table (#744) is
71//! not: its record codec and every one of its operations live in
72//! [`decdn_incentive::buyer_pool_table`], shared with the client's
73//! `RedbBuyerPoolStore` (#1246). This file contributes only the buyer table's
74//! wiring — the node holds it in its own `buyer.redb`, so a buyer top-up
75//! commits on a writer slot separate from the seller lane flush.
76//!
77//! [ADR 003 §Off-chain voucher state persistence]: ../../../adr/003-payments.md
78
79use std::collections::HashSet;
80use std::path::{Path, PathBuf};
81
82use crossbeam_queue::SegQueue;
83use dashmap::DashMap;
84use dashmap::mapref::entry::Entry;
85
86use alloy::primitives::{Address, B256, U256};
87use decdn_common::identity;
88/// The `PaymentPool` deployment the store's seller-side state is bound to.
89pub use decdn_incentive::Deployment;
90use decdn_incentive::buyer_pool_table::BuyerPoolTable;
91use decdn_incentive::store::{
92    CheckpointKey, KeyedCheckpointStore, PendingSettle, PendingSettleStore, PoolStateStore,
93    StoreError,
94};
95use decdn_incentive::{
96    AdvanceOutcome, BuyerLaneProgress, BuyerLoad, BuyerPoolState, BuyerPoolStore, DepositOutcome,
97    LaneChain, LaneKey, LaneState, PoolId,
98};
99use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition};
100use serde::{Deserialize, Serialize};
101
102/// File name of the seller lane redb database within `data_dir`.
103const LANES_DB_FILE: &str = decdn_common::data_dir::NODE_LANES_DB_FILE;
104
105/// File name of the pending-settle redb database (seller and buyer sets).
106const SETTLE_DB_FILE: &str = decdn_common::data_dir::NODE_SETTLE_DB_FILE;
107
108/// File name of the settlement-watcher checkpoint redb database.
109const CHECKPOINT_DB_FILE: &str = decdn_common::data_dir::NODE_CHECKPOINT_DB_FILE;
110
111/// File name of the buyer pool redb database (buyer state + owner index).
112///
113/// Shared with the CLI through [`decdn_common::data_dir`] so both sides name
114/// the same file: an operator tool that wants this store has to know it is not
115/// the client's
116/// [`CLIENT_BUYER_DB_FILE`](decdn_common::data_dir::CLIENT_BUYER_DB_FILE).
117const BUYER_DB_FILE: &str = decdn_common::data_dir::NODE_BUYER_DB_FILE;
118
119/// Byte width of a [`LaneKey`] on disk: `pool_id ‖ signer ‖ provider` =
120/// `32 + 20 + 20`.
121const LANE_KEY_LEN: usize = 72;
122
123/// On-disk file mode (`0o600` — owner-only read+write). Defense-in-depth: the
124/// containing `data_dir` is already enforced to `0o700` by
125/// [`decdn_common::identity::ensure_data_dir`], so other local users cannot
126/// reach the file via path traversal, but we still tighten the file mode in
127/// case the directory ACL is widened out-of-band.
128#[cfg(unix)]
129const DB_FILE_MODE: u32 = 0o600;
130
131/// `schema_version` this binary writes and decodes. A record carrying a higher
132/// value causes `load_all` to refuse to start (see
133/// [`StoreError::UnsupportedSchema`]) — a cheap forward tripwire so a store a
134/// newer binary wrote is rejected loudly rather than silently mis-decoded. It
135/// is not a migration hook: there is one on-disk format and no older shape to
136/// read.
137const SUPPORTED_SCHEMA_VERSION: u32 = 1;
138
139/// Sanity ceiling on trailing bytes per lane record. Trailing bytes past the
140/// known fields are tolerated (a newer writer's additive field is a no-op to
141/// us — see [`StoredLaneState`]), but a `remainder.len()` above this threshold
142/// is logged as a warning so an honest schema-skew incident or malicious
143/// padding attempt is observable in operator logs without re-introducing the
144/// strict-decoding regression issue #527's reviewers warned against.
145const SANE_TRAILER_MAX_BYTES: usize = 256;
146
147/// redb table holding the per-lane voucher state.
148///
149/// Key: the [`LaneKey`] encoding `pool_id ‖ signer ‖ provider` (`[u8; 72]`).
150/// Value: postcard-encoded [`StoredLaneState`] (variable length).
151const LANE_TABLE: TableDefinition<'_, &[u8; LANE_KEY_LEN], &[u8]> =
152    TableDefinition::new("lane_state_v1");
153
154/// redb table holding the pending-settle set (#327): pools this node closed
155/// on-chain that await the grace window to elapse before their accrued claim
156/// finalizes. Lives in `settle.redb`.
157///
158/// Key: raw `PoolId` bytes (`[u8; 32]`).
159/// Value: the grace-window deadline (Unix seconds). A fixed-width native `u64`
160/// value needs no postcard envelope (unlike [`LANE_TABLE`]), so there is no
161/// schema-version trailer to evolve here.
162const PENDING_SETTLE_TABLE: TableDefinition<'_, &[u8; 32], u64> =
163    TableDefinition::new("pending_settle_v1");
164
165/// redb table holding pools this node closed as the **buyer** (#988) — the
166/// unilateral close of an unreachable provider's idle pool — that await the
167/// grace window. Same shape as [`PENDING_SETTLE_TABLE`] (key: `PoolId` bytes,
168/// value: deadline) but a SEPARATE table so the buyer settle sweep and the
169/// seller settle sweep never settle each other's closes. Lives in `settle.redb`.
170const BUYER_PENDING_SETTLE_TABLE: TableDefinition<'_, &[u8; 32], u64> =
171    TableDefinition::new("buyer_pending_settle_v1");
172
173/// redb table holding each on-chain watcher's scan checkpoint (#751, keyed in
174/// #1092/#1108): the last block scanned per [`CheckpointKey`], so a bring-up
175/// backfill resumes across restarts and covers events landing while the node was
176/// down. Lives in `checkpoint.redb`. One `&str` key per
177/// watcher (the [`CheckpointKey::as_str`] literals); a fixed-width native `u64`
178/// value needs no postcard envelope.
179const WATCHER_CHECKPOINT_TABLE: TableDefinition<'_, &str, u64> =
180    TableDefinition::new("watcher_checkpoint_v1");
181
182/// redb table holding the store's deployment stamp: the [`Deployment`] every
183/// row in [`LANE_TABLE`], both pending-settle tables, and
184/// [`WATCHER_CHECKPOINT_TABLE`] was written against. Lives in `lanes.redb`, so
185/// a restamp and the lane-table drop it forces commit in one transaction.
186///
187/// One row, key [`DEPLOYMENT_KEY`], value [`Deployment::to_bytes`]:
188/// `chain_id (8 bytes, big-endian) ‖ payment_pool (20 bytes)`. A fixed-width
189/// value needs no postcard envelope. A stamp that does not decode is
190/// [`StoreError::Corrupt`]: the stamp decides which rows survive the open, so a
191/// stamp this binary cannot read must stop bring-up.
192const DEPLOYMENT_TABLE: TableDefinition<'_, &str, &[u8]> = TableDefinition::new("deployment_v1");
193
194/// The one key of [`DEPLOYMENT_TABLE`].
195const DEPLOYMENT_KEY: &str = "payment_pool";
196
197/// Encode a [`LaneKey`] into its `[u8; 72]` table key: `pool_id ‖ signer ‖
198/// provider`.
199fn lane_key_bytes(key: &LaneKey) -> [u8; LANE_KEY_LEN] {
200    let mut out = [0u8; LANE_KEY_LEN];
201    out[..32].copy_from_slice(key.pool_id.as_slice());
202    out[32..52].copy_from_slice(key.signer.as_slice());
203    out[52..].copy_from_slice(key.provider.as_slice());
204    out
205}
206
207/// Decode a `[u8; 72]` table key back into its [`LaneKey`] parts
208/// `(pool_id, signer, provider)`. Fixed-width slices, so the array indexing is
209/// total.
210fn lane_key_parts(bytes: &[u8; LANE_KEY_LEN]) -> (B256, Address, Address) {
211    let mut pool = [0u8; 32];
212    pool.copy_from_slice(&bytes[..32]);
213    let mut signer = [0u8; 20];
214    signer.copy_from_slice(&bytes[32..52]);
215    let mut provider = [0u8; 20];
216    provider.copy_from_slice(&bytes[52..]);
217    (
218        B256::from(pool),
219        Address::from(signer),
220        Address::from(provider),
221    )
222}
223
224/// On-disk record — the single, complete lane-state format. The numeric
225/// balance fields use fixed-size big-endian byte arrays instead of
226/// variable-length integers so the encoded value width is stable across
227/// postcard versions and identical to the on-chain representation, making
228/// manual inspection straightforward.
229///
230/// The lane's identity (`pool_id`, `signer`, `provider`) is NOT stored in the
231/// value — it is the table key ([`lane_key_bytes`]) — so a value carries only
232/// the mutable watermark plus the capability's `cap`/`expiry` and the owner's
233/// signature over that capability (`owner_sig`).
234///
235/// `schema_version` is a forward tripwire only (see [`SUPPORTED_SCHEMA_VERSION`]):
236/// `decode_record` uses [`postcard::take_from_bytes`], which tolerates trailing
237/// bytes, so a record a newer binary wrote with an appended field still decodes
238/// its known prefix here rather than erroring on the extra bytes.
239///
240/// `signature` and `owner_sig` are each a length-prefixed `Vec<u8>` on disk; the
241/// in-memory [`LaneState`] carries the stronger `Option<[u8; 65]>`, and the
242/// narrowing (empty → `None`, 65 → `Some`, anything else → corrupt) lives in
243/// [`StoredLaneState::into_state`]. `owner_sig` is the owner's EIP-712 signature
244/// over the lane's `Capability` — the `ownerSig` the redeemer submits as a
245/// `PaymentPool.redeemMany` `CapabilityReg`. It is a field of the lane record,
246/// not a side table, so it is committed in the same fsynced transaction as the
247/// voucher frontier it backs and can never be durable-out-of-step with it.
248///
249/// `registered_until` is a required field after `expiry`: the observed on-chain
250/// capability expiry for this lane's signer. Adding it is a breaking on-disk
251/// change — a record written before it fails to decode here (the trailing `u64`
252/// is absent, so `take_from_bytes` hits `DeserializeUnexpectedEnd`), it is not
253/// silently defaulted. That break is deliberate and unversioned: deCDN is
254/// pre-launch with no deployed store to stay compatible with, so
255/// [`SUPPORTED_SCHEMA_VERSION`] does not bump.
256#[derive(Debug, Serialize, Deserialize)]
257struct StoredLaneState {
258    schema_version: u32,
259    last_amount: [u8; 32],
260    last_bytes_delivered: [u8; 32],
261    signature: Vec<u8>,
262    cap: [u8; 32],
263    expiry: u64,
264    registered_until: u64,
265    /// The lane's live hash-chain epoch, flattened (ADR 003 §Off-chain voucher
266    /// state persistence). `tip` is the preimage **bytes** at `verified_index`,
267    /// not just the depth: only the payer can produce a value at a given depth,
268    /// so a node that kept the index alone would hold an unprovable claim after
269    /// a restart — in exactly the abandonment case the chain exists to cover.
270    ///
271    /// Chain state is **frontier**, so the #1672 durability split covers it
272    /// unchanged: losing it on a crash forfeits at most the chunks metered
273    /// since the lane's last signature, which is the node's own un-signed tail
274    /// and the safe direction to lose.
275    chain_root: [u8; 32],
276    chunk_price: [u8; 32],
277    verified_index: u8,
278    tip: [u8; 32],
279    /// The owner's EIP-712 signature over the lane's `Capability` (`r‖s‖v`),
280    /// length-prefixed on disk and narrowed to the in-memory `Option<[u8; 65]>`
281    /// (empty → `None`, 65 → `Some`, anything else → corrupt) in
282    /// [`StoredLaneState::into_state`]. The seller redeemer submits it as a
283    /// `PaymentPool.redeemMany` `CapabilityReg` `ownerSig` to register the signer
284    /// on-chain (ADR 003 §Capability delegation). A required field like
285    /// `registered_until`: adding it is a breaking on-disk change — a record
286    /// written before it fails to decode (the trailing length-prefix is absent,
287    /// so `take_from_bytes` hits `DeserializeUnexpectedEnd`), it is not silently
288    /// defaulted. That break is deliberate and unversioned: deCDN is pre-launch
289    /// with no deployed store to stay compatible with.
290    owner_sig: Vec<u8>,
291    /// The lane's paid cumulative — the on-chain `newPaidCumulative` of the most
292    /// recent `PoolRedeemed` this node observed for it — as a fixed-width
293    /// big-endian `[u8; 32]`, like `last_amount`/`cap`. The seller redeemer
294    /// subtracts it from the lane's owed value to get what it still has to cash;
295    /// persisting it here (in the same fsynced row as the frontier) is what stops
296    /// a restarted node from forgetting redemptions behind its log-poller cursor
297    /// and re-submitting already-redeemed lanes for silent on-chain no-ops
298    /// (#2052). A required field like `registered_until`/`owner_sig`: adding it is
299    /// a breaking on-disk change (a record written before it fails to decode), not
300    /// silently defaulted — deliberate and unversioned, as deCDN is pre-launch.
301    paid_cumulative: [u8; 32],
302}
303
304impl From<&LaneState> for StoredLaneState {
305    fn from(state: &LaneState) -> Self {
306        Self {
307            schema_version: SUPPORTED_SCHEMA_VERSION,
308            last_amount: state.last_amount().to_be_bytes(),
309            last_bytes_delivered: state.last_bytes_delivered().to_be_bytes(),
310            signature: state.last_signature().map_or_else(Vec::new, |s| s.to_vec()),
311            cap: state.cap.to_be_bytes(),
312            expiry: state.expiry,
313            registered_until: state.registered_until,
314            owner_sig: state.owner_sig.map_or_else(Vec::new, |s| s.to_vec()),
315            paid_cumulative: state.paid_cumulative.to_be_bytes(),
316            chain_root: state.chain().chain_root.into(),
317            chunk_price: state.chain().chunk_price.to_be_bytes(),
318            verified_index: state.chain().verified_index,
319            tip: state.chain().tip.into(),
320        }
321    }
322}
323
324/// Narrow an on-disk length-prefixed signature to the in-memory
325/// `Option<[u8; 65]>`: empty → `None`, exactly 65 bytes → `Some`, any other
326/// length → [`StoreError::Corrupt`] (a malformed record we must not silently
327/// submit to the on-chain `PaymentPool.redeemMany`).
328fn decode_signature(
329    bytes: &[u8],
330    pool_id: B256,
331    what: &str,
332) -> Result<Option<[u8; 65]>, StoreError> {
333    if bytes.is_empty() {
334        return Ok(None);
335    }
336    let len = bytes.len();
337    Ok(Some(<[u8; 65]>::try_from(bytes).map_err(|_| {
338        StoreError::Corrupt {
339            pool_id: Some(pool_id),
340            detail: format!("stored {what} signature is {len} bytes, expected 0 or 65"),
341        }
342    })?))
343}
344
345impl StoredLaneState {
346    /// Reconstruct the in-memory [`LaneState`] via its hydration constructor
347    /// (the trusted cross-crate writer, #527/#751), given the lane identity
348    /// recovered from the table key. The on-disk `signature` is a
349    /// length-prefixed `Vec<u8>`; it is narrowed to the in-memory
350    /// `Option<[u8; 65]>` here: empty → `None`, exactly 65 bytes → `Some`, any
351    /// other length → [`StoreError::Corrupt`] (a malformed record we must not
352    /// silently submit to the on-chain `PaymentPool.redeem`).
353    fn into_state(
354        self,
355        pool_id: B256,
356        signer: Address,
357        provider: Address,
358    ) -> Result<LaneState, StoreError> {
359        if self.schema_version > SUPPORTED_SCHEMA_VERSION {
360            return Err(StoreError::UnsupportedSchema {
361                found: self.schema_version,
362                supported: SUPPORTED_SCHEMA_VERSION,
363            });
364        }
365        let last_signature = decode_signature(&self.signature, pool_id, "voucher")?;
366        let owner_sig = decode_signature(&self.owner_sig, pool_id, "owner capability")?;
367        let mut state = LaneState::hydrate(
368            pool_id,
369            signer,
370            provider,
371            U256::from_be_bytes(self.cap),
372            self.expiry,
373            U256::from_be_bytes(self.last_amount),
374            U256::from_be_bytes(self.last_bytes_delivered),
375            last_signature,
376            LaneChain {
377                chain_root: B256::from(self.chain_root),
378                chunk_price: U256::from_be_bytes(self.chunk_price),
379                verified_index: self.verified_index,
380                tip: B256::from(self.tip),
381            },
382        );
383        state.registered_until = self.registered_until;
384        state.owner_sig = owner_sig;
385        state.paid_cumulative = U256::from_be_bytes(self.paid_cumulative);
386        Ok(state)
387    }
388}
389
390/// One lane's slot in the in-memory working set.
391///
392/// `Live` holds the authoritative frontier. `Tombstoned` marks a `forget`-ten
393/// lane whose on-disk row is not yet deleted — it stays in the map (rather than
394/// being removed) so `flush` learns to delete the row, and so a `record` that
395/// resurrects the lane in the same window is decided under the entry's shard
396/// lock rather than racing a separate tombstone set. Reads treat `Tombstoned`
397/// as absent.
398///
399/// The `Live` variant carries a full [`LaneState`] inline — no `Box`. That is
400/// the point: a stored lane clones out with a memcpy on every `record`/`get`,
401/// the hot serve path, and boxing would trade that for a heap indirection on
402/// exactly the frequent variant to shrink the rare `Tombstoned` one. Tombstones
403/// are transient (the next flush reaps them), so the size skew never
404/// accumulates.
405#[derive(Debug, Clone)]
406#[allow(clippy::large_enum_variant)]
407enum LaneSlot {
408    Live(LaneState),
409    Tombstoned,
410}
411
412/// `redb`-backed persistent implementation of [`PoolStateStore`].
413///
414/// Construct via [`PersistentPoolStateStore::open`]. One `redb::Database` per
415/// write family is owned for the lifetime of this value; drop closes every
416/// handle. The lane table is buffered in memory: `record`/`forget` mutate the
417/// working set only, and a caller must call [`PoolStateStore::flush`] to commit
418/// it to disk. The store is thread-safe — each redb file serialises its own
419/// writes via single-writer transactions, families commit on independent writer
420/// slots, and reads are MVCC.
421///
422/// The working set is a [`DashMap`] rather than one mutex-guarded map, so a
423/// `record` on the paid-delivery path locks only its own lane's shard — no lane
424/// serialises on another, and settlement's `load_all`/`get` do not contend
425/// with `record` (issue #1792 item 1). Each entry's shard lock is the per-lane
426/// critical section that keeps a `record`/`forget` race decidable.
427#[derive(Debug)]
428pub struct PersistentPoolStateStore {
429    /// Seller lane frontier (`lanes.redb`). The owner-signed capability material
430    /// each lane's redeemer needs rides the lane record itself (`owner_sig`), so
431    /// the periodic flush commits it in the same transaction as the frontier.
432    lanes_db: Database,
433    /// Seller + buyer pending-settle sets (`settle.redb`).
434    settle_db: Database,
435    /// Settlement-watcher scan checkpoints (`checkpoint.redb`).
436    checkpoint_db: Database,
437    /// Buyer pool state + owner index (`buyer.redb`).
438    buyer_db: Database,
439    /// Path of the lane store file (`lanes.redb`); returned by [`Self::path`].
440    path: PathBuf,
441    /// The per-lane working set, hydrated from disk at `open()`.
442    lanes: DashMap<LaneKey, LaneSlot>,
443    /// Lock-free work-list of lanes changed since the last flush.
444    /// `record`/`forget`/`set_registered_until` push their key; `flush` drains
445    /// it and re-reads each slot from `lanes` (the source of truth), so a
446    /// duplicate or a since-superseded key is harmless. Draining the queue —
447    /// rather than clearing a shared dirty set in place — is what bounds the
448    /// durable watermark's lag to one flush interval across a concurrent
449    /// `record` (ADR 003 §Off-chain voucher state persistence): a `record` that
450    /// lands after a key is drained pushes it afresh and is captured next flush.
451    dirty: SegQueue<LaneKey>,
452}
453
454impl PersistentPoolStateStore {
455    /// Open (or create) the lane-state store under `data_dir`.
456    ///
457    /// `data_dir` is validated through
458    /// [`decdn_common::identity::ensure_data_dir`] before any redb operation,
459    /// which enforces `0o700` on the directory and rejects insecure modes.
460    /// The store file is then chmod'd to `0o600` after creation as
461    /// defense-in-depth.
462    ///
463    /// # Errors
464    ///
465    /// - [`StoreError::Backend`] when the `data_dir` security check fails.
466    /// - [`StoreError::Backend`] when `redb::Database::create` refuses the file.
467    /// - [`StoreError::Corrupt`] when the file exists but is zero-length —
468    ///   `redb` would otherwise treat that as "create a fresh database" and
469    ///   silently reopen the issue #527 replay window.
470    /// - [`StoreError::PermissionTighten`] when the post-create chmod fails.
471    /// - [`StoreError::Io`] for any other filesystem error while stat-ing.
472    ///
473    /// When this returns `Err`, the caller MUST abort node bring-up —
474    /// starting with a clean store silently forfeits the issue #527 guarantee.
475    ///
476    /// `deployment` is the `PaymentPool` the node is configured against. The
477    /// store binds to it before it hydrates any lane: a store stamped with
478    /// another deployment drops its lane rows, pending settles and watcher
479    /// checkpoints, and a store with no stamp takes this one and keeps its rows.
480    pub fn open(data_dir: &Path, deployment: Deployment) -> Result<Self, StoreError> {
481        Self::open_with(data_dir, deployment, Self::tighten_permissions)
482    }
483
484    /// Testable form of [`Self::open`] that takes an injectable chmod
485    /// function. Non-test callers use [`Self::open`]; tests inject a closure
486    /// that simulates chmod failure to exercise the cleanup-branch asymmetry
487    /// (the security-critical fix for #527 follow-up review).
488    ///
489    /// `pub(crate)` deliberately — exposing this beyond the crate would let an
490    /// external caller pass a no-op `chmod_fn` and silently weaken the
491    /// file-mode hardening.
492    pub(crate) fn open_with<F>(
493        data_dir: &Path,
494        deployment: Deployment,
495        chmod_fn: F,
496    ) -> Result<Self, StoreError>
497    where
498        F: Fn(&Path) -> Result<(), StoreError>,
499    {
500        identity::ensure_data_dir(data_dir).map_err(|err| {
501            StoreError::Backend(format!(
502                "data_dir {} failed security check: {err:#}",
503                data_dir.display()
504            ))
505        })?;
506
507        // One hardened redb file per write family. `lanes.redb` opens first so a
508        // corrupt or empty lane store — the file that guards the issue #527
509        // voucher-replay window — is the one that aborts bring-up, and every
510        // integration test that fault-injects that window names this file.
511        let path = data_dir.join(LANES_DB_FILE);
512        let lanes_db = Self::open_hardened_db(&path, &chmod_fn)?;
513        let settle_db = Self::open_hardened_db(&data_dir.join(SETTLE_DB_FILE), &chmod_fn)?;
514        let checkpoint_db = Self::open_hardened_db(&data_dir.join(CHECKPOINT_DB_FILE), &chmod_fn)?;
515        let buyer_db = Self::open_hardened_db(&data_dir.join(BUYER_DB_FILE), &chmod_fn)?;
516
517        Self::bind_deployment(&lanes_db, &settle_db, &checkpoint_db, deployment)?;
518        let lanes = Self::hydrate_lanes(&lanes_db)?;
519        Ok(Self {
520            lanes_db,
521            settle_db,
522            checkpoint_db,
523            buyer_db,
524            path,
525            lanes,
526            dirty: SegQueue::new(),
527        })
528    }
529
530    /// Open (or create) one hardened redb file at `path`, applying the empty-file
531    /// guard, TOCTOU-safe create, and `0o600` tightening every lane-store family
532    /// file gets. Each family lives in its own file so its commits take an
533    /// independent `redb` writer slot.
534    ///
535    /// Rejects a zero-length file: `redb::Database::create` treats both "file
536    /// does not exist" and "file exists but is empty" as "create a fresh
537    /// database" — so a `truncate -s 0` (or a filesystem rollback that nukes
538    /// content but preserves the inode) would start with an empty store. For
539    /// `lanes.redb` that silently reopens the issue #527 voucher-replay window;
540    /// for the other families it silently re-grants budget the lost rows bounded
541    /// (a dropped pending-settle entry forgets an in-flight redemption).
542    ///
543    /// On a chmod failure the cleanup depends on whether the file pre-existed
544    /// this call: a freshly-created file is removed so the next start sees a
545    /// clean state; a pre-existing file (real payment state on disk) is
546    /// preserved, because a transient chmod failure on a read-only mount or NFS
547    /// must not delete live state. The TOCTOU window between the stat and
548    /// `Database::create` is closed via `OpenOptions::create_new`.
549    fn open_hardened_db<F>(path: &Path, chmod_fn: &F) -> Result<Database, StoreError>
550    where
551        F: Fn(&Path) -> Result<(), StoreError>,
552    {
553        let file_existed_before_open = match std::fs::metadata(path) {
554            Ok(meta) if meta.len() == 0 => {
555                return Err(StoreError::Corrupt {
556                    pool_id: None,
557                    detail: format!(
558                        "redb store file at {} is empty (length 0). \
559                         This is either a manual truncation or a filesystem rollback, \
560                         either of which silently discards durable payment state \
561                         (for lanes.redb this re-opens the issue #527 voucher-replay window). \
562                         Restore from backup, or delete the file deliberately to start fresh \
563                         (forfeiting prior history).",
564                        path.display()
565                    ),
566                });
567            }
568            Ok(_) => true,
569            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
570                match std::fs::OpenOptions::new()
571                    .write(true)
572                    .create_new(true)
573                    .open(path)
574                {
575                    Ok(file) => {
576                        // Drop the handle immediately; redb opens its own.
577                        drop(file);
578                        false
579                    }
580                    Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => true,
581                    Err(err) => return Err(StoreError::Io(err)),
582                }
583            }
584            Err(err) => return Err(StoreError::Io(err)),
585        };
586
587        let db = Database::create(path).map_err(|err| {
588            StoreError::Backend(format!(
589                "failed to open redb store file at {}: {err}. \
590                 Removing the file forfeits its durability guard \
591                 — restore from backup or investigate the corruption.",
592                path.display()
593            ))
594        })?;
595
596        if let Err(chmod_err) = chmod_fn(path) {
597            // `drop(db)` is load-bearing on Windows: NTFS holds a mandatory
598            // exclusive lock on the file handle, so `remove_file` below would
599            // error with sharing-violation if the handle outlives.
600            drop(db);
601            Self::handle_chmod_failure(path, &chmod_err, file_existed_before_open);
602            return Err(chmod_err);
603        }
604
605        Ok(db)
606    }
607
608    /// Bind the store to `deployment` before any row is read.
609    ///
610    /// - The stamp names `deployment`: nothing changes. The check runs in a READ
611    ///   transaction, so a boot against the same deployment takes no write and
612    ///   the #527 file-stability guarantee holds.
613    /// - No stamp: the first open against this store claims it for
614    ///   `deployment`. Existing rows stay — they are presumed written by the
615    ///   deployment the node was already configured against, before the stamp
616    ///   existed. The presumption fails for a node that was repointed at a
617    ///   redeploy BEFORE the stamp existed: it claims the colliding rows, and
618    ///   the operator clears them by hand (see the CHANGELOG entry).
619    /// - The stamp names another deployment: every row it wrote is dropped —
620    ///   the lane table, both pending-settle sets, and the watcher checkpoints —
621    ///   and the stamp is rewritten. Those rows name pool ids that repeat on the
622    ///   new deployment. Kept, a returning buyer's new pool would resolve to the
623    ///   old lane: its frontier rejects the buyer's first voucher, and the
624    ///   redeemer submits the old deployment's signatures to the new contract.
625    ///   The dropped lanes' unredeemed vouchers are forfeit.
626    ///
627    /// The settle and checkpoint files clear first. The lane table clears in the
628    /// same `lanes.redb` transaction that writes the new stamp, so a crash at any
629    /// point leaves either the old stamp (the next boot repeats the clear) or the
630    /// new stamp with every lane row gone. That guarantee assumes the next boot
631    /// keeps the NEW config: a crash after the settle/checkpoint clears followed
632    /// by a config revert to the OLD deployment matches the surviving stamp and
633    /// keeps the lanes, but that deployment's pending settles and checkpoints are
634    /// already gone (safe today — a lost checkpoint only widens a rescan — but a
635    /// future pending-settle consumer inherits this one-directional invariant).
636    ///
637    /// # Errors
638    /// [`StoreError::Corrupt`] for a malformed stamp; [`StoreError::Backend`]
639    /// when a transaction fails.
640    fn bind_deployment(
641        lanes_db: &Database,
642        settle_db: &Database,
643        checkpoint_db: &Database,
644        deployment: Deployment,
645    ) -> Result<(), StoreError> {
646        let read_txn = lanes_db
647            .begin_read()
648            .map_err(|err| StoreError::Backend(format!("begin_read (deployment): {err}")))?;
649        let stamp = match read_txn.open_table(DEPLOYMENT_TABLE) {
650            Ok(table) => table
651                .get(DEPLOYMENT_KEY)
652                .map_err(|err| StoreError::Backend(format!("get (deployment): {err}")))?
653                .map(|guard| Deployment::from_bytes(guard.value()))
654                .transpose()?,
655            Err(redb::TableError::TableDoesNotExist(_)) => None,
656            Err(err) => {
657                return Err(StoreError::Backend(format!(
658                    "open_table (deployment): {err}"
659                )));
660            }
661        };
662        let foreign = match stamp {
663            Some(stamp) if stamp == deployment => return Ok(()),
664            None => None,
665            Some(foreign) => Some(foreign),
666        };
667        if let Some(foreign) = foreign {
668            Self::log_forfeited_lanes(&read_txn, foreign, deployment);
669        }
670        drop(read_txn);
671        if foreign.is_some() {
672            Self::delete_tables(settle_db, "settle", |txn| {
673                txn.delete_table(PENDING_SETTLE_TABLE)?;
674                txn.delete_table(BUYER_PENDING_SETTLE_TABLE)?;
675                Ok(())
676            })?;
677            Self::delete_tables(checkpoint_db, "checkpoint", |txn| {
678                txn.delete_table(WATCHER_CHECKPOINT_TABLE)?;
679                Ok(())
680            })?;
681        }
682
683        let mut txn = lanes_db
684            .begin_write()
685            .map_err(|err| StoreError::Backend(format!("begin_write (deployment): {err}")))?;
686        txn.set_durability(Durability::Immediate)
687            .map_err(|err| StoreError::Backend(format!("set_durability (deployment): {err}")))?;
688        if foreign.is_some() {
689            txn.delete_table(LANE_TABLE)
690                .map_err(|err| StoreError::Backend(format!("delete_table (lanes): {err}")))?;
691        }
692        {
693            let mut table = txn
694                .open_table(DEPLOYMENT_TABLE)
695                .map_err(|err| StoreError::Backend(format!("open_table (deployment): {err}")))?;
696            table
697                .insert(DEPLOYMENT_KEY, deployment.to_bytes().as_slice())
698                .map_err(|err| StoreError::Backend(format!("insert (deployment): {err}")))?;
699        }
700        txn.commit()
701            .map_err(|err| StoreError::Backend(format!("commit (deployment): {err}")))
702    }
703
704    /// Walk the lane table once before the deployment rebind drops it: each
705    /// per-lane WARN is the LAST RECORD of that lane's unredeemed claim on the
706    /// old deployment (mirroring the buyer-side `drop_foreign_row` convention
707    /// of naming the recovery target), and the walk sums the forfeited value
708    /// for the summary WARN. The forfeited value is the lane's full claim —
709    /// [`LaneState::owed`], the signed cumulative plus the chain-verified
710    /// frontier — minus its paid watermark, the same figure the redeemer
711    /// collects; counting the signed amount alone would understate a lane by
712    /// up to one whole chain.
713    ///
714    /// This walk is observational: the rows are about to be deleted, so
715    /// nothing it hits may abort THIS deployment's bring-up. A row that fails
716    /// to decode is WARN-logged by its key parts, counted in `dropped_lanes`
717    /// AND in the summary's `undecodable_lanes` (its value cannot enter the
718    /// forfeited sum, so that field marks the sum as a lower bound), and a
719    /// table or iterator error degrades to one WARN with the audit marked
720    /// unknown — the `delete_table` that follows is the arbiter of real
721    /// corruption. An absent lane table logs a zero-lane summary.
722    fn log_forfeited_lanes(
723        read_txn: &redb::ReadTransaction,
724        foreign: Deployment,
725        configured: Deployment,
726    ) {
727        let mut dropped_lanes: u64 = 0;
728        let mut undecodable_lanes: u64 = 0;
729        let mut forfeited = U256::ZERO;
730        let mut audit_err: Option<String> = None;
731        match read_txn.open_table(LANE_TABLE) {
732            Ok(table) => match table.iter() {
733                Ok(iter) => {
734                    for entry in iter {
735                        let (key_guard, value_guard) = match entry {
736                            Ok(pair) => pair,
737                            Err(err) => {
738                                audit_err = Some(format!("lanes iter entry: {err}"));
739                                break;
740                            }
741                        };
742                        let key_bytes: [u8; LANE_KEY_LEN] = *key_guard.value();
743                        dropped_lanes = dropped_lanes.saturating_add(1);
744                        match Self::log_one_forfeited_lane(&key_bytes, value_guard.value(), foreign)
745                        {
746                            Some(unredeemed) => {
747                                forfeited = forfeited.saturating_add(unredeemed);
748                            }
749                            None => {
750                                undecodable_lanes = undecodable_lanes.saturating_add(1);
751                            }
752                        }
753                    }
754                }
755                Err(err) => audit_err = Some(format!("lanes iter: {err}")),
756            },
757            Err(redb::TableError::TableDoesNotExist(_)) => {}
758            Err(err) => audit_err = Some(format!("open_table (lanes): {err}")),
759        }
760        if let Some(error) = audit_err {
761            tracing::warn!(
762                error,
763                "could not enumerate the seller lanes for the forfeit audit; proceeding \
764                 with the deployment rebind's drop, forfeited value unknown"
765            );
766        }
767        tracing::warn!(
768            foreign_chain_id = foreign.chain_id,
769            foreign_payment_pool = %foreign.payment_pool,
770            configured_chain_id = configured.chain_id,
771            configured_payment_pool = %configured.payment_pool,
772            dropped_lanes,
773            undecodable_lanes,
774            forfeited_micro_usdc = %forfeited,
775            "dropping seller lane state, pending settles and watcher checkpoints written \
776             against another PaymentPool deployment; pool ids repeat across deployments, \
777             and the dropped lanes' unredeemed vouchers are forfeit"
778        );
779    }
780
781    /// WARN one lane the rebind is about to drop — the last record of its
782    /// claim — and return its forfeited value, or `None` for a row that fails
783    /// to decode (WARN-logged by its key parts; its value cannot be reported).
784    fn log_one_forfeited_lane(
785        key_bytes: &[u8; LANE_KEY_LEN],
786        value_bytes: &[u8],
787        foreign: Deployment,
788    ) -> Option<U256> {
789        match decode_record(key_bytes, value_bytes) {
790            Ok(state) => {
791                let unredeemed = forfeited_value(&state);
792                tracing::warn!(
793                    pool_id = %state.pool_id,
794                    signer = %state.signer,
795                    provider = %state.provider,
796                    owed = %state.owed(),
797                    last_amount = %state.last_amount(),
798                    paid_cumulative = %state.paid_cumulative,
799                    unredeemed_micro_usdc = %unredeemed,
800                    foreign_chain_id = foreign.chain_id,
801                    foreign_payment_pool = %foreign.payment_pool,
802                    "dropping this seller lane with the deployment rebind; \
803                     this line is the last record of its unredeemed claim \
804                     on the old deployment"
805                );
806                Some(unredeemed)
807            }
808            Err(err) => {
809                let (pool_id, signer, provider) = lane_key_parts(key_bytes);
810                tracing::warn!(
811                    %pool_id,
812                    %signer,
813                    %provider,
814                    error = %err,
815                    foreign_chain_id = foreign.chain_id,
816                    foreign_payment_pool = %foreign.payment_pool,
817                    "dropping an undecodable seller lane with the \
818                     deployment rebind; its value cannot be reported"
819                );
820                None
821            }
822        }
823    }
824
825    /// Delete tables from `db` in one fsynced write transaction. `what` names
826    /// the file in error messages.
827    fn delete_tables(
828        db: &Database,
829        what: &str,
830        delete: impl FnOnce(&redb::WriteTransaction) -> Result<(), redb::TableError>,
831    ) -> Result<(), StoreError> {
832        let mut txn = db
833            .begin_write()
834            .map_err(|err| StoreError::Backend(format!("begin_write ({what} clear): {err}")))?;
835        txn.set_durability(Durability::Immediate)
836            .map_err(|err| StoreError::Backend(format!("set_durability ({what} clear): {err}")))?;
837        delete(&txn)
838            .map_err(|err| StoreError::Backend(format!("delete_table ({what} clear): {err}")))?;
839        txn.commit()
840            .map_err(|err| StoreError::Backend(format!("commit ({what} clear): {err}")))
841    }
842
843    /// Read the whole lane table into the in-memory working set at open. A
844    /// corrupt or forward-schema record aborts startup (running past it would
845    /// reopen the issue #527 voucher-replay window). Every hydrated lane enters
846    /// as [`LaneSlot::Live`].
847    fn hydrate_lanes(db: &Database) -> Result<DashMap<LaneKey, LaneSlot>, StoreError> {
848        let read_txn = db
849            .begin_read()
850            .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
851        let table = match read_txn.open_table(LANE_TABLE) {
852            Ok(t) => t,
853            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(DashMap::new()),
854            Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
855        };
856        let out = DashMap::new();
857        let iter = table
858            .iter()
859            .map_err(|err| StoreError::Backend(format!("table iter: {err}")))?;
860        for entry in iter {
861            let (key_guard, value_guard) =
862                entry.map_err(|err| StoreError::Backend(format!("iter entry: {err}")))?;
863            let key_bytes: [u8; LANE_KEY_LEN] = *key_guard.value();
864            let state = decode_record(&key_bytes, value_guard.value())?;
865            out.insert(state.key(), LaneSlot::Live(state));
866        }
867        Ok(out)
868    }
869
870    /// Operator-facing logging for the chmod-failure cleanup branch. Extracted
871    /// so the security policy ("preserve pre-existing, remove fresh") lives in
872    /// one place; both branches emit `tracing::error!` because both refuse to
873    /// bring the node up and the operator needs an error-level signal.
874    fn handle_chmod_failure(path: &Path, chmod_err: &StoreError, file_existed_before_open: bool) {
875        let observed_mode = Self::observed_mode_string(path);
876        if file_existed_before_open {
877            tracing::error!(
878                path = %path.display(),
879                observed_mode = %observed_mode,
880                event = "lane_store_chmod_fail_preserve",
881                %chmod_err,
882                "chmod failed on pre-existing lane state store; refusing to delete (would reopen issue #527 replay window). \
883                 Investigate the permission error and chmod the file to 0o600 manually before retry.",
884            );
885            return;
886        }
887
888        // Fresh file path: we definitively created this file ourselves via
889        // `OpenOptions::create_new` upstream, so removing it cannot destroy
890        // anyone else's voucher state.
891        if let Err(remove_err) = std::fs::remove_file(path) {
892            tracing::error!(
893                %remove_err,
894                path = %path.display(),
895                observed_mode = %observed_mode,
896                event = "lane_store_chmod_fail_remove_failed",
897                %chmod_err,
898                "chmod failed on freshly-created lane state store, and removal also failed; \
899                 file persists with current mode. chmod 0o600 manually before next start.",
900            );
901        }
902    }
903
904    /// Best-effort stringified file mode for operator log lines. Returns
905    /// `"unknown"` on stat failure or non-Unix targets — the field is purely
906    /// informational, so a missing value is preferable to a panic.
907    fn observed_mode_string(path: &Path) -> String {
908        #[cfg(unix)]
909        {
910            use std::os::unix::fs::PermissionsExt;
911            match std::fs::metadata(path) {
912                Ok(meta) => format!("{:o}", meta.permissions().mode() & 0o777),
913                Err(_) => "unknown".into(),
914            }
915        }
916        #[cfg(not(unix))]
917        {
918            let _ = path;
919            "n/a (non-unix)".into()
920        }
921    }
922
923    /// Tighten the on-disk file mode to `0o600` after open. **Idempotent**: if
924    /// the file is already at the target mode, the syscall is skipped so a
925    /// read-only mount (EROFS) where the mode is correct from a prior boot does
926    /// not brick subsequent startups.
927    ///
928    /// No-op on non-Unix targets (Windows ACLs are controlled at directory
929    /// level, per the comment on [`DB_FILE_MODE`]).
930    #[cfg(unix)]
931    fn tighten_permissions(path: &Path) -> Result<(), StoreError> {
932        use std::os::unix::fs::PermissionsExt;
933        let meta = std::fs::metadata(path).map_err(|source| StoreError::PermissionTighten {
934            path: path.to_path_buf(),
935            source,
936        })?;
937        if meta.permissions().mode() & 0o777 == DB_FILE_MODE {
938            return Ok(());
939        }
940        let perms = std::fs::Permissions::from_mode(DB_FILE_MODE);
941        std::fs::set_permissions(path, perms).map_err(|source| StoreError::PermissionTighten {
942            path: path.to_path_buf(),
943            source,
944        })?;
945        Ok(())
946    }
947
948    #[cfg(not(unix))]
949    fn tighten_permissions(_path: &Path) -> Result<(), StoreError> {
950        tracing::debug!(
951            "lane state store: file-mode tightening skipped on non-unix; relying on data_dir ACL",
952        );
953        Ok(())
954    }
955
956    /// Filesystem path of the underlying database file. Useful for log messages
957    /// and operator runbooks.
958    #[must_use]
959    pub fn path(&self) -> &Path {
960        &self.path
961    }
962}
963
964/// The value the deployment rebind forfeits for one dropped lane: the lane's
965/// full claim — [`LaneState::owed`], the signed cumulative plus the
966/// chain-verified frontier — minus its on-chain paid watermark, saturating.
967/// This is the figure the redeemer collects; counting `last_amount` alone
968/// would understate a lane by up to one whole chain, and a chain-extended
969/// redemption can leave `paid_cumulative` above `last_amount`.
970fn forfeited_value(state: &LaneState) -> U256 {
971    state.owed().saturating_sub(state.paid_cumulative)
972}
973
974/// Decode one stored record (table value) into a [`LaneState`], given its table
975/// key (the lane encoding). Shared by `load_all` and `get`.
976///
977/// `take_from_bytes` instead of `from_bytes` so a newer writer's additive
978/// fields (trailing bytes to this reader) decode cleanly — see
979/// [`StoredLaneState`]'s doc and the version tripwire in `into_state`.
980fn decode_record(
981    key_bytes: &[u8; LANE_KEY_LEN],
982    value_bytes: &[u8],
983) -> Result<LaneState, StoreError> {
984    let (pool_id, signer, provider) = lane_key_parts(key_bytes);
985    let (stored, remainder): (StoredLaneState, &[u8]) = postcard::take_from_bytes(value_bytes)
986        .map_err(|err| StoreError::Corrupt {
987            pool_id: Some(pool_id),
988            detail: format!("postcard decode failed: {err}"),
989        })?;
990    // Trailing-byte tolerance is bounded: a malicious writer could pad megabytes
991    // onto every record and silently inflate every read. Log (don't fail) when
992    // the trailer beyond the known fields exceeds a small sanity ceiling.
993    if remainder.len() > SANE_TRAILER_MAX_BYTES {
994        tracing::warn!(
995            %pool_id,
996            remainder = remainder.len(),
997            limit = SANE_TRAILER_MAX_BYTES,
998            event = "lane_store_excess_trailer",
999            "lane state record has unusually large trailing bytes; possible malicious padding or large-additive-field schema skew",
1000        );
1001    }
1002    stored.into_state(pool_id, signer, provider)
1003}
1004
1005impl PoolStateStore for PersistentPoolStateStore {
1006    /// Every persisted lane, from the in-memory working set hydrated at `open()`.
1007    /// Tombstoned slots (a `forget`-ten lane not yet flushed) read as absent.
1008    fn load_all(&self) -> Result<Vec<LaneState>, StoreError> {
1009        Ok(self
1010            .lanes
1011            .iter()
1012            .filter_map(|entry| match entry.value() {
1013                LaneSlot::Live(state) => Some(state.clone()),
1014                LaneSlot::Tombstoned => None,
1015            })
1016            .collect())
1017    }
1018
1019    fn get(&self, key: LaneKey) -> Result<Option<LaneState>, StoreError> {
1020        Ok(self.lanes.get(&key).and_then(|entry| match entry.value() {
1021            LaneSlot::Live(state) => Some(state.clone()),
1022            LaneSlot::Tombstoned => None,
1023        }))
1024    }
1025
1026    /// Advance the in-memory lane state and mark it dirty. Durability is the
1027    /// background flush's job (ADR 003 §Off-chain voucher state persistence).
1028    /// Locks only this lane's [`DashMap`] shard; concurrent `record`s on other
1029    /// lanes proceed in parallel.
1030    fn record(&self, state: &LaneState) -> Result<(), StoreError> {
1031        let key = state.key();
1032        let mut next = state.clone();
1033        match self.lanes.entry(key) {
1034            Entry::Occupied(mut occ) => {
1035                if let LaneSlot::Live(existing) = occ.get() {
1036                    next.registered_until = next.registered_until.max(existing.registered_until);
1037                    // Never let a record that omits the owner-signed material drop
1038                    // a grant the lane already holds: like `registered_until`,
1039                    // `owner_sig` is trusted-writer state set once and kept, and
1040                    // losing it would strand the redeemer for a lane whose frontier
1041                    // still advances (#1906).
1042                    if next.owner_sig.is_none() {
1043                        next.owner_sig = existing.owner_sig;
1044                    }
1045                    // Same rule for the paid watermark: the voucher-record path
1046                    // carries it from the live in-memory lane, which never learns
1047                    // the redeemed cumulative (that rides the `PoolRedeemed`
1048                    // watcher via `set_paid_cumulative`). Keep the persisted value
1049                    // so a frontier advance cannot regress it to zero (#2052).
1050                    next.paid_cumulative = next.paid_cumulative.max(existing.paid_cumulative);
1051                }
1052                occ.insert(LaneSlot::Live(next));
1053            }
1054            Entry::Vacant(vac) => {
1055                vac.insert(LaneSlot::Live(next));
1056            }
1057        }
1058        self.dirty.push(key);
1059        Ok(())
1060    }
1061
1062    /// Raise this lane's observed on-chain registration expiry, monotonically —
1063    /// touches ONLY `registered_until`, never the replay-critical `last_*`
1064    /// tuple, so it cannot race a concurrent voucher `record` into a lost
1065    /// update. A no-op for a lane with no live record. Buffered like `record`;
1066    /// durability is the next `flush`'s job.
1067    fn set_registered_until(&self, key: LaneKey, registered_until: u64) -> Result<(), StoreError> {
1068        let Some(mut slot) = self.lanes.get_mut(&key) else {
1069            return Ok(());
1070        };
1071        let LaneSlot::Live(state) = slot.value_mut() else {
1072            return Ok(());
1073        };
1074        if registered_until > state.registered_until {
1075            state.registered_until = registered_until;
1076            self.dirty.push(key);
1077        }
1078        Ok(())
1079    }
1080
1081    /// Raise this lane's paid cumulative, monotonically — touches ONLY
1082    /// `paid_cumulative`, never the replay-critical `last_*` tuple, so a
1083    /// `PoolRedeemed` write cannot race a concurrent voucher `record` into a lost
1084    /// update. A no-op for a lane with no live record (a redemption observed after
1085    /// the lane was forgotten has nothing to advance). Buffered like `record`;
1086    /// durability is the next `flush`'s job (#2052).
1087    fn set_paid_cumulative(&self, key: LaneKey, paid_cumulative: U256) -> Result<(), StoreError> {
1088        let Some(mut slot) = self.lanes.get_mut(&key) else {
1089            return Ok(());
1090        };
1091        let LaneSlot::Live(state) = slot.value_mut() else {
1092            return Ok(());
1093        };
1094        if paid_cumulative > state.paid_cumulative {
1095            state.paid_cumulative = paid_cumulative;
1096            self.dirty.push(key);
1097        }
1098        Ok(())
1099    }
1100
1101    /// Tombstone the lane so reads treat it as absent and the next flush deletes
1102    /// its on-disk row. Idempotent. The slot stays in the map (as a tombstone)
1103    /// so a concurrent `record` resurrecting the lane is decided under the same
1104    /// shard lock rather than racing a separate set. The lane's owner-signed
1105    /// capability material (`owner_sig`) is part of the record, so it goes with
1106    /// the lane in the same delete.
1107    fn forget(&self, key: LaneKey) -> Result<(), StoreError> {
1108        self.lanes.insert(key, LaneSlot::Tombstoned);
1109        self.dirty.push(key);
1110        Ok(())
1111    }
1112
1113    /// Write every dirty lane and apply every tombstone in ONE fsynced redb
1114    /// transaction. Each lane record carries its own owner-signed capability
1115    /// material, so nothing is written separately. Idempotent — a no-op when
1116    /// clean.
1117    ///
1118    /// Double-buffered: the dirty work-list is drained and each lane's slot is
1119    /// cloned out (a cheap memcpy — [`LaneState`] holds no heap fields), then
1120    /// encoded, all off the fsync's critical path. The ~1–10 ms fsynced commit
1121    /// holds no lane shard lock, so a concurrent `record` (a single-shard map
1122    /// insert) never waits on disk I/O.
1123    ///
1124    /// A `record` that lands after its key is drained pushes the key afresh and
1125    /// is captured by the next flush; the store only needs a monotonic floor,
1126    /// and the frontier is documented "safe to lose" on a crash (ADR 003
1127    /// §Off-chain voucher state persistence), so a slightly older value now and a
1128    /// newer one one cadence later is correct.
1129    fn flush(&self) -> Result<(), StoreError> {
1130        // Drain the work-list, deduplicating: a lane touched N times since the
1131        // last flush sits in the queue N times, but we re-read its slot once.
1132        let mut drained: HashSet<LaneKey> = HashSet::new();
1133        while let Some(key) = self.dirty.pop() {
1134            drained.insert(key);
1135        }
1136        if drained.is_empty() {
1137            return Ok(());
1138        }
1139
1140        let mut writes: Vec<(LaneKey, Vec<u8>)> = Vec::new();
1141        let mut tombstones: Vec<LaneKey> = Vec::new();
1142        for key in &drained {
1143            // Clone the slot out under the shard lock, then release it before
1144            // encoding so a concurrent `record` on this lane never waits on the
1145            // postcard encode.
1146            let slot = self.lanes.get(key).map(|entry| entry.value().clone());
1147            match slot {
1148                Some(LaneSlot::Live(state)) => {
1149                    match postcard::to_allocvec(&StoredLaneState::from(&state)) {
1150                        Ok(encoded) => writes.push((*key, encoded)),
1151                        Err(err) => {
1152                            // Nothing has been committed yet, but the drain
1153                            // already emptied these keys out of the work-list.
1154                            // Re-push every drained key so the next flush retries
1155                            // them — an early return here without the re-push
1156                            // would leave the un-encoded lanes permanently
1157                            // "clean" and never reach disk.
1158                            for key in &drained {
1159                                self.dirty.push(*key);
1160                            }
1161                            return Err(StoreError::Codec(format!(
1162                                "postcard encode failed: {err}"
1163                            )));
1164                        }
1165                    }
1166                }
1167                Some(LaneSlot::Tombstoned) => tombstones.push(*key),
1168                // Drained key with no slot: a `forget` re-pushed this key after a
1169                // flush reaped its tombstone via `remove_if`, so the row is
1170                // already gone. Nothing to write or delete — skip it.
1171                None => {}
1172            }
1173        }
1174        let mut snapshot = FlushSnapshot { writes, tombstones };
1175        // Every batch reaches redb in ascending key order; see
1176        // [`FlushSnapshot::sort_by_table_key`].
1177        snapshot.sort_by_table_key();
1178
1179        // Fsynced commit with NO lane shard lock held.
1180        if let Err(err) = self.commit_snapshot(&snapshot) {
1181            // The commit failed after the work-list was drained. Re-push the
1182            // snapshotted keys so the next flush retries them (the background
1183            // flusher logs "retrying next tick"). A concurrent `record`/`forget`
1184            // that superseded a key in the meantime keeps its newer slot; the
1185            // re-pushed key just re-reads whatever `lanes` now holds.
1186            for (key, _) in &snapshot.writes {
1187                self.dirty.push(*key);
1188            }
1189            for key in &snapshot.tombstones {
1190                self.dirty.push(*key);
1191            }
1192            tracing::warn!(
1193                dirty_lanes = snapshot.writes.len() + snapshot.tombstones.len(),
1194                error = %err,
1195                "lane store commit failed; every drained key is re-queued for the next flush"
1196            );
1197            return Err(err);
1198        }
1199
1200        // Commit succeeded: reap tombstoned slots whose row is now gone. Guard
1201        // with `remove_if` so a `record` that resurrected the lane to `Live`
1202        // after the snapshot keeps its slot (and its freshly-pushed dirty mark).
1203        for key in &snapshot.tombstones {
1204            self.lanes
1205                .remove_if(key, |_, slot| matches!(slot, LaneSlot::Tombstoned));
1206        }
1207        Ok(())
1208    }
1209}
1210
1211/// Encoded dirty writes and tombstone keys resolved from the drained work-list,
1212/// so [`PersistentPoolStateStore::flush`] can run its fsynced commit with no
1213/// shard lock held. Each lane record already carries its own owner-signed
1214/// capability material, so there is no separate capability batch.
1215struct FlushSnapshot {
1216    writes: Vec<(LaneKey, Vec<u8>)>,
1217    tombstones: Vec<LaneKey>,
1218}
1219
1220impl FlushSnapshot {
1221    /// Order both batches by their [`lane_key_bytes`] table key, which is redb's
1222    /// own key order — redb compares a `&[u8; N]` key lexicographically over the
1223    /// encoding. Ascending keys hit redb's append fast path, which fills a leaf
1224    /// page before opening the next instead of leaving each page part-full at a
1225    /// random split point. Measured over 512 lanes at this key and value size,
1226    /// the table occupies about 30% fewer leaf pages (47 against 65-68).
1227    ///
1228    /// The gain lands on keys appended past the end of the table — new lanes, and
1229    /// the cold-load case — because an overwrite of a hydrated lane replaces in
1230    /// place whatever order it arrives in. Steady-state flushes are dominated by
1231    /// overwrites, so treat this as a bound on how far the table sprawls over its
1232    /// lifetime rather than a saving on every flush.
1233    ///
1234    /// Sorting the encoded key rather than an `Ord` on [`LaneKey`] keeps the
1235    /// sort key and the redb key the same value, so a change to the key layout
1236    /// cannot make the two disagree. `writes` and `tombstones` are disjoint —
1237    /// each drained key resolves to exactly one of a live slot or a tombstone —
1238    /// so the insert run and the remove run never meet on one key, and sorting
1239    /// both leaves the whole flush deterministic even though the work-list drains
1240    /// through a `HashSet` whose iteration order carries no meaning.
1241    fn sort_by_table_key(&mut self) {
1242        self.writes
1243            .sort_by_cached_key(|(lane, _)| lane_key_bytes(lane));
1244        self.tombstones.sort_by_cached_key(lane_key_bytes);
1245    }
1246}
1247
1248impl PersistentPoolStateStore {
1249    /// Apply a [`FlushSnapshot`] in ONE fsynced redb transaction. Holds no
1250    /// shard lock — every value was already encoded during the snapshot. All
1251    /// batches arrive in ascending table-key order, so the insert loops append
1252    /// rightward through the B-trees.
1253    fn commit_snapshot(&self, snapshot: &FlushSnapshot) -> Result<(), StoreError> {
1254        let mut write_txn = self
1255            .lanes_db
1256            .begin_write()
1257            .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1258        write_txn
1259            .set_durability(Durability::Immediate)
1260            .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1261        {
1262            let mut table = write_txn
1263                .open_table(LANE_TABLE)
1264                .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1265            for (key, encoded) in &snapshot.writes {
1266                let key_bytes = lane_key_bytes(key);
1267                table
1268                    .insert(&key_bytes, encoded.as_slice())
1269                    .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1270            }
1271            for key in &snapshot.tombstones {
1272                let key_bytes = lane_key_bytes(key);
1273                table
1274                    .remove(&key_bytes)
1275                    .map_err(|err| StoreError::Backend(format!("remove: {err}")))?;
1276            }
1277        }
1278        write_txn
1279            .commit()
1280            .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1281        Ok(())
1282    }
1283}
1284
1285// ---------------------------------------------------------------------------
1286// Buyer-side store (#744)
1287//
1288// The record codec and every table operation live in
1289// `decdn_incentive::buyer_pool_table` — the same code the client's
1290// `RedbBuyerPoolStore` runs (#1246). This file keeps only the wiring: which
1291// file the table lives in (`buyer.redb`, its own per-family file, so a buyer
1292// commit never waits on the seller lane, pending-settle or watcher-checkpoint
1293// writer slot) and which `TableDefinition` names it.
1294// ---------------------------------------------------------------------------
1295
1296/// Test/e2e-only seam on the concrete store. Kept here rather than on the
1297/// handle because the buyer reconciliation + mixed-reclaim e2e (#763) holds a
1298/// `PersistentPoolStateStore`.
1299#[cfg(any(test, feature = "anvil-e2e"))]
1300impl PersistentPoolStateStore {
1301    /// Test/e2e-only: write raw value bytes under a buyer pool key, bypassing
1302    /// the postcard encoder, to simulate a row left undecodable by a binary
1303    /// downgrade.
1304    ///
1305    /// # Errors
1306    ///
1307    /// [`StoreError::Backend`] if the write or durable commit fails.
1308    pub fn insert_raw_buyer_record(&self, pool_id: PoolId, bytes: &[u8]) -> Result<(), StoreError> {
1309        BuyerPoolTable::new(&self.buyer_db).insert_raw(pool_id, bytes)
1310    }
1311}
1312
1313/// [`BuyerPoolStore`] adapter over the shared [`PersistentPoolStateStore`].
1314///
1315/// Holds an `Arc` to the same store the seller path uses. The seller's
1316/// `lane_state_v1` table and the buyer pool table sit in separate redb files
1317/// (`lanes.redb` and `buyer.redb`) behind that one handle, so neither family's
1318/// commit waits on the other's writer slot. Hand this to the buyer service as
1319/// `Arc<dyn BuyerPoolStore>`.
1320#[derive(Debug, Clone)]
1321pub struct BuyerPoolStoreHandle {
1322    inner: std::sync::Arc<PersistentPoolStateStore>,
1323}
1324
1325impl BuyerPoolStoreHandle {
1326    /// Wrap a shared persistent store as a buyer-pool store.
1327    #[must_use]
1328    pub const fn new(inner: std::sync::Arc<PersistentPoolStateStore>) -> Self {
1329        Self { inner }
1330    }
1331
1332    /// View this store's `buyer.redb` as the buyer pool table.
1333    ///
1334    /// Not `const` — it derefs the `Arc`, which const fns cannot do.
1335    fn table(&self) -> BuyerPoolTable<'_> {
1336        BuyerPoolTable::new(&self.inner.buyer_db)
1337    }
1338}
1339
1340/// Every method delegates to [`decdn_incentive::buyer_pool_table`]; this newtype
1341/// contributes the `buyer.redb` wiring, not the logic.
1342impl BuyerPoolStore for BuyerPoolStoreHandle {
1343    fn load_all(&self) -> Result<BuyerLoad, StoreError> {
1344        self.table().load_all()
1345    }
1346
1347    fn record(&self, state: &BuyerPoolState) -> Result<(), StoreError> {
1348        self.table().record(state)
1349    }
1350
1351    fn forget(&self, owner: Address) -> Result<(), StoreError> {
1352        self.table().forget(owner)
1353    }
1354
1355    fn forget_if_pool(&self, owner: Address, pool_id: PoolId) -> Result<bool, StoreError> {
1356        self.table().forget_if_pool(owner, pool_id)
1357    }
1358
1359    fn get_by_pool_id(&self, pool_id: PoolId) -> Result<Option<BuyerPoolState>, StoreError> {
1360        self.table().get_by_pool_id(pool_id)
1361    }
1362
1363    fn get_by_owner(&self, owner: Address) -> Result<Option<BuyerPoolState>, StoreError> {
1364        self.table().get_by_owner(owner)
1365    }
1366
1367    fn advance_progress(
1368        &self,
1369        owner: Address,
1370        pool_id: PoolId,
1371        lane: LaneKey,
1372        bytes: U256,
1373        amount: U256,
1374    ) -> Result<AdvanceOutcome, StoreError> {
1375        self.table()
1376            .advance_progress(owner, pool_id, lane, bytes, amount)
1377    }
1378
1379    fn rebase_progress(
1380        &self,
1381        owner: Address,
1382        pool_id: PoolId,
1383        lane: LaneKey,
1384        anchor: BuyerLaneProgress,
1385        totals: BuyerLaneProgress,
1386    ) -> Result<AdvanceOutcome, StoreError> {
1387        self.table()
1388            .rebase_progress(owner, pool_id, lane, anchor, totals)
1389    }
1390
1391    fn seed_progress(
1392        &self,
1393        owner: Address,
1394        pool_id: PoolId,
1395        lane: LaneKey,
1396        bytes: U256,
1397        amount: U256,
1398    ) -> Result<AdvanceOutcome, StoreError> {
1399        self.table()
1400            .seed_progress(owner, pool_id, lane, bytes, amount)
1401    }
1402
1403    fn add_deposit(
1404        &self,
1405        owner: Address,
1406        pool_id: PoolId,
1407        additional: U256,
1408    ) -> Result<DepositOutcome, StoreError> {
1409        self.table().add_deposit(owner, pool_id, additional)
1410    }
1411}
1412
1413impl PersistentPoolStateStore {
1414    /// Insert/overwrite a pending-settle entry in `table` (fsync-on-commit). The
1415    /// `PENDING_SETTLE_TABLE` and `BUYER_PENDING_SETTLE_TABLE` share this body so
1416    /// the seller and buyer pending sets stay byte-for-byte consistent.
1417    fn pending_record_in(
1418        &self,
1419        table_def: TableDefinition<'_, &[u8; 32], u64>,
1420        entry: &PendingSettle,
1421    ) -> Result<(), StoreError> {
1422        let key: [u8; 32] = entry.pool_id.into();
1423        let mut write_txn = self
1424            .settle_db
1425            .begin_write()
1426            .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1427        // Force fsync-on-commit, same durability discipline as the other tables: the
1428        // pool is already closing on-chain by the time an entry is written here,
1429        // so a post-close crash that lost it would strand the settlement
1430        // obligation — the very gap this fsync guards.
1431        write_txn
1432            .set_durability(Durability::Immediate)
1433            .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1434        {
1435            let mut table = write_txn
1436                .open_table(table_def)
1437                .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1438            table
1439                .insert(&key, entry.settle_after)
1440                .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1441        }
1442        write_txn
1443            .commit()
1444            .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1445        Ok(())
1446    }
1447
1448    /// Load every pending-settle entry from `table` (an absent table is the
1449    /// empty set, not an error — first-boot tolerance).
1450    fn pending_load_from(
1451        &self,
1452        table_def: TableDefinition<'_, &[u8; 32], u64>,
1453    ) -> Result<Vec<PendingSettle>, StoreError> {
1454        let read_txn = self
1455            .settle_db
1456            .begin_read()
1457            .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1458        let table = match read_txn.open_table(table_def) {
1459            Ok(t) => t,
1460            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
1461            Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1462        };
1463        let mut out = Vec::new();
1464        let iter = table
1465            .iter()
1466            .map_err(|err| StoreError::Backend(format!("table iter: {err}")))?;
1467        for entry in iter {
1468            let (key_guard, value_guard) =
1469                entry.map_err(|err| StoreError::Backend(format!("iter entry: {err}")))?;
1470            out.push(PendingSettle {
1471                pool_id: B256::from(*key_guard.value()),
1472                settle_after: value_guard.value(),
1473            });
1474        }
1475        Ok(out)
1476    }
1477
1478    /// Remove a pending-settle entry from `table` (a no-op against a
1479    /// never-written table, which must not be created as a side effect).
1480    fn pending_forget_in(
1481        &self,
1482        table_def: TableDefinition<'_, &[u8; 32], u64>,
1483        pool_id: B256,
1484    ) -> Result<(), StoreError> {
1485        let key: [u8; 32] = pool_id.into();
1486        {
1487            let read_txn = self
1488                .settle_db
1489                .begin_read()
1490                .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1491            match read_txn.open_table(table_def) {
1492                Ok(_) => {}
1493                Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
1494                Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1495            }
1496        }
1497
1498        let mut write_txn = self
1499            .settle_db
1500            .begin_write()
1501            .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1502        write_txn
1503            .set_durability(Durability::Immediate)
1504            .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1505        {
1506            let mut table = write_txn
1507                .open_table(table_def)
1508                .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1509            table
1510                .remove(&key)
1511                .map_err(|err| StoreError::Backend(format!("remove: {err}")))?;
1512        }
1513        write_txn
1514            .commit()
1515            .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1516        Ok(())
1517    }
1518}
1519
1520impl PendingSettleStore for PersistentPoolStateStore {
1521    fn record_pending(&self, entry: &PendingSettle) -> Result<(), StoreError> {
1522        self.pending_record_in(PENDING_SETTLE_TABLE, entry)
1523    }
1524
1525    fn load_pending(&self) -> Result<Vec<PendingSettle>, StoreError> {
1526        self.pending_load_from(PENDING_SETTLE_TABLE)
1527    }
1528
1529    fn forget_pending(&self, pool_id: B256) -> Result<(), StoreError> {
1530        self.pending_forget_in(PENDING_SETTLE_TABLE, pool_id)
1531    }
1532}
1533
1534/// [`PendingSettleStore`] over the buyer's `buyer_pending_settle_v1` table,
1535/// isolated from the seller's `pending_settle_v1` table so the two settle
1536/// sweeps never finalize each other's closes (#988). Wraps the same shared
1537/// [`PersistentPoolStateStore`] (one redb file, one handle); hand this to the
1538/// buyer service as `Arc<dyn PendingSettleStore>`.
1539#[derive(Debug, Clone)]
1540pub struct BuyerPendingSettleStoreHandle {
1541    inner: std::sync::Arc<PersistentPoolStateStore>,
1542}
1543
1544impl BuyerPendingSettleStoreHandle {
1545    /// Wrap a shared persistent store as the buyer pending-settle store.
1546    #[must_use]
1547    pub const fn new(inner: std::sync::Arc<PersistentPoolStateStore>) -> Self {
1548        Self { inner }
1549    }
1550}
1551
1552impl PendingSettleStore for BuyerPendingSettleStoreHandle {
1553    fn record_pending(&self, entry: &PendingSettle) -> Result<(), StoreError> {
1554        self.inner
1555            .pending_record_in(BUYER_PENDING_SETTLE_TABLE, entry)
1556    }
1557
1558    fn load_pending(&self) -> Result<Vec<PendingSettle>, StoreError> {
1559        self.inner.pending_load_from(BUYER_PENDING_SETTLE_TABLE)
1560    }
1561
1562    fn forget_pending(&self, pool_id: B256) -> Result<(), StoreError> {
1563        self.inner
1564            .pending_forget_in(BUYER_PENDING_SETTLE_TABLE, pool_id)
1565    }
1566}
1567
1568impl KeyedCheckpointStore for PersistentPoolStateStore {
1569    fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError> {
1570        let read_txn = self
1571            .checkpoint_db
1572            .begin_read()
1573            .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1574        // A never-written checkpoint table means "no prior scan" — first boot.
1575        let table = match read_txn.open_table(WATCHER_CHECKPOINT_TABLE) {
1576            Ok(t) => t,
1577            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
1578            Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1579        };
1580        let value = table
1581            .get(key.as_str())
1582            .map_err(|err| StoreError::Backend(format!("get: {err}")))?;
1583        Ok(value.map(|g| g.value()))
1584    }
1585
1586    fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError> {
1587        let mut write_txn = self
1588            .checkpoint_db
1589            .begin_write()
1590            .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1591        // Force fsync-on-commit, same durability discipline as the other tables.
1592        write_txn
1593            .set_durability(Durability::Immediate)
1594            .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1595        {
1596            let mut table = write_txn
1597                .open_table(WATCHER_CHECKPOINT_TABLE)
1598                .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1599            table
1600                .insert(key.as_str(), block)
1601                .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1602        }
1603        write_txn
1604            .commit()
1605            .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1606        Ok(())
1607    }
1608}
1609
1610#[cfg(test)]
1611mod tests;