Skip to main content

decdn_node/
receipt_log.rs

1//! Append-only download-receipt log for the node runtime (issue #248).
2//!
3//! A node enqueues one structured [`DownloadReceipt`] per served-and-paid blob
4//! at the voucher-acceptance point (see
5//! [`crate::handlers::client::ClientHandler`]); the disk write happens off the
6//! delivery path in a background writer (see the Seam note below, #803). The
7//! log gives the operator an on-disk, off-chain record of which blobs were
8//! delivered and paid for — enabling revenue audit, cross-restart double-spend
9//! triage, and dispute evidence without relying solely on on-chain state.
10//!
11//! # Seam
12//!
13//! There are two layers. [`ReceiptLog`] is the *disk* boundary — a small trait
14//! (mirroring the trait+impl pattern of
15//! [`decdn_incentive::store::PoolStateStore`] / [`crate::channel_store`])
16//! whose runtime impl is the disk-backed [`JsonlReceiptLog`], appending one JSON
17//! object per line to `<data_dir>/download_receipts.jsonl` (JSON Lines).
18//! [`ReceiptSink`] is the *hot-path* boundary: the paid-delivery path does not
19//! call [`ReceiptLog::append`] directly — it enqueues through the non-blocking
20//! [`ReceiptSink::record`], and a single background [`spawn_receipt_writer`]
21//! task owns the `JsonlReceiptLog` and performs the actual `append` off the
22//! delivery path (#803), so a slow or full disk can never back-pressure
23//! delivery. Both seams take an in-memory fake in unit/loopback tests.
24//!
25//! # Durability
26//!
27//! Each [`ReceiptLog::append`] writes one line straight to the file — one
28//! `write_all`, which lands the bytes in the OS page cache — and does **not**
29//! fsync per record. The receipt log is an *audit* artifact, not the
30//! voucher-replay guard. That guard is the lane watermark in
31//! [`crate::channel_store::PersistentPoolStateStore`], which
32//! advances **in memory** the moment a voucher or preimage is accepted, reaches
33//! disk on the runtime's periodic lane flush
34//! (`payment.voucher_commit_interval_ms`, 5 s by default), and is flushed again
35//! before every on-chain redemption.
36//!
37//! Neither side fsyncs on the delivery path, so after a crash the two tails
38//! **can** disagree, in either direction, and neither direction is guaranteed.
39//! The audit log runs ahead when the background writer has already written a
40//! line whose lane advance is still buffered — a restart then finds a receipt
41//! whose watermark the lane store no longer holds. It runs behind when the
42//! crash catches receipts still queued in [`RECEIPT_LOG_CAPACITY`] (that queue
43//! is process memory and dies with the process, and only a graceful shutdown
44//! drains it), or when a hard crash drops page-cache lines the 5 s lane flush
45//! had already fsynced past.
46//!
47//! Both directions are safe. Ahead costs revenue, not correctness: the node
48//! forfeits at most one flush interval of *frontier* (value it has not yet
49//! redeemed), which ADR 003 §Off-chain voucher state persistence accepts.
50//! Behind is a gap in the audit record and nothing more.
51//!
52//! Replay protection rests on the *redeemed* watermark instead, which the
53//! pre-redemption flush floors. That floor holds wherever the node can afford
54//! to wait for it: the periodic sweep and the redeem-hint path skip their
55//! submit when the flush fails, while the shutdown sweep redeems anyway,
56//! because forfeiting the claim across the stop is worse than a bounded
57//! re-serve risk (see [`crate::payment_settlement`]). Skipping the
58//! per-line fsync keeps the cost off the hot delivery path (ADR 003).
59//!
60//! The append is also crash-atomic at the line level only in the usual POSIX
61//! sense (a torn final line is possible after a hard crash); JSONL readers MUST
62//! tolerate a truncated trailing line.
63//!
64//! # Rotation (issue #802)
65//!
66//! Left unbounded the log grows for the entire process lifetime and across
67//! restarts (`O_APPEND`), eventually exhausting `data_dir`. [`JsonlReceiptLog`]
68//! therefore takes a [`RotationPolicy`] (`max_file_bytes`, `retained_files`):
69//! once the live file would exceed `max_file_bytes` it is rotated to a numbered
70//! backup (`download_receipts.jsonl.1`, `.2`, …, oldest = highest), a fresh
71//! live file is opened, and any backup beyond `retained_files` is deleted. This
72//! bounds `data_dir` to roughly `(retained_files + 1) * max_file_bytes`.
73//! `retained_files == 0` keeps no backups — the live file is truncated in place
74//! on rotation. Rotation is **best-effort**: a rename/open failure mid-rotation
75//! never leaves the log without a writable handle and never aborts paid
76//! delivery (the background writer treats an `append` error as non-fatal, and
77//! delivery is decoupled from it by the bounded queue, #803), it only trims or
78//! skips a backup that cycle.
79
80use std::fs::{File, OpenOptions};
81use std::io::Write;
82use std::path::{Path, PathBuf};
83use std::sync::{Arc, Mutex};
84
85use alloy::primitives::U256;
86use iroh_blobs::Hash;
87use serde::{Deserialize, Serialize};
88use tokio::sync::mpsc;
89use tokio_util::sync::CancellationToken;
90
91use crate::metrics::Metrics;
92use crate::stop_handle::StopHandle;
93
94/// File name of the receipt log within `data_dir`. Canonical name lives in
95/// `decdn_common` so the daemon writer and the `config validate` summary can't
96/// disagree on where receipts land (#964).
97const RECEIPT_LOG_FILE: &str = decdn_common::config::RECEIPT_LOG_FILE;
98
99/// On-disk file mode (`0o600` — owner-only read+write). Same defense-in-depth
100/// rationale as [`crate::channel_store`]: `data_dir` is already `0o700`, but the
101/// receipt log can carry per-client delivery metadata, so the file mode is
102/// tightened independently in case the directory ACL is widened out-of-band.
103#[cfg(unix)]
104const RECEIPT_LOG_FILE_MODE: u32 = 0o600;
105
106/// One durable record of a served-and-paid blob (issue #248).
107///
108/// The on-disk shape. The paid-delivery path captures a [`RawReceipt`] at the
109/// voucher-acceptance point and the background writer renders it into this
110/// value (`RawReceipt::render`, #1792 item 2), so every receipt still
111/// corresponds to a cumulative payment the node verified and committed — the
112/// rendering just happens off the hot path. All identifier fields are lower-hex
113/// strings (no `0x` prefix) so the log is self-describing and greppable without
114/// a schema:
115///
116/// - `hash` — BLAKE3 content hash of the delivered blob (raw 32 bytes, hex).
117/// - `client_node_id` — iroh `NodeId` (ed25519 public key) of the paying peer
118///   (raw 32 bytes, hex).
119/// - `voucher_amount` — the accepted voucher's cumulative amount (its sole
120///   ordering key; there is no nonce), as a decimal string. A decimal `uint256`
121///   (not hex) so an auditor reads the same value the on-chain `PaymentPool` and
122///   operator dashboards show; it is reconstructed from the 32-byte big-endian
123///   wire amount.
124///
125/// `size` is the byte count covered by *this* payment proof (the newly
126/// delivered, now-paid bytes), and `timestamp_secs` is the node's wall-clock
127/// Unix time (seconds) read where the receipt is rendered — the background
128/// writer stamps it as it dequeues, which lags the acceptance point only by the
129/// queue-drain latency (#1792 item 2).
130///
131/// # Invariants
132///
133/// The fields are **private** and the only public constructor is [`new`], which
134/// renders the hex and decimal-`uint256` strings from typed inputs. This makes
135/// the invariants type-owned: a receipt cannot be built field-wise, and serde's
136/// derived `Deserialize` cannot synthesize one with a malformed `hash` /
137/// `client_node_id` / `voucher_amount` from outside the crate (the fields stay
138/// private to readers). Accessor methods expose the rendered values read-only.
139///
140/// Field-wise construction from outside the crate does not compile (the fields
141/// are private), so the validating [`new`] is the only public path in:
142///
143/// ```compile_fail
144/// use decdn_node::receipt_log::DownloadReceipt;
145/// // ERROR: fields `hash`, `size`, ... are private — invariant cannot be bypassed.
146/// let _ = DownloadReceipt {
147///     hash: "not-a-hash".to_string(),
148///     size: 1,
149///     client_node_id: "nope".to_string(),
150///     voucher_amount: "0xdeadbeef".to_string(),
151///     timestamp: 0,
152/// };
153/// ```
154///
155/// [`new`]: DownloadReceipt::new
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct DownloadReceipt {
158    /// BLAKE3 content hash of the delivered blob, lower-hex (64 chars).
159    hash: String,
160    /// Bytes covered by this payment proof (the newly paid delivery).
161    size: u64,
162    /// iroh `NodeId` of the paying client, lower-hex (64 chars).
163    client_node_id: String,
164    /// Accepted voucher cumulative amount, decimal `uint256` string.
165    voucher_amount: String,
166    /// Node wall-clock Unix time (seconds), stamped by the background writer as
167    /// it renders the receipt (#1792 item 2) — the acceptance point plus the
168    /// queue-drain latency. The wire key stays `timestamp` (preserved via
169    /// `#[serde(rename)]`) so the on-disk JSONL format is byte-identical to the
170    /// original; the Rust field name carries the unit explicitly.
171    #[serde(rename = "timestamp")]
172    timestamp_secs: u64,
173}
174
175impl DownloadReceipt {
176    /// Build a receipt from the typed delivery-path values, rendering every
177    /// invariant inside the constructor:
178    ///
179    /// - `hash` is the [`iroh_blobs::Hash`] of the delivered blob — a distinct
180    ///   type from `client_node_id_bytes`, so the two 32-byte identifiers cannot
181    ///   be transposed at a call site. Rendered to lower-hex (no `0x`).
182    /// - `client_node_id_bytes` is the raw 32-byte iroh node id, lower-hex.
183    /// - `voucher_amount` is the accepted voucher's cumulative `uint256` amount;
184    ///   the decimal string is rendered here so the "decimal uint256" invariant
185    ///   is owned by the type, not the caller.
186    #[must_use]
187    pub fn new(
188        hash: &Hash,
189        size: u64,
190        client_node_id_bytes: &[u8; 32],
191        voucher_amount: U256,
192        timestamp_secs: u64,
193    ) -> Self {
194        Self {
195            hash: hex_lower(hash.as_bytes()),
196            size,
197            client_node_id: hex_lower(client_node_id_bytes),
198            voucher_amount: voucher_amount.to_string(),
199            timestamp_secs,
200        }
201    }
202
203    /// BLAKE3 content hash of the delivered blob, lower-hex (64 chars, no `0x`).
204    #[must_use]
205    pub fn hash(&self) -> &str {
206        &self.hash
207    }
208
209    /// Bytes covered by this payment proof (the newly paid delivery).
210    #[must_use]
211    pub const fn size(&self) -> u64 {
212        self.size
213    }
214
215    /// iroh `NodeId` of the paying client, lower-hex (64 chars, no `0x`).
216    #[must_use]
217    pub fn client_node_id(&self) -> &str {
218        &self.client_node_id
219    }
220
221    /// Accepted voucher cumulative amount, decimal `uint256` string.
222    #[must_use]
223    pub fn voucher_amount(&self) -> &str {
224        &self.voucher_amount
225    }
226
227    /// Node wall-clock Unix time (seconds) at voucher acceptance.
228    #[must_use]
229    pub const fn timestamp_secs(&self) -> u64 {
230        self.timestamp_secs
231    }
232}
233
234/// One receipt as the paid-delivery hot path produces it: raw typed
235/// identifiers, no rendering and no clock read.
236///
237/// This is what [`ReceiptSink::record`] takes and what rides the writer queue,
238/// so enqueuing a receipt on the delivery path is a handful of `Copy` field
239/// moves — no hex encoding, no `U256` decimal formatting, and no
240/// `SystemTime::now()` (issue #1792 item 2). The background writer
241/// ([`spawn_receipt_writer`]) turns it into the on-disk [`DownloadReceipt`] via
242/// `RawReceipt::render`, which is where the two 64-char hex renders, the
243/// decimal-`uint256` render, and the acceptance timestamp are applied — off the
244/// hot path, in the same task that already owns the disk write (#803).
245#[derive(Debug, Clone, Copy)]
246pub struct RawReceipt {
247    /// BLAKE3 content hash of the delivered blob.
248    hash: Hash,
249    /// Bytes covered by this payment proof (the newly paid delivery).
250    size: u64,
251    /// Raw 32-byte iroh `NodeId` (ed25519 public key) of the paying peer.
252    client_node_id: [u8; 32],
253    /// Accepted voucher cumulative amount (its sole ordering key; no nonce).
254    voucher_amount: U256,
255}
256
257impl RawReceipt {
258    /// Capture the typed delivery-path values with no rendering. Cheap enough to
259    /// build inline on the paid-delivery path — every field is `Copy`.
260    #[must_use]
261    pub const fn new(
262        hash: Hash,
263        size: u64,
264        client_node_id: [u8; 32],
265        voucher_amount: U256,
266    ) -> Self {
267        Self {
268            hash,
269            size,
270            client_node_id,
271            voucher_amount,
272        }
273    }
274
275    /// Render this raw receipt into its on-disk [`DownloadReceipt`] form,
276    /// stamping `timestamp_secs`. This is where the hex and decimal-`uint256`
277    /// rendering happens; callers run it off the delivery path (the background
278    /// writer, or the inline test/loopback sink), so the hot path never pays for
279    /// it. `timestamp_secs` is read where `render` is called — for the
280    /// background writer that is the moment the receipt is dequeued, which lags
281    /// acceptance only by the queue-drain latency and preserves FIFO order.
282    #[must_use]
283    fn render(self, timestamp_secs: u64) -> DownloadReceipt {
284        DownloadReceipt::new(
285            &self.hash,
286            self.size,
287            &self.client_node_id,
288            self.voucher_amount,
289            timestamp_secs,
290        )
291    }
292}
293
294/// Lower-hex encode a 32-byte array without a `0x` prefix. Delegates to
295/// `alloy::primitives::hex::encode` (the `const-hex` re-export already pulled in
296/// via the `alloy` dependency), which emits lowercase with no `0x` prefix —
297/// byte-identical to the prior hand-rolled `{:02x}` loop, and locked against
298/// drift by the `serialized_line_is_wire_stable` test.
299fn hex_lower(bytes: &[u8; 32]) -> String {
300    alloy::primitives::hex::encode(bytes)
301}
302
303/// Append-only sink for [`DownloadReceipt`]s.
304///
305/// The trait exists so the voucher-accept path can be exercised against an
306/// in-memory fake in unit tests; the runtime uses [`JsonlReceiptLog`].
307/// Implementations MUST be cheap enough to call inline on the delivery path
308/// (one buffered line write + flush — no fsync; see the module durability note).
309pub trait ReceiptLog: Send + Sync {
310    /// Durably-enough append one receipt (write + flush, no fsync).
311    ///
312    /// # Errors
313    ///
314    /// Returns the underlying [`std::io::Error`] if serialization or the write
315    /// fails. The caller treats a receipt-log failure as non-fatal (the payment
316    /// already advanced the lane watermark) and logs it.
317    fn append(&self, receipt: &DownloadReceipt) -> std::io::Result<()>;
318}
319
320/// Size-based rotation policy for [`JsonlReceiptLog`] (issue #802).
321///
322/// `max_file_bytes` is the size at which the live file is rotated;
323/// `retained_files` is how many numbered backups (`.1`..=`.N`) to keep (`0`
324/// truncates the live file in place instead of keeping backups). A
325/// `max_file_bytes` of `0` disables rotation entirely (the live file grows
326/// unbounded) — a degenerate value the engine accepts but production never
327/// produces.
328///
329/// The engine deliberately accepts any values (the rotation guards in
330/// [`JsonlReceiptLog::append`] cope with a sub-line cap, and tests use tiny
331/// caps to exercise rotation deterministically). Production policies are built
332/// *only* from a [`decdn_common::config::ResolvedReceipts`] via the [`From`]
333/// impl below, and that config type is range-validated by
334/// `decdn_common::config::resolve_config` — so every live policy is within the
335/// operator-facing safe bounds (`[MIN_RECEIPT_MAX_FILE_BYTES,
336/// MAX_RECEIPT_MAX_FILE_BYTES]`, `retained_files <= MAX_RECEIPT_RETAINED_FILES`).
337#[derive(Debug, Clone, Copy)]
338pub struct RotationPolicy {
339    max_file_bytes: u64,
340    retained_files: u32,
341}
342
343impl RotationPolicy {
344    /// Build a policy from raw, *unvalidated* limits. Test-only: production
345    /// constructs policies from validated config through the [`From`] impl, so
346    /// this wider entry point (which accepts out-of-range and degenerate caps)
347    /// is not exposed outside `#[cfg(test)]`.
348    #[cfg(test)]
349    #[must_use]
350    pub(crate) const fn new(max_file_bytes: u64, retained_files: u32) -> Self {
351        Self {
352            max_file_bytes,
353            retained_files,
354        }
355    }
356}
357
358impl From<&decdn_common::config::ResolvedReceipts> for RotationPolicy {
359    /// The sole production constructor. `ResolvedReceipts` is range-validated by
360    /// the config resolver, so the policy is always within the operator-facing
361    /// safe bounds — the validation guarantee carries across the type boundary
362    /// instead of being re-opened by a raw constructor.
363    fn from(r: &decdn_common::config::ResolvedReceipts) -> Self {
364        Self {
365            max_file_bytes: r.max_file_bytes,
366            retained_files: r.retained_files,
367        }
368    }
369}
370
371/// Mutex-guarded writer state: the live append handle plus the in-memory byte
372/// count used to decide rotation without an extra `stat` per append.
373#[derive(Debug)]
374struct LogState {
375    file: File,
376    written: u64,
377}
378
379/// JSON-Lines, append-only [`ReceiptLog`] backed by a single rotating file
380/// under `data_dir`.
381///
382/// One [`File`] handle is held open for the process lifetime behind a [`Mutex`]
383/// so concurrent delivery streams cannot interleave partial lines. The handle
384/// is opened in append mode (`O_APPEND`), so even across the (already
385/// serialized) writes each `write_all` lands at the current end of file.
386/// Rotation (issue #802) swaps that handle under the same lock; see the
387/// module-level rotation note.
388#[derive(Debug)]
389pub struct JsonlReceiptLog {
390    state: Mutex<LogState>,
391    path: PathBuf,
392    policy: RotationPolicy,
393}
394
395impl JsonlReceiptLog {
396    /// Open (or create) the receipt log under `data_dir` with rotation `policy`.
397    ///
398    /// The file is opened in append mode and, on Unix, created with mode
399    /// `0o600`; an existing file's mode is tightened to `0o600` as
400    /// defense-in-depth (idempotent — skipped when already correct, so a
401    /// read-only mount with the right mode does not brick startup). The
402    /// in-memory rotation counter is seeded from the existing file length so a
403    /// log that is already near the cap rotates on the next append rather than
404    /// only after a full cap's worth of fresh writes.
405    ///
406    /// Unlike [`crate::channel_store::PersistentPoolStateStore::open`], a
407    /// failure here is **not** required to abort node bring-up: the receipt log
408    /// is an audit artifact, not the #527 replay guard. The runtime decides the
409    /// fatality (it currently logs and continues without the audit log).
410    ///
411    /// # Errors
412    ///
413    /// Returns a [`std::io::Error`] if the file cannot be opened/created, its
414    /// mode cannot be tightened, or its current length cannot be read.
415    pub fn open(data_dir: &Path, policy: RotationPolicy) -> std::io::Result<Self> {
416        let path = data_dir.join(RECEIPT_LOG_FILE);
417        let file = open_append(&path)?;
418        let written = file.metadata()?.len();
419        Ok(Self {
420            state: Mutex::new(LogState { file, written }),
421            path,
422            policy,
423        })
424    }
425
426    /// Filesystem path of the underlying (live) log file. Useful for operator
427    /// log lines and runbooks.
428    #[must_use]
429    pub fn path(&self) -> &Path {
430        &self.path
431    }
432
433    /// Path of the `n`-th rotated backup (`<live>.n`).
434    fn backup_path(&self, n: u32) -> PathBuf {
435        let mut raw = self.path.clone().into_os_string();
436        raw.push(format!(".{n}"));
437        PathBuf::from(raw)
438    }
439
440    /// Rotate the live file, preserving a usable append handle in `state`.
441    ///
442    /// Best-effort: every step that can fail leaves `state.file` pointing at a
443    /// writable canonical-path handle so the caller's next `append` still
444    /// succeeds. Any backup numbered above `retained_files` is pruned first (so
445    /// lowering the retention count reclaims stale backups). With
446    /// `retained_files == 0` the live file is then truncated in place;
447    /// otherwise the oldest retained backup is dropped, the chain is shifted up,
448    /// the live file becomes `.1`, and a fresh live file is opened. If opening
449    /// the fresh file fails, `.1` is renamed back so the original handle keeps
450    /// writing to the canonical path.
451    fn rotate(&self, state: &mut LogState) -> std::io::Result<()> {
452        let retained = self.policy.retained_files;
453        // Prune any backups numbered above the retention count first. This
454        // bounds disk even after an operator *lowers* `retained_files` (or sets
455        // it to 0) between runs, when stale higher-numbered backups (e.g. `.10`)
456        // from a prior, larger setting would otherwise linger forever.
457        self.prune_backups_above(retained);
458
459        if retained == 0 {
460            state.file.set_len(0)?;
461            state.written = 0;
462            return Ok(());
463        }
464
465        // A missing live path means an earlier rotation moved it to `.1` and
466        // could not move it back: `state.file` already writes into `.1`. The
467        // shift below would then push that inode up the chain on every retry
468        // until it fell off the end, so re-create the live file instead — that
469        // completes the earlier rotation.
470        if !self.path.exists() {
471            state.file = open_append(&self.path)?;
472            state.written = 0;
473            return Ok(());
474        }
475
476        // Drop the oldest retained backup (NotFound is fine before the chain
477        // fills) to make room for the shift below.
478        match std::fs::remove_file(self.backup_path(retained)) {
479            Ok(()) => {}
480            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
481            Err(e) => return Err(e),
482        }
483        // Shift `.i -> .(i+1)` from the top down so no rename clobbers a file
484        // that still needs moving. A missing intermediate backup is a benign gap.
485        for i in (1..retained).rev() {
486            match std::fs::rename(self.backup_path(i), self.backup_path(i + 1)) {
487                Ok(()) => {}
488                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
489                Err(e) => return Err(e),
490            }
491        }
492        // Move the live file to `.1`. The open handle in `state.file` follows the
493        // inode, so writes through it would now land in `.1` until we swap it.
494        std::fs::rename(&self.path, self.backup_path(1))?;
495        match open_append(&self.path) {
496            Ok(fresh) => {
497                state.file = fresh;
498                state.written = 0;
499                Ok(())
500            }
501            Err(open_err) => {
502                // Restore the canonical path so the still-open `state.file`
503                // handle keeps appending to a correctly-named live log.
504                if let Err(error) = std::fs::rename(self.backup_path(1), &self.path) {
505                    tracing::warn!(
506                        path = %self.path.display(),
507                        %error,
508                        "download receipt log: restoring the live path after a failed rotation \
509                         failed; receipts append to the `.1` backup until the next rotation \
510                         re-creates the live file"
511                    );
512                }
513                Err(open_err)
514            }
515        }
516    }
517
518    /// Best-effort removal of every backup numbered strictly above `retained`.
519    /// Backups are written densely (`.1`..`.k`), so we delete upward from
520    /// `retained + 1` and stop at the first gap. This is disk-hygiene, not a
521    /// correctness gate, and must never abort an append — so it never returns an
522    /// error. It does, however, distinguish the two stop conditions: a missing
523    /// index ends the dense run silently, but a *real* error (permissions, busy,
524    /// I/O) is not the end of the run — higher-numbered backups may still exist
525    /// and would leak past the `(retained_files + 1) * max_file_bytes` disk
526    /// bound. Scanning past a gap could run unbounded, so we still stop, but a
527    /// real error is logged so the leak is observable rather than silent.
528    fn prune_backups_above(&self, retained: u32) {
529        let mut n = retained.saturating_add(1);
530        loop {
531            match std::fs::remove_file(self.backup_path(n)) {
532                Ok(()) => {}
533                // The dense `.1`..`.k` run ends at the first missing index.
534                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
535                Err(e) => {
536                    tracing::warn!(
537                        error = %e,
538                        event = "download_receipt_prune_failed",
539                        path = %self.backup_path(n).display(),
540                        "could not prune stale receipt-log backup; backups above \
541                         it may exceed receipts.retained_files until removed manually",
542                    );
543                    return;
544                }
545            }
546            match n.checked_add(1) {
547                Some(next) => n = next,
548                None => return,
549            }
550        }
551    }
552}
553
554/// Open (or create) an append-mode handle at `path`, applying the `0o600` mode
555/// on Unix and tightening an existing file's mode. Shared by `open` and the
556/// post-rotation reopen so both honour the same permissions invariant.
557fn open_append(path: &Path) -> std::io::Result<File> {
558    let mut opts = OpenOptions::new();
559    opts.create(true).append(true);
560    #[cfg(unix)]
561    {
562        use std::os::unix::fs::OpenOptionsExt as _;
563        opts.mode(RECEIPT_LOG_FILE_MODE);
564    }
565    let file = opts.open(path)?;
566    #[cfg(unix)]
567    tighten_permissions(path)?;
568    Ok(file)
569}
570
571/// Tighten the on-disk file mode to `0o600`. Idempotent: skips the syscall when
572/// the mode already matches so a read-only mount with the correct mode (e.g.
573/// from a prior boot) does not fail startup. Mirrors
574/// [`crate::channel_store::PersistentPoolStateStore`]'s tightening.
575#[cfg(unix)]
576fn tighten_permissions(path: &Path) -> std::io::Result<()> {
577    use std::os::unix::fs::PermissionsExt as _;
578    let meta = std::fs::metadata(path)?;
579    if meta.permissions().mode() & 0o777 == RECEIPT_LOG_FILE_MODE {
580        return Ok(());
581    }
582    std::fs::set_permissions(path, std::fs::Permissions::from_mode(RECEIPT_LOG_FILE_MODE))
583}
584
585impl ReceiptLog for JsonlReceiptLog {
586    fn append(&self, receipt: &DownloadReceipt) -> std::io::Result<()> {
587        // Serialize to a single line first, so a serialization failure never
588        // writes a partial record. `serde_json::to_string` cannot embed a
589        // newline in these scalar fields, keeping one-receipt-per-line.
590        let mut line = serde_json::to_string(receipt).map_err(std::io::Error::other)?;
591        line.push('\n');
592        let line_len = u64::try_from(line.len()).unwrap_or(u64::MAX);
593        let mut guard = self
594            .state
595            .lock()
596            .map_err(|_| std::io::Error::other("receipt log mutex poisoned"))?;
597        // Rotate before writing when this line would push the live file past the
598        // cap, but only once the file already holds a record — so a single line
599        // larger than the cap still writes (after one rotation) instead of
600        // looping. Rotation is best-effort: if it fails, `state.file` is still a
601        // writable handle, so we log and fall through to append the receipt to
602        // the current file (temporarily exceeding the cap) rather than dropping
603        // an audit record. The next append retries the rotation.
604        if self.policy.max_file_bytes > 0
605            && guard.written > 0
606            && guard.written.saturating_add(line_len) > self.policy.max_file_bytes
607            && let Err(e) = self.rotate(&mut guard)
608        {
609            tracing::warn!(
610                error = %e,
611                event = "download_receipt_rotation_failed",
612                path = %self.path.display(),
613                "receipt log rotation failed; appending to the current file \
614                 (it may exceed receipts.max_file_bytes until the next rotation)",
615            );
616        }
617        match guard
618            .file
619            .write_all(line.as_bytes())
620            // `File::flush` is a no-op: `write_all` above is what hands the
621            // bytes to the OS. Kept so the call reads as a complete write
622            // sequence if the sink ever grows a buffered writer. No fsync — see
623            // the module-level durability note.
624            .and_then(|()| guard.file.flush())
625        {
626            Ok(()) => {
627                guard.written = guard.written.saturating_add(line_len);
628                Ok(())
629            }
630            Err(e) => {
631                // A partial write can land bytes before erroring, so the
632                // in-memory counter may now disagree with the file. Resync from
633                // metadata (best-effort) so a later rotation isn't decided on a
634                // stale count.
635                if let Ok(meta) = guard.file.metadata() {
636                    guard.written = meta.len();
637                }
638                Err(e)
639            }
640        }
641    }
642}
643
644/// A [`ReceiptLog`] that drops every receipt. Used as the runtime fallback when
645/// [`JsonlReceiptLog::open`] fails at bring-up: the audit log is best-effort, so
646/// the node serves paid delivery without it rather than refusing to start (the
647/// replay guard lives in the separate lane store, which the periodic flush
648/// mirrors to disk). The operator gets one `error!` at startup naming the open
649/// failure.
650#[derive(Debug, Default)]
651pub struct NoopReceiptLog;
652
653impl ReceiptLog for NoopReceiptLog {
654    fn append(&self, _receipt: &DownloadReceipt) -> std::io::Result<()> {
655        Ok(())
656    }
657}
658
659/// Non-blocking enqueue boundary for [`RawReceipt`]s on the paid-delivery
660/// hot path (#803).
661///
662/// The voucher-accept path records a receipt through this seam as each voucher
663/// is accepted (acceptance is implicit — delivery simply continues), so the
664/// implementation MUST NOT block on disk I/O: a backed-up sink drops the
665/// receipt (best-effort, audit-only) rather than stall the payment, which
666/// already advanced the lane watermark. The runtime uses [`ChannelReceiptSink`]
667/// (hands off to the background [`spawn_receipt_writer`] task); tests use a
668/// synchronous fake behind the `test-support` `DirectReceiptSink`.
669///
670/// The seam carries a [`RawReceipt`], not the rendered [`DownloadReceipt`]: the
671/// hex/decimal rendering and the timestamp are applied downstream, off the hot
672/// path (#1792 item 2).
673pub trait ReceiptSink: Send + Sync {
674    /// Best-effort, non-blocking record of one receipt. Never blocks the caller
675    /// on disk and never fails delivery — a sink that cannot accept the receipt
676    /// drops it.
677    fn record(&self, receipt: RawReceipt);
678}
679
680/// Capacity of the receipt-writer queue. Receipts are roughly one per voucher
681/// interval (~1 MiB served), so this absorbs a large burst of voucher
682/// acceptances while a disk stall (a slow or full `data_dir`, the realistic
683/// end-state of #802) is worked off, without ever back-pressuring paid
684/// delivery. On overflow the *audit* receipt is dropped (counted via
685/// [`Metrics::receipt_write_dropped`]) rather than the *payment* stalling — the
686/// payment already advanced the lane watermark. Rotation (#802) bounds the log
687/// on disk; this bounds it in memory.
688pub const RECEIPT_LOG_CAPACITY: usize = 1024;
689
690/// Production [`ReceiptSink`]: enqueues to the background writer over a bounded
691/// channel. Mirrors the redeem-hint sink (#751): a `Full` channel drops the
692/// receipt and counts it (`decdn_receipt_writes_dropped_total`); a `Closed`
693/// channel — the writer stopped during shutdown — is silently ignored (the tail
694/// drain has already run, or is bounded by the shutdown deadline).
695#[derive(Clone)]
696pub struct ChannelReceiptSink {
697    tx: mpsc::Sender<RawReceipt>,
698    metrics: Arc<Metrics>,
699}
700
701impl std::fmt::Debug for ChannelReceiptSink {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("ChannelReceiptSink")
704            .field("capacity", &self.tx.max_capacity())
705            .finish_non_exhaustive()
706    }
707}
708
709impl ReceiptSink for ChannelReceiptSink {
710    fn record(&self, receipt: RawReceipt) {
711        match self.tx.try_send(receipt) {
712            // Enqueued — the background writer will append it.
713            Ok(()) => {}
714            // Queue saturated: drop the audit receipt and count it.
715            Err(mpsc::error::TrySendError::Full(_)) => self.metrics.receipt_write_dropped(),
716            // Writer gone. Expected only during shutdown (after the router drains
717            // and the writer's token is cancelled), so it is left uncounted to
718            // avoid false alerting — mirroring the redeem-hint `Closed` rationale
719            // (#751). A `debug!` still leaves a trace, so a writer that died
720            // unexpectedly (e.g. panicked) — which would otherwise drop every
721            // subsequent receipt with no signal — is at least observable.
722            Err(mpsc::error::TrySendError::Closed(_)) => {
723                tracing::debug!(
724                    event = "download_receipt_sink_closed",
725                    "receipt dropped: writer gone (expected only during shutdown)"
726                );
727            }
728        }
729    }
730}
731
732/// Synchronous [`ReceiptSink`] that appends inline to a [`ReceiptLog`],
733/// swallowing the write error. Test/loopback support only: lets the unit and
734/// `client_loopback` tests keep a deterministic in-memory [`ReceiptLog`] fake
735/// behind the sink seam the handler depends on, without standing up the
736/// background writer or polling for an async drain. Production never uses it —
737/// the runtime always routes through [`spawn_receipt_writer`] so disk I/O cannot
738/// block paid delivery.
739///
740/// It deliberately *violates* the [`ReceiptSink`] non-blocking contract (it
741/// appends inline on the caller's thread), so it must never be wired onto the
742/// paid-delivery path. The `test-support` feature gate keeps it out of every
743/// production build: it compiles only for this crate's own tests and for the
744/// cross-crate integration tests in `tests/`, which cannot see `#[cfg(test)]`
745/// items. The field is private and the type is `#[doc(hidden)]` so it does not
746/// read as a production knob.
747#[cfg(any(test, feature = "test-support"))]
748#[doc(hidden)]
749pub struct DirectReceiptSink(Arc<dyn ReceiptLog>);
750
751#[cfg(any(test, feature = "test-support"))]
752impl DirectReceiptSink {
753    /// Wrap a synchronous [`ReceiptLog`] as an inline-appending sink. Test and
754    /// loopback use only — see the type docs; never wire this onto the
755    /// paid-delivery path.
756    #[must_use]
757    pub fn new(log: Arc<dyn ReceiptLog>) -> Self {
758        Self(log)
759    }
760}
761
762#[cfg(any(test, feature = "test-support"))]
763impl std::fmt::Debug for DirectReceiptSink {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        f.debug_struct("DirectReceiptSink").finish_non_exhaustive()
766    }
767}
768
769#[cfg(any(test, feature = "test-support"))]
770impl ReceiptSink for DirectReceiptSink {
771    fn record(&self, receipt: RawReceipt) {
772        // Render inline (this fake appends on the caller's thread anyway) with a
773        // timestamp read here, mirroring where the background writer reads it.
774        let rendered = receipt.render(crate::payment_settlement::unix_now());
775        // Audit-only and non-fatal, matching the background writer's handling.
776        let _ = self.0.append(&rendered);
777    }
778}
779
780/// Spawn the single background task that owns the on-disk [`ReceiptLog`] and
781/// drains the receipt queue, returning the [`ReceiptSink`] the delivery path
782/// enqueues through plus the [`StopHandle`] that stops the task at graceful
783/// shutdown.
784///
785/// Decouples the audit write from the paid-delivery hot path (#803): the
786/// voucher-accept path does only a non-blocking [`ReceiptSink::record`] (a
787/// bounded `try_send`) as each voucher is accepted, while this task performs
788/// the actual `append` on the blocking pool. A slow or full disk can only fill
789/// the queue (and drop audit records, counted) — it can never delay delivery
790/// or serialize delivery on the receipt-log mutex. The writer is the *only*
791/// caller of [`ReceiptLog::append`], so the log's internal mutex sees no
792/// cross-stream contention.
793///
794/// On its stop signal ([`StopHandle::cancel`], fired after the router has
795/// drained, so no further receipts are produced) the task flushes whatever is
796/// already enqueued and exits; complete [`StopHandle::shutdown`] within the
797/// shutdown deadline to preserve the audit tail. The `StopHandle` owns the stop
798/// token beside the task handle, so the join cannot hang on sinks that outlive
799/// shutdown — [`crate::handlers::client::ClientHandler`] holds one for the
800/// whole runtime.
801pub fn spawn_receipt_writer(
802    log: Arc<dyn ReceiptLog>,
803    metrics: Arc<Metrics>,
804) -> (Arc<dyn ReceiptSink>, StopHandle) {
805    let (tx, rx) = mpsc::channel::<RawReceipt>(RECEIPT_LOG_CAPACITY);
806    let writer_metrics = Arc::clone(&metrics);
807    let handle =
808        StopHandle::spawn(move |shutdown| receipt_writer_loop(rx, log, writer_metrics, shutdown));
809    (Arc::new(ChannelReceiptSink { tx, metrics }), handle)
810}
811
812/// Drain loop for the background receipt writer (see [`spawn_receipt_writer`]).
813/// Appends each receipt FIFO until the queue closes or `shutdown` fires, then
814/// flushes the already-enqueued tail before returning.
815async fn receipt_writer_loop(
816    mut rx: mpsc::Receiver<RawReceipt>,
817    log: Arc<dyn ReceiptLog>,
818    metrics: Arc<Metrics>,
819    shutdown: CancellationToken,
820) {
821    loop {
822        tokio::select! {
823            biased;
824            maybe = rx.recv() => match maybe {
825                Some(receipt) => append_one(&log, receipt, &metrics).await,
826                // All sinks dropped — nothing more can be enqueued.
827                None => break,
828            },
829            () = shutdown.cancelled() => break,
830        }
831    }
832    // Tail-drain: write every receipt already enqueued before exiting, so a
833    // graceful shutdown does not drop the audit tail the way a crash does.
834    // `try_recv` yields `Empty` once
835    // the buffer is drained (and `Disconnected` if the senders are gone); either
836    // ends the drain.
837    while let Ok(receipt) = rx.try_recv() {
838        append_one(&log, receipt, &metrics).await;
839    }
840    tracing::debug!("download-receipt writer drained and stopped");
841}
842
843/// Render one raw receipt and append it on the blocking pool, logging a
844/// non-fatal failure.
845///
846/// The delivery task only enqueued a [`RawReceipt`]; the hex/decimal rendering
847/// happens here (#1792 item 2), off the paid-delivery path. The timestamp is
848/// read at dequeue — so it lags the acceptance point only by the queue-drain
849/// latency and stays in FIFO order — while the render itself runs *inside* the
850/// [`tokio::task::spawn_blocking`] closure, so no formatting or allocation
851/// touches a runtime worker thread either.
852///
853/// The blocking pool is also where the synchronous `write_all`/`flush` (and any
854/// disk stall under a full or slow `data_dir`) runs, never on a runtime worker.
855/// The single writer awaits each append before the next, preserving receipt
856/// order. An append error is non-fatal — the payment already advanced the lane
857/// watermark — so it is logged at `warn`, counted into
858/// `decdn_receipt_write_failures_total`, and the loop continues.
859async fn append_one(log: &Arc<dyn ReceiptLog>, raw: RawReceipt, metrics: &Metrics) {
860    // Stamp at dequeue so the timestamp reflects acceptance order; the render
861    // that consumes it runs on the blocking pool below.
862    let stamped_at = crate::payment_settlement::unix_now();
863    let log = Arc::clone(log);
864    let join = tokio::task::spawn_blocking(move || {
865        let receipt = raw.render(stamped_at);
866        let res = log.append(&receipt);
867        (res, receipt)
868    })
869    .await;
870    // A `JoinError` means the blocking append panicked (`spawn_blocking` tasks
871    // are not cancellable, so a panic is the only way here). The receipt was
872    // moved into the panicked closure, so its fields are gone — but the loss
873    // still gets a structured line, distinct from the normal write-failure path
874    // below, rather than relying on the default panic hook's unstructured stderr.
875    let (res, receipt) = match join {
876        Ok(pair) => pair,
877        Err(join_err) => {
878            metrics.receipt_write_failure();
879            tracing::warn!(
880                error = %join_err,
881                event = "download_receipt_writer_join_error",
882                "download-receipt append task panicked; one audit receipt lost \
883                 (payment already committed, audit log only)"
884            );
885            return;
886        }
887    };
888    if let Err(e) = res {
889        metrics.receipt_write_failure();
890        tracing::warn!(
891            hash = receipt.hash(),
892            client_node_id = receipt.client_node_id(),
893            voucher_amount = receipt.voucher_amount(),
894            error = %e,
895            event = "download_receipt_write_failed",
896            "failed to append download receipt; payment already committed (audit log only)"
897        );
898    }
899}
900
901#[cfg(test)]
902mod tests;