Skip to main content

sidestr_round/
journal.rs

1//! The vote journal: what this signer has authorised, written down before
2//! the custody key is asked and before anything is published.
3//!
4//! `round.mjs` keeps `signed` (height → the proposal it signed, and when) in
5//! memory, so a signer that restarts has forgotten what it signed and will
6//! sign whatever entitled proposal arrives next — which is how two
7//! authorisations for one height come to exist (ADR-2101, review §7:
8//! "replace the in-memory `signed` map with durable safety state").
9//!
10//! # What is guaranteed, exactly
11//!
12//! Every authorisation is two records. The **intent** (the height or the
13//! burn, the proposal id, the template id or the unsigned txid, the time) is
14//! written and synced **before** the custody signer
15//! ([`crate::signer::BlockSigner`]) is invoked; if that write fails the
16//! signer is not called, so no signature exists. After the signer answers,
17//! the **signature** is recorded, and only then is the `Publish` action
18//! returned; if that second write fails nothing is published. On restart
19//! the journal is loaded and the rule of one signature per height (or per
20//! burn) is applied against every entry — an intent whose signature was
21//! never recorded counts, since a signature that may exist is one that may
22//! have left.
23//!
24//! The wire does not change: nothing in a journal entry is published.
25//!
26//! A journal is not anti-rollback (review §7): a host restored from a
27//! snapshot has an old journal. It stops the ordinary case — a crash or a
28//! restart — from turning into a double signature, and that is all it claims.
29
30use std::fs::{File, OpenOptions};
31use std::io::{Read, Write};
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::{Error, Result};
37
38/// What a vote is for.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum VoteScope {
42    /// A block template at this height.
43    Height(u32),
44    /// A peg-out PSBT paying this burn, `<txid>:<vout>`.
45    Burn(String),
46}
47
48/// Whether this signer proposed the thing or co-signed another's.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum VoteRole {
52    /// My own proposal, which carries my signature.
53    Proposed,
54    /// Another signer's proposal that I signed.
55    Signed,
56}
57
58/// Which of the two records of an authorisation this is.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum VoteStage {
62    /// Written before the custody signer is asked. Counts as an
63    /// authorisation on reload: the signature may exist.
64    Intent,
65    /// Written after the custody signer answered, with the signature.
66    #[default]
67    Signed,
68}
69
70/// One record. An authorisation is an [`VoteStage::Intent`] followed by a
71/// [`VoteStage::Signed`] with the same `subject`; either alone is an
72/// authorisation for the one-signature rule.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct VoteEntry {
75    /// The height or the burn.
76    pub scope: VoteScope,
77    /// Proposed or signed.
78    pub role: VoteRole,
79    /// The proposal event's id (kind 23510 or 23512).
80    pub subject: String,
81    /// What was authorised, as hex: the template id for a block
82    /// ([`sidestr_core::block::template_id`]), the unsigned txid for a
83    /// peg-out.
84    pub digest: String,
85    /// When, unix **milliseconds**, by the signer's clock — the round's
86    /// clock is milliseconds so the reference's timing holds to the
87    /// millisecond across a restart.
88    pub at: u64,
89    /// Intent or signed. Absent in a file written before this field
90    /// existed: read as signed.
91    #[serde(default)]
92    pub stage: VoteStage,
93    /// The signature the custody signer produced, hex, on a
94    /// [`VoteStage::Signed`] record: one 64-byte BIP-340 signature for a
95    /// block; one per input, comma-separated, for a peg-out.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub signature: Option<String>,
98}
99
100/// The port. [`MemoryJournal`] for tests and throwaway runs; [`FileJournal`]
101/// for a signer that must survive a restart.
102pub trait VoteJournal {
103    /// Write an entry durably. Returning an error means the signer does not
104    /// sign (for an intent) or does not publish (for a signature).
105    fn record(&mut self, entry: &VoteEntry) -> Result<()>;
106    /// Every entry, oldest first.
107    fn entries(&self) -> Result<Vec<VoteEntry>>;
108}
109
110/// A journal that forgets on drop.
111#[derive(Debug, Default)]
112pub struct MemoryJournal {
113    entries: Vec<VoteEntry>,
114}
115
116impl MemoryJournal {
117    /// Empty.
118    pub fn new() -> Self {
119        Self::default()
120    }
121    /// Seeded, as if loaded from disk.
122    pub fn with_entries(entries: Vec<VoteEntry>) -> Self {
123        Self { entries }
124    }
125}
126
127impl VoteJournal for MemoryJournal {
128    fn record(&mut self, entry: &VoteEntry) -> Result<()> {
129        self.entries.push(entry.clone());
130        Ok(())
131    }
132    fn entries(&self) -> Result<Vec<VoteEntry>> {
133        Ok(self.entries.clone())
134    }
135}
136
137/// An append-only file of JSON lines, one entry per line, `fsync`ed after
138/// every write.
139///
140/// # Durability, exactly
141///
142/// - **On open the file is validated.** Every `\n`-terminated line must be
143///   an entry; a malformed terminated line is an error (a journal that
144///   cannot be read is not a journal, and a signer without one does not
145///   sign). An unterminated suffix is a torn write from a crash: if it
146///   parses as a whole entry only its newline was lost, and it is
147///   terminated; otherwise it is cut back to the last record boundary. The
148///   repair is synced, as is the directory after the file is created.
149/// - **`record` appends after that boundary** and syncs. If the append
150///   fails part-way the file is cut back to the length it had immediately
151///   before this write, measured on the file itself, never to a length
152///   cached earlier (a cached length rolled back other writers' acknowledged
153///   entries; found by the 2026-09-22 verification pass); if even that fails
154///   the journal refuses every later write, so a torn line can never have
155///   another record appended to it.
156/// - **One writer per file.** `open` takes an exclusive advisory lock
157///   (`flock`) for the handle's lifetime and a second live handle is
158///   refused by name ([`Error::Journal`], "held by another handle"), so two
159///   rounds cannot share one file and roll each other back. The block round
160///   and the peg-out round each own their own journal.
161/// - A torn record was never acknowledged, so nothing was signed on its
162///   strength: the intent is written before the custody signer is asked.
163#[derive(Debug)]
164pub struct FileJournal {
165    path: PathBuf,
166    file: File,
167    /// Bytes up to the last record boundary; every byte before it is a
168    /// terminated, well-formed entry.
169    durable_len: u64,
170    /// Set when a failed append could not be rolled back.
171    poisoned: Option<String>,
172}
173
174/// The parse of a file's bytes: the entries, and the unterminated suffix if
175/// there is one (its start offset and whether it parses as an entry).
176struct Parsed {
177    entries: Vec<VoteEntry>,
178    durable_len: u64,
179    torn: Option<(u64, Option<VoteEntry>)>,
180}
181
182fn parse(bytes: &[u8], path: &Path) -> Result<Parsed> {
183    let mut entries = Vec::new();
184    let mut pos = 0usize;
185    let mut line_no = 0usize;
186    let mut durable_len = 0u64;
187    let mut torn = None;
188    while pos < bytes.len() {
189        line_no += 1;
190        let rest = &bytes[pos..];
191        match rest.iter().position(|b| *b == b'\n') {
192            Some(nl) => {
193                let line = &rest[..nl];
194                let text = std::str::from_utf8(line).map_err(|e| {
195                    Error::Journal(format!("{} line {line_no}: {e}", path.display()))
196                })?;
197                if !text.trim().is_empty() {
198                    let e = serde_json::from_str::<VoteEntry>(text).map_err(|e| {
199                        Error::Journal(format!("{} line {line_no}: {e}", path.display()))
200                    })?;
201                    entries.push(e);
202                }
203                pos += nl + 1;
204                durable_len = pos as u64;
205            }
206            None => {
207                let whole = std::str::from_utf8(rest)
208                    .ok()
209                    .and_then(|t| serde_json::from_str::<VoteEntry>(t).ok());
210                torn = Some((pos as u64, whole));
211                break;
212            }
213        }
214    }
215    Ok(Parsed {
216        entries,
217        durable_len,
218        torn,
219    })
220}
221
222fn journal_err(path: &Path, what: &str, e: impl std::fmt::Display) -> Error {
223    Error::Journal(format!("{}: {what}: {e}", path.display()))
224}
225
226/// Take the exclusive advisory lock for the handle's lifetime; a second live
227/// handle on the same file is refused rather than allowed to roll the first
228/// one back. Advisory: it binds every opener that uses this type, which is
229/// every round in this crate; it does not stop a foreign process writing.
230fn lock_exclusive(file: &File, path: &Path) -> Result<()> {
231    use rustix::fs::{flock, FlockOperation};
232    flock(file, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
233        if e == rustix::io::Errno::WOULDBLOCK {
234            Error::Journal(format!(
235                "{}: held by another handle; one writer per journal file",
236                path.display()
237            ))
238        } else {
239            journal_err(path, "lock", e)
240        }
241    })
242}
243
244fn sync_dir(path: &Path) -> Result<()> {
245    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
246        File::open(dir)
247            .and_then(|d| d.sync_all())
248            .map_err(|e| journal_err(dir, "fsync directory", e))?;
249    }
250    Ok(())
251}
252
253impl FileJournal {
254    /// Open or create `path`, creating its directory, validating the file
255    /// and repairing a torn tail (see the type's documentation).
256    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
257        let path = path.as_ref().to_path_buf();
258        if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
259            std::fs::create_dir_all(dir)?;
260        }
261        let existed = path.exists();
262        let mut file = OpenOptions::new()
263            .create(true)
264            .append(true)
265            .read(true)
266            .open(&path)
267            .map_err(|e| journal_err(&path, "open", e))?;
268        lock_exclusive(&file, &path)?;
269        if !existed {
270            file.sync_all()
271                .map_err(|e| journal_err(&path, "fsync", e))?;
272            sync_dir(&path)?;
273        }
274        let mut bytes = Vec::new();
275        file.read_to_end(&mut bytes)
276            .map_err(|e| journal_err(&path, "read", e))?;
277        let parsed = parse(&bytes, &path)?;
278        let mut durable_len = parsed.durable_len;
279        if let Some((start, whole)) = parsed.torn {
280            match whole {
281                Some(_) => {
282                    // a whole entry that lost only its newline: terminate it
283                    file.write_all(b"\n")
284                        .map_err(|e| journal_err(&path, "repair", e))?;
285                    durable_len = bytes.len() as u64 + 1;
286                }
287                None => {
288                    file.set_len(start)
289                        .map_err(|e| journal_err(&path, "truncate torn tail", e))?;
290                    durable_len = start;
291                }
292            }
293            file.sync_all()
294                .map_err(|e| journal_err(&path, "fsync repair", e))?;
295            sync_dir(&path)?;
296        }
297        Ok(Self {
298            path,
299            file,
300            durable_len,
301            poisoned: None,
302        })
303    }
304    /// Where it lives.
305    pub fn path(&self) -> &Path {
306        &self.path
307    }
308}
309
310impl VoteJournal for FileJournal {
311    fn record(&mut self, entry: &VoteEntry) -> Result<()> {
312        if let Some(why) = &self.poisoned {
313            return Err(Error::Journal(format!(
314                "{}: refusing every write after a failed append: {why}",
315                self.path.display()
316            )));
317        }
318        let mut line = serde_json::to_string(entry).map_err(|e| Error::Journal(e.to_string()))?;
319        line.push('\n');
320        // the boundary this write starts at, measured now on the file itself:
321        // rolling back to a cached length would cut off anything appended
322        // since (the lock makes that impossible from another handle, but the
323        // rollback must not depend on it)
324        let before = self
325            .file
326            .metadata()
327            .map(|m| m.len())
328            .map_err(|e| journal_err(&self.path, "stat before append", e))?;
329        let written = self
330            .file
331            .write_all(line.as_bytes())
332            .and_then(|()| self.file.sync_data());
333        match written {
334            Ok(()) => {
335                self.durable_len = before + line.len() as u64;
336                Ok(())
337            }
338            Err(e) => {
339                // cut back to the boundary this write started at, so a torn line is never appended to
340                let rolled = self
341                    .file
342                    .set_len(before)
343                    .and_then(|()| self.file.sync_data());
344                if let Err(r) = rolled {
345                    self.poisoned = Some(format!("{e}; rollback failed: {r}"));
346                }
347                Err(journal_err(&self.path, "append", e))
348            }
349        }
350    }
351
352    fn entries(&self) -> Result<Vec<VoteEntry>> {
353        if let Some(why) = &self.poisoned {
354            return Err(Error::Journal(format!(
355                "{}: unreadable after a failed append: {why}",
356                self.path.display()
357            )));
358        }
359        let bytes = std::fs::read(&self.path).map_err(|e| journal_err(&self.path, "read", e))?;
360        let parsed = parse(&bytes, &self.path)?;
361        if let Some((start, _)) = parsed.torn {
362            return Err(Error::Journal(format!(
363                "{}: unterminated record at byte {start}; reopen the journal to repair it",
364                self.path.display()
365            )));
366        }
367        Ok(parsed.entries)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn entry(h: u32) -> VoteEntry {
376        VoteEntry {
377            scope: VoteScope::Height(h),
378            role: VoteRole::Signed,
379            subject: "ab".repeat(32),
380            digest: "cd".repeat(32),
381            at: 1_790_000_000_000 + u64::from(h),
382            stage: VoteStage::Signed,
383            signature: None,
384        }
385    }
386
387    fn scratch(name: &str) -> PathBuf {
388        std::env::temp_dir().join(format!(
389            "sidestr-round-journal-{name}-{}",
390            std::process::id()
391        ))
392    }
393
394    #[test]
395    fn the_file_journal_round_trips_and_repairs_a_torn_tail_before_appending() {
396        let dir = scratch("torn");
397        let _ = std::fs::remove_dir_all(&dir);
398        let path = dir.join("votes.jsonl");
399        {
400            let mut j = FileJournal::open(&path).unwrap();
401            j.record(&entry(1)).unwrap();
402            j.record(&VoteEntry {
403                scope: VoteScope::Burn(format!("{}:0", "ef".repeat(32))),
404                role: VoteRole::Proposed,
405                ..entry(2)
406            })
407            .unwrap();
408            assert_eq!(j.entries().unwrap().len(), 2);
409        }
410        let clean_len = std::fs::metadata(&path).unwrap().len();
411        // a torn last line is cut back on open, and the next record lands on the boundary
412        std::fs::OpenOptions::new()
413            .append(true)
414            .open(&path)
415            .unwrap()
416            .write_all(b"{\"scope\":{\"hei")
417            .unwrap();
418        let mut j = FileJournal::open(&path).unwrap();
419        assert_eq!(std::fs::metadata(&path).unwrap().len(), clean_len);
420        let e = j.entries().unwrap();
421        assert_eq!(e.len(), 2);
422        assert_eq!(e[0], entry(1));
423        assert!(matches!(e[1].scope, VoteScope::Burn(_)));
424        j.record(&entry(3)).unwrap();
425        drop(j);
426        let e = FileJournal::open(&path).unwrap().entries().unwrap();
427        assert_eq!(e.len(), 3, "append after recovery survives a reload");
428        assert_eq!(e[2], entry(3));
429        // a second crash after recovery: the same repair, the same append
430        std::fs::OpenOptions::new()
431            .append(true)
432            .open(&path)
433            .unwrap()
434            .write_all(b"{\"sco")
435            .unwrap();
436        let mut j = FileJournal::open(&path).unwrap();
437        j.record(&entry(4)).unwrap();
438        drop(j);
439        let e = FileJournal::open(&path).unwrap().entries().unwrap();
440        assert_eq!(e.iter().map(|e| &e.scope).collect::<Vec<_>>().len(), 4);
441        assert_eq!(e[3], entry(4));
442        // a whole entry that lost only its newline is kept and terminated
443        let mut whole = serde_json::to_vec(&entry(5)).unwrap();
444        std::fs::OpenOptions::new()
445            .append(true)
446            .open(&path)
447            .unwrap()
448            .write_all(&whole)
449            .unwrap();
450        let j = FileJournal::open(&path).unwrap();
451        assert_eq!(j.entries().unwrap().len(), 5);
452        whole.push(b'\n');
453        assert!(std::fs::read(&path).unwrap().ends_with(&whole));
454        drop(j); // the lock is held for the handle's lifetime
455                 // a malformed terminated line is an error: fail closed
456        let mut text = std::fs::read_to_string(&path).unwrap();
457        text.push_str("{\"scope\":{\"height\":3}}\n");
458        std::fs::write(&path, text).unwrap();
459        let e = FileJournal::open(&path).unwrap_err().to_string();
460        assert!(e.contains("line 6"), "{e}");
461        let _ = std::fs::remove_dir_all(dir);
462    }
463
464    #[test]
465    fn a_fresh_file_and_a_reload_read_older_records_without_a_stage() {
466        let dir = scratch("stage");
467        let _ = std::fs::remove_dir_all(&dir);
468        let path = dir.join("deep").join("votes.jsonl");
469        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
470        std::fs::write(
471            &path,
472            format!(
473                "{{\"scope\":{{\"height\":9}},\"role\":\"signed\",\"subject\":\"{}\",\"digest\":\"{}\",\"at\":5}}\n",
474                "ab".repeat(32),
475                "cd".repeat(32)
476            ),
477        )
478        .unwrap();
479        let j = FileJournal::open(&path).unwrap();
480        let e = j.entries().unwrap();
481        assert_eq!(e[0].stage, VoteStage::Signed);
482        assert_eq!(e[0].signature, None);
483        let _ = std::fs::remove_dir_all(dir);
484    }
485
486    #[test]
487    fn the_wire_shape_of_an_entry_is_stable() {
488        let s = serde_json::to_string(&entry(5)).unwrap();
489        assert!(
490            s.starts_with(r#"{"scope":{"height":5},"role":"signed","subject":""#),
491            "{s}"
492        );
493        assert!(s.ends_with(r#","stage":"signed"}"#), "{s}");
494        let s = serde_json::to_string(&VoteEntry {
495            stage: VoteStage::Intent,
496            ..entry(5)
497        })
498        .unwrap();
499        assert!(s.ends_with(r#","stage":"intent"}"#), "{s}");
500    }
501}