Skip to main content

qualia_client_core/wallet/
ledger.rs

1//! Persistent append-only wallet ledger — tracks ILP micropayment dispatches and
2//! locally-signed transaction hashes so that `WalletStatus` can report real values
3//! instead of hardcoded mocks.
4//!
5//! Storage format: NDJSON in `<storage_path>/wallet_ledger.ndjson`.
6//! Each line is a [`LedgerEntry`] serialized as JSON.
7
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11/// A single entry in the wallet ledger.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LedgerEntry {
14    /// ISO-8601 timestamp of when this entry was created.
15    pub timestamp: String,
16    /// The type of ledger event.
17    pub kind: LedgerEntryKind,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type")]
22pub enum LedgerEntryKind {
23    /// An ILP micropayment was dispatched (or queued).
24    IlpDispatch {
25        recipient_label: String,
26        ilp_address: String,
27        amount_micro_cents: u64,
28        status: String, // "sent" | "queued" | "failed"
29    },
30    /// A transaction was signed and broadcast on-chain.
31    TxBroadcast {
32        chain: String, // "XEC" | "BTC" | etc.
33        txid: String,
34        amount_sats: u64,  // in chain-native smallest unit
35        direction: String, // "out"
36    },
37    /// A token mint (GENESIS) was broadcast.
38    TokenMint {
39        chain: String,
40        txid: String,
41        token_id: String,
42        symbol: String,
43    },
44}
45
46/// Returns the path to the wallet ledger file.
47pub fn ledger_path(storage_path: &Path) -> PathBuf {
48    storage_path.join("wallet_ledger.ndjson")
49}
50
51/// Append a ledger entry to the NDJSON file. Creates the file if it doesn't exist.
52pub fn append_entry(storage_path: &Path, entry: &LedgerEntry) -> Result<(), String> {
53    use std::io::Write;
54    let path = ledger_path(storage_path);
55    if let Some(parent) = path.parent() {
56        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
57    }
58    let mut file = std::fs::OpenOptions::new()
59        .create(true)
60        .append(true)
61        .open(&path)
62        .map_err(|e| e.to_string())?;
63    let json = serde_json::to_string(entry).map_err(|e| e.to_string())?;
64    writeln!(file, "{}", json).map_err(|e| e.to_string())?;
65    Ok(())
66}
67
68/// Read all ledger entries. Returns an empty vec if the file doesn't exist.
69pub fn read_entries(storage_path: &Path) -> Vec<LedgerEntry> {
70    let path = ledger_path(storage_path);
71    let content = match std::fs::read_to_string(&path) {
72        Ok(c) => c,
73        Err(_) => return Vec::new(),
74    };
75    content
76        .lines()
77        .filter(|line| !line.trim().is_empty())
78        .filter_map(|line| serde_json::from_str(line).ok())
79        .collect()
80}
81
82/// Sum total ILP micro-cents dispatched with status "sent".
83pub fn total_ilp_sent_micro_cents(storage_path: &Path) -> u64 {
84    let entries = read_entries(storage_path);
85    let mut total = 0u64;
86    for entry in entries {
87        if let LedgerEntryKind::IlpDispatch {
88            amount_micro_cents,
89            status,
90            ..
91        } = entry.kind
92        {
93            if status == "sent" {
94                total = total.saturating_add(amount_micro_cents);
95            }
96        }
97    }
98    total
99}
100
101/// Create a new LedgerEntry with the current timestamp.
102pub fn new_entry(kind: LedgerEntryKind) -> LedgerEntry {
103    let timestamp = {
104        use std::time::{SystemTime, UNIX_EPOCH};
105        let secs = SystemTime::now()
106            .duration_since(UNIX_EPOCH)
107            .map(|d| d.as_secs())
108            .unwrap_or(0);
109        // Simple ISO-8601 UTC from epoch seconds
110        let days = secs / 86400;
111        let remaining = secs % 86400;
112        let hours = remaining / 3600;
113        let minutes = (remaining % 3600) / 60;
114        let seconds = remaining % 60;
115        // Approximate date from epoch (good enough for ledger timestamps)
116        // Using a simple calculation from 1970-01-01
117        let (year, month, day) = epoch_days_to_date(days);
118        format!(
119            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
120            year, month, day, hours, minutes, seconds
121        )
122    };
123    LedgerEntry { timestamp, kind }
124}
125
126/// Convert days since epoch to (year, month, day). Civil calendar.
127fn epoch_days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
128    // Algorithm from Howard Hinnant's date algorithms
129    let z = days_since_epoch + 719468;
130    let era = z / 146097;
131    let doe = z - era * 146097;
132    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
133    let y = yoe + era * 400;
134    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
135    let mp = (5 * doy + 2) / 153;
136    let d = doy - (153 * mp + 2) / 5 + 1;
137    let m = if mp < 10 { mp + 3 } else { mp - 9 };
138    let y = if m <= 2 { y + 1 } else { y };
139    (y, m, d)
140}
141
142/// Public accessor for epoch-to-date conversion (used by api.rs timestamp formatting).
143pub fn epoch_days_to_date_pub(days_since_epoch: u64) -> (u64, u64, u64) {
144    epoch_days_to_date(days_since_epoch)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::path::PathBuf;
151
152    #[test]
153    fn test_ledger_round_trip() {
154        let tmp = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
155            .join("target")
156            .join("test_ledger_rt");
157        let _ = std::fs::remove_dir_all(&tmp);
158        std::fs::create_dir_all(&tmp).unwrap();
159
160        let entry1 = new_entry(LedgerEntryKind::IlpDispatch {
161            recipient_label: "test-node".into(),
162            ilp_address: "$ilp.test/node".into(),
163            amount_micro_cents: 5000,
164            status: "sent".into(),
165        });
166        let entry2 = new_entry(LedgerEntryKind::TxBroadcast {
167            chain: "XEC".into(),
168            txid: "abc123".into(),
169            amount_sats: 100000,
170            direction: "out".into(),
171        });
172
173        append_entry(&tmp, &entry1).unwrap();
174        append_entry(&tmp, &entry2).unwrap();
175
176        let entries = read_entries(&tmp);
177        assert_eq!(entries.len(), 2);
178
179        let total = total_ilp_sent_micro_cents(&tmp);
180        assert_eq!(total, 5000);
181
182        let _ = std::fs::remove_dir_all(&tmp);
183    }
184
185    #[test]
186    fn test_empty_ledger_returns_zero() {
187        let tmp = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
188            .join("target")
189            .join("test_ledger_empty");
190        let _ = std::fs::remove_dir_all(&tmp);
191        assert_eq!(total_ilp_sent_micro_cents(&tmp), 0);
192        assert!(read_entries(&tmp).is_empty());
193    }
194
195    #[test]
196    fn test_epoch_date_conversion() {
197        // 2026-01-01 = day 20454 from epoch
198        let (y, m, d) = epoch_days_to_date(20454);
199        assert_eq!(y, 2026);
200        assert_eq!(m, 1);
201        assert_eq!(d, 1);
202    }
203}