qualia_client_core/wallet/
ledger.rs1use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LedgerEntry {
14 pub timestamp: String,
16 pub kind: LedgerEntryKind,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type")]
22pub enum LedgerEntryKind {
23 IlpDispatch {
25 recipient_label: String,
26 ilp_address: String,
27 amount_micro_cents: u64,
28 status: String, },
30 TxBroadcast {
32 chain: String, txid: String,
34 amount_sats: u64, direction: String, },
37 TokenMint {
39 chain: String,
40 txid: String,
41 token_id: String,
42 symbol: String,
43 },
44}
45
46pub fn ledger_path(storage_path: &Path) -> PathBuf {
48 storage_path.join("wallet_ledger.ndjson")
49}
50
51pub 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
68pub 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
82pub 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
101pub 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 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 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
126fn epoch_days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
128 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
142pub 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 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}