Skip to main content

btctax_core/
identity.rs

1//! Stable identity & canonical ordering (§6.2). EventId is a STRUCTURED (injective) function of its
2//! components — no hashing needed for identity; only the content *fingerprint* (conflict detection) hashes.
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6/// The four supported venues. Fixed source priority for same-instant fold ties (§6.2): Swan>Coinbase>Gemini>River.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8pub enum Source {
9    Swan,
10    Coinbase,
11    Gemini,
12    River,
13}
14impl Source {
15    /// Lower = folds first at the same `utc_timestamp` (Swan=0 … River=3).
16    pub fn priority(self) -> u8 {
17        match self {
18            Source::Swan => 0,
19            Source::Coinbase => 1,
20            Source::Gemini => 2,
21            Source::River => 3,
22        }
23    }
24    pub fn tag(self) -> &'static str {
25        match self {
26            Source::Swan => "swan",
27            Source::Coinbase => "coinbase",
28            Source::Gemini => "gemini",
29            Source::River => "river",
30        }
31    }
32}
33
34/// Stable real-world-row identity scoped by (source, direction) (§6.2). Opaque string assigned by adapters.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct SourceRef(pub String);
37impl SourceRef {
38    pub fn new(s: impl Into<String>) -> Self {
39        SourceRef(s.into())
40    }
41}
42
43/// SHA-256 hex of canonical content; used ONLY for conflict detection (§6.2/§9).
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
45pub struct Fingerprint(pub String);
46impl Fingerprint {
47    pub fn of_bytes(bytes: &[u8]) -> Self {
48        let mut h = Sha256::new();
49        h.update(bytes);
50        Fingerprint(format!("{:x}", h.finalize()))
51    }
52}
53
54/// Universal reference target (§6.2). Equality is what matters; we also derive Ord/Hash for map keys + stable output.
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
56pub enum EventId {
57    /// Imported events: f(source, source_ref) — survives cosmetic re-exports/corrections.
58    Import {
59        source: Source,
60        source_ref: SourceRef,
61    },
62    /// System ImportConflict: f("conflict", source, source_ref, new_fingerprint) — distinct from its target.
63    Conflict {
64        source: Source,
65        source_ref: SourceRef,
66        fingerprint: Fingerprint,
67    },
68    /// App-generated decisions: f("decision", decision_seq).
69    Decision { seq: u64 },
70}
71impl EventId {
72    pub fn import(source: Source, source_ref: SourceRef) -> Self {
73        EventId::Import { source, source_ref }
74    }
75    pub fn conflict(source: Source, source_ref: SourceRef, fingerprint: &Fingerprint) -> Self {
76        EventId::Conflict {
77            source,
78            source_ref,
79            fingerprint: fingerprint.clone(),
80        }
81    }
82    pub fn decision(seq: u64) -> Self {
83        EventId::Decision { seq }
84    }
85    /// Stable string form for the persistence `event_id` column (components are also stored separately).
86    pub fn canonical(&self) -> String {
87        match self {
88            EventId::Import { source, source_ref } => {
89                format!("import|{}|{}", source.tag(), source_ref.0)
90            }
91            EventId::Conflict {
92                source,
93                source_ref,
94                fingerprint,
95            } => {
96                format!(
97                    "conflict|{}|{}|{}",
98                    source.tag(),
99                    source_ref.0,
100                    fingerprint.0
101                )
102            }
103            EventId::Decision { seq } => format!("decision|{seq}"),
104        }
105    }
106}
107
108/// Basis pool identity (§6.3).
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
110pub enum WalletId {
111    Exchange { provider: String, account: String },
112    SelfCustody { label: String },
113}
114
115/// Lot identity (§6.2): origin event + a per-origin split sequence, assigned deterministically as lots split.
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117pub struct LotId {
118    pub origin_event_id: EventId,
119    pub split_sequence: u32,
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn source_priority_is_swan_first_river_last() {
128        let mut v = [
129            Source::River,
130            Source::Coinbase,
131            Source::Swan,
132            Source::Gemini,
133        ];
134        v.sort_by_key(|s| s.priority());
135        assert_eq!(
136            v,
137            [
138                Source::Swan,
139                Source::Coinbase,
140                Source::Gemini,
141                Source::River
142            ]
143        );
144    }
145
146    #[test]
147    fn import_event_id_is_stable_function_of_source_and_ref() {
148        let a = EventId::import(Source::Coinbase, SourceRef::new("ID-1"));
149        let b = EventId::import(Source::Coinbase, SourceRef::new("ID-1"));
150        assert_eq!(a, b);
151        assert_eq!(a.canonical(), "import|coinbase|ID-1");
152    }
153
154    #[test]
155    fn conflict_event_id_is_distinct_from_its_target() {
156        let target = EventId::import(Source::Gemini, SourceRef::new("T1"));
157        let fp = Fingerprint::of_bytes(b"new-content");
158        let c1 = EventId::conflict(Source::Gemini, SourceRef::new("T1"), &fp);
159        let c2 = EventId::conflict(Source::Gemini, SourceRef::new("T1"), &fp);
160        assert_ne!(EventId::import(Source::Gemini, SourceRef::new("T1")), c1);
161        assert_eq!(c1, c2); // re-importing the identical changed row reproduces the same conflict id (§6.2)
162        let _ = target;
163    }
164
165    #[test]
166    fn decision_event_id_is_function_of_seq() {
167        assert_eq!(EventId::decision(7).canonical(), "decision|7");
168        assert_ne!(EventId::decision(7), EventId::decision(8));
169    }
170
171    #[test]
172    fn lot_id_is_origin_plus_split_sequence() {
173        let origin = EventId::import(Source::Swan, SourceRef::new("TX9"));
174        let l0 = LotId {
175            origin_event_id: origin.clone(),
176            split_sequence: 0,
177        };
178        let l1 = LotId {
179            origin_event_id: origin,
180            split_sequence: 1,
181        };
182        assert_ne!(l0, l1);
183        assert!(l0 < l1); // deterministic ordering for stable output
184    }
185}