1use crate::CliError;
8use btctax_core::EventId;
9use rusqlite::{Connection, OptionalExtension};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub fn init_table(conn: &Connection) -> Result<(), CliError> {
16 conn.execute_batch(
17 "CREATE TABLE IF NOT EXISTS optimize_attestation \
18 (disposal_event TEXT PRIMARY KEY, attestation TEXT NOT NULL, attested_at TEXT NOT NULL);",
19 )?;
20 Ok(())
21}
22
23pub fn set(
27 conn: &Connection,
28 disposal: &EventId,
29 attestation: &str,
30 attested_at: &str,
31) -> Result<(), CliError> {
32 init_table(conn)?;
33 conn.execute(
34 "INSERT INTO optimize_attestation(disposal_event,attestation,attested_at) \
35 VALUES(?1,?2,?3) \
36 ON CONFLICT(disposal_event) DO UPDATE \
37 SET attestation=excluded.attestation, attested_at=excluded.attested_at",
38 rusqlite::params![disposal.canonical(), attestation, attested_at],
39 )?;
40 Ok(())
41}
42
43pub fn get(conn: &Connection, disposal: &EventId) -> Result<Option<String>, CliError> {
46 init_table(conn)?;
47 Ok(conn
48 .query_row(
49 "SELECT attestation FROM optimize_attestation WHERE disposal_event=?1",
50 [disposal.canonical()],
51 |r| r.get(0),
52 )
53 .optional()?)
54}
55
56pub fn all(conn: &Connection) -> Result<BTreeMap<EventId, (String, String)>, CliError> {
60 init_table(conn)?;
61 let mut stmt = conn.prepare(
62 "SELECT disposal_event, attestation, attested_at \
63 FROM optimize_attestation ORDER BY disposal_event",
64 )?;
65 let rows = stmt.query_map([], |r| {
66 Ok((
67 r.get::<_, String>(0)?,
68 r.get::<_, String>(1)?,
69 r.get::<_, String>(2)?,
70 ))
71 })?;
72 let mut out = BTreeMap::new();
73 for row in rows {
74 let (disposal_str, attestation, attested_at) = row?;
75 out.insert(
76 crate::eventref::parse_event_id(&disposal_str)?,
77 (attestation, attested_at),
78 );
79 }
80 Ok(out)
81}
82
83pub fn clear(conn: &Connection, disposal: &EventId) -> Result<(), CliError> {
87 init_table(conn)?;
88 conn.execute(
89 "DELETE FROM optimize_attestation WHERE disposal_event=?1",
90 [disposal.canonical()],
91 )?;
92 Ok(())
93}
94
95pub fn attested_set(conn: &Connection) -> Result<BTreeSet<EventId>, CliError> {
98 init_table(conn)?;
99 let mut stmt =
100 conn.prepare("SELECT disposal_event FROM optimize_attestation ORDER BY disposal_event")?;
101 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
102 let mut out = BTreeSet::new();
103 for r in rows {
104 out.insert(crate::eventref::parse_event_id(&r?)?);
105 }
106 Ok(out)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use btctax_core::{EventId, Source, SourceRef};
113
114 fn eid(seq: u64) -> EventId {
115 EventId::decision(seq)
116 }
117
118 fn eid_import(src_ref: &str) -> EventId {
119 EventId::import(Source::Coinbase, SourceRef::new(src_ref.to_string()))
120 }
121
122 fn mem() -> rusqlite::Connection {
123 let c = rusqlite::Connection::open_in_memory().unwrap();
124 init_table(&c).unwrap();
125 c
126 }
127
128 #[test]
129 fn set_then_get_round_trips() {
130 let c = mem();
131 let disposal = eid(1);
132 set(&c, &disposal, r#"{"lots":[]}"#, "2025-03-15").unwrap();
133 let stored = get(&c, &disposal).unwrap().unwrap();
134 assert_eq!(stored, r#"{"lots":[]}"#);
135 }
136
137 #[test]
138 fn get_missing_returns_none() {
139 let c = mem();
140 assert_eq!(get(&c, &eid(99)).unwrap(), None);
141 }
142
143 #[test]
144 fn get_on_tableless_vault_returns_none() {
145 let c = rusqlite::Connection::open_in_memory().unwrap(); assert_eq!(get(&c, &eid(1)).unwrap(), None);
147 }
148
149 #[test]
150 fn attested_set_on_tableless_vault_returns_empty() {
151 let c = rusqlite::Connection::open_in_memory().unwrap(); assert!(attested_set(&c).unwrap().is_empty());
153 }
154
155 #[test]
156 fn attested_set_returns_keys_in_deterministic_sorted_order() {
157 let c = mem();
158 let d3 = eid(3);
160 let d1 = eid(1);
161 let d2 = eid(2);
162 set(&c, &d3, "sel3", "2025-01-03").unwrap();
163 set(&c, &d1, "sel1", "2025-01-01").unwrap();
164 set(&c, &d2, "sel2", "2025-01-02").unwrap();
165 let set_out = attested_set(&c).unwrap();
166 assert_eq!(set_out.len(), 3);
168 assert!(set_out.contains(&d1));
169 assert!(set_out.contains(&d2));
170 assert!(set_out.contains(&d3));
171 let set_out2 = attested_set(&c).unwrap();
173 let keys1: Vec<_> = set_out.iter().collect();
174 let keys2: Vec<_> = set_out2.iter().collect();
175 assert_eq!(keys1, keys2);
176 }
177
178 #[test]
179 fn upsert_replaces_prior_attestation() {
180 let c = mem();
181 let disposal = eid(1);
182 set(&c, &disposal, "original_sel", "2025-01-01").unwrap();
183 set(&c, &disposal, "updated_sel", "2025-06-01").unwrap();
184 assert_eq!(get(&c, &disposal).unwrap().unwrap(), "updated_sel");
185 }
186
187 #[test]
188 fn all_returns_both_attestations_in_deterministic_order() {
189 let c = mem();
190 let d1 = eid(1);
191 let d2 = eid(2);
192 set(&c, &d2, r#"{"lots":["lot2"]}"#, "2025-04-01").unwrap();
194 set(&c, &d1, r#"{"lots":["lot1"]}"#, "2025-03-15").unwrap();
195 let result = all(&c).unwrap();
196 assert_eq!(result.len(), 2);
197 let (sel1, at1) = result.get(&d1).expect("d1 must be present");
198 assert_eq!(sel1, r#"{"lots":["lot1"]}"#);
199 assert_eq!(at1, "2025-03-15");
200 let (sel2, at2) = result.get(&d2).expect("d2 must be present");
201 assert_eq!(sel2, r#"{"lots":["lot2"]}"#);
202 assert_eq!(at2, "2025-04-01");
203 let mut keys = result.keys();
205 assert_eq!(keys.next().unwrap(), &d1);
206 assert_eq!(keys.next().unwrap(), &d2);
207 }
208
209 #[test]
210 fn all_on_tableless_vault_returns_empty() {
211 let c = rusqlite::Connection::open_in_memory().unwrap(); assert!(all(&c).unwrap().is_empty());
213 }
214
215 #[test]
216 fn r2_i1_side_table_to_overlay_enforcement() {
217 use btctax_core::identity::WalletId;
222 use btctax_core::optimize::compliance_overlay;
223 use btctax_core::project::{ComplianceStatus, DisposalCompliance};
224 use time::macros::date;
225
226 let c = mem();
227 let disposal_d = eid(42);
228 let attested_sel = r#"{"lots":[{"lot":"decision|1","sat":100000}]}"#;
229
230 set(&c, &disposal_d, attested_sel, "2025-03-15").unwrap();
232
233 let attested = attested_set(&c).unwrap();
235 assert!(
236 attested.contains(&disposal_d),
237 "attested_set must reflect the stored attestation"
238 );
239
240 let wallet = WalletId::SelfCustody {
241 label: "cold".into(),
242 };
243 let row = DisposalCompliance {
244 disposal: disposal_d.clone(),
245 wallet: wallet.clone(),
246 date: date!(2026 - 06 - 01),
247 status: ComplianceStatus::NonCompliant,
248 };
249
250 let unchanged_empty = std::collections::BTreeSet::new();
253 let result_divergent =
254 compliance_overlay(std::slice::from_ref(&row), &attested, &unchanged_empty);
255 assert_eq!(
256 result_divergent[0].status,
257 ComplianceStatus::NonCompliant,
258 "R2-I1: divergent pick (D ∉ unchanged) must stay NonCompliant even when attested"
259 );
260
261 let unchanged_with_d: std::collections::BTreeSet<_> = [disposal_d.clone()].into();
264 let result_unchanged =
265 compliance_overlay(std::slice::from_ref(&row), &attested, &unchanged_with_d);
266 assert_eq!(
267 result_unchanged[0].status,
268 ComplianceStatus::AttestedRecording,
269 "positive control: attested + unchanged self-custody must upgrade to AttestedRecording"
270 );
271
272 let all_records = all(&c).unwrap();
274 assert_eq!(all_records.len(), 1);
275 let (stored_sel, stored_at) = all_records.get(&disposal_d).expect("D must be in all");
276 assert_eq!(stored_sel, attested_sel);
277 assert_eq!(stored_at, "2025-03-15");
278 }
279
280 #[test]
281 fn attested_set_reflects_all_stored_disposals() {
282 let c = mem();
283 let d_a = eid_import("TX-A");
284 let d_b = eid_import("TX-B");
285 set(&c, &d_a, "sel_a", "2025-01-01").unwrap();
286 set(&c, &d_b, "sel_b", "2025-01-02").unwrap();
287 let s = attested_set(&c).unwrap();
288 assert!(s.contains(&d_a));
289 assert!(s.contains(&d_b));
290 assert!(!s.contains(&eid(99))); }
292
293 #[test]
294 fn table_created_on_existing_conn_without_explicit_init() {
295 let c = rusqlite::Connection::open_in_memory().unwrap();
298 let disposal = eid(5);
299 set(&c, &disposal, "some_sel", "2025-05-01").unwrap();
301 assert_eq!(get(&c, &disposal).unwrap().unwrap(), "some_sel");
302 }
303}