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