Skip to main content

harmoniis_wallet/
types.rs

1use crate::{
2    crypto::{generate_secret_hex, sha256_bytes},
3    error::{Error, Result},
4};
5use serde::{Deserialize, Serialize};
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8// ── WitnessSecret ─────────────────────────────────────────────────────────────
9
10/// Format: `n:{contract_id}:secret:{hex64}`
11/// Matches backend arbitration.rs and witness.rs exactly.
12#[derive(Clone, Zeroize, ZeroizeOnDrop)]
13pub struct WitnessSecret {
14    contract_id: String,
15    hex_value: String,
16}
17
18impl WitnessSecret {
19    /// Generate a fresh random secret for the given contract.
20    pub fn generate(contract_id: &str) -> Self {
21        Self {
22            contract_id: contract_id.to_string(),
23            hex_value: generate_secret_hex(),
24        }
25    }
26
27    /// Parse from `n:{contract_id}:secret:{hex64}` string.
28    pub fn parse(s: &str) -> Result<Self> {
29        // Format: n:<id>:secret:<hex64>
30        // Split on ":secret:" to handle contract_ids that contain colons
31        let prefix = "n:";
32        let mid = ":secret:";
33        if !s.starts_with(prefix) {
34            return Err(Error::InvalidFormat(format!(
35                "WitnessSecret must start with 'n:': {s}"
36            )));
37        }
38        let without_prefix = &s[prefix.len()..];
39        let sep_pos = without_prefix
40            .rfind(mid)
41            .ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
42        let contract_id = &without_prefix[..sep_pos];
43        let hex_value = &without_prefix[sep_pos + mid.len()..];
44        if hex_value.len() != 64 {
45            return Err(Error::InvalidFormat(format!(
46                "hex_value must be 64 chars, got {}: {s}",
47                hex_value.len()
48            )));
49        }
50        if !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
51            return Err(Error::InvalidFormat(format!(
52                "hex_value must be hex digits: {s}"
53            )));
54        }
55        Ok(Self {
56            contract_id: contract_id.to_string(),
57            hex_value: hex_value.to_string(),
58        })
59    }
60
61    /// Serialize to wire format: `n:{contract_id}:secret:{hex64}`
62    pub fn display(&self) -> String {
63        format!("n:{}:secret:{}", self.contract_id, self.hex_value)
64    }
65
66    /// Compute the public proof by SHA256-ing the 32 raw bytes of hex_value.
67    pub fn public_proof(&self) -> WitnessProof {
68        let raw = hex::decode(&self.hex_value)
69            .expect("hex_value is always valid hex; generated/parsed that way");
70        let public_hash = sha256_bytes(&raw);
71        WitnessProof {
72            contract_id: self.contract_id.clone(),
73            public_hash,
74        }
75    }
76
77    pub fn contract_id(&self) -> &str {
78        &self.contract_id
79    }
80
81    pub fn hex_value(&self) -> &str {
82        &self.hex_value
83    }
84}
85
86impl std::fmt::Debug for WitnessSecret {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("WitnessSecret")
89            .field("contract_id", &self.contract_id)
90            .field("hex_value", &"[redacted]")
91            .finish()
92    }
93}
94
95// ── WitnessProof ───────────────────────────────────────────────────────────────
96
97/// Format: `n:{contract_id}:public:{sha256_hex64}`
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct WitnessProof {
100    pub contract_id: String,
101    pub public_hash: String,
102}
103
104impl WitnessProof {
105    /// Parse from `n:{contract_id}:public:{hash64}` string.
106    pub fn parse(s: &str) -> Result<Self> {
107        let prefix = "n:";
108        let mid = ":public:";
109        if !s.starts_with(prefix) {
110            return Err(Error::InvalidFormat(format!(
111                "WitnessProof must start with 'n:': {s}"
112            )));
113        }
114        let without_prefix = &s[prefix.len()..];
115        let sep_pos = without_prefix
116            .rfind(mid)
117            .ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
118        let contract_id = &without_prefix[..sep_pos];
119        let public_hash = &without_prefix[sep_pos + mid.len()..];
120        if public_hash.len() != 64 {
121            return Err(Error::InvalidFormat(format!(
122                "public_hash must be 64 chars, got {}: {s}",
123                public_hash.len()
124            )));
125        }
126        Ok(Self {
127            contract_id: contract_id.to_string(),
128            public_hash: public_hash.to_string(),
129        })
130    }
131
132    /// Serialize to wire format: `n:{contract_id}:public:{hash64}`
133    pub fn display(&self) -> String {
134        format!("n:{}:public:{}", self.contract_id, self.public_hash)
135    }
136}
137
138// ── Contract ──────────────────────────────────────────────────────────────────
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum ContractStatus {
143    Issued,
144    Active,
145    Delivered,
146    Burned,
147    Refunded,
148}
149
150impl ContractStatus {
151    pub fn as_str(&self) -> &str {
152        match self {
153            Self::Issued => "issued",
154            Self::Active => "active",
155            Self::Delivered => "delivered",
156            Self::Burned => "burned",
157            Self::Refunded => "refunded",
158        }
159    }
160
161    pub fn parse(s: &str) -> Result<Self> {
162        match s {
163            "issued" => Ok(Self::Issued),
164            "active" => Ok(Self::Active),
165            "delivered" => Ok(Self::Delivered),
166            "burned" => Ok(Self::Burned),
167            "refunded" => Ok(Self::Refunded),
168            _ => Err(Error::InvalidFormat(format!("unknown status: {s}"))),
169        }
170    }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174#[serde(rename_all = "snake_case")]
175pub enum ContractType {
176    Service,
177    ProductDigital,
178    ProductPhysical,
179}
180
181impl ContractType {
182    pub fn as_str(&self) -> &str {
183        match self {
184            Self::Service => "service",
185            Self::ProductDigital => "product_digital",
186            Self::ProductPhysical => "product_physical",
187        }
188    }
189
190    pub fn parse(s: &str) -> Result<Self> {
191        match s {
192            "service" => Ok(Self::Service),
193            "product_digital" => Ok(Self::ProductDigital),
194            "product_physical" => Ok(Self::ProductPhysical),
195            _ => Err(Error::InvalidFormat(format!("unknown contract type: {s}"))),
196        }
197    }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201#[serde(rename_all = "snake_case")]
202pub enum Role {
203    Buyer,
204    Seller,
205}
206
207impl Role {
208    pub fn as_str(&self) -> &str {
209        match self {
210            Self::Buyer => "buyer",
211            Self::Seller => "seller",
212        }
213    }
214
215    pub fn parse(s: &str) -> Result<Self> {
216        match s {
217            "buyer" => Ok(Self::Buyer),
218            "seller" => Ok(Self::Seller),
219            _ => Err(Error::InvalidFormat(format!("unknown role: {s}"))),
220        }
221    }
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Contract {
226    pub contract_id: String,
227    pub contract_type: ContractType,
228    pub status: ContractStatus,
229    /// Stored as display string: `n:{id}:secret:{hex64}`
230    pub witness_secret: Option<String>,
231    /// Stored as display string: `n:{id}:public:{hash64}`
232    pub witness_proof: Option<String>,
233    pub amount_units: u64,
234    pub work_spec: String,
235    pub buyer_fingerprint: String,
236    pub seller_fingerprint: Option<String>,
237    pub reference_post: Option<String>,
238    pub delivery_deadline: Option<String>,
239    pub role: Role,
240    pub delivered_text: Option<String>,
241    pub certificate_id: Option<String>,
242    pub created_at: String,
243    pub updated_at: String,
244}
245
246impl Contract {
247    pub fn new(
248        contract_id: String,
249        contract_type: ContractType,
250        amount_units: u64,
251        work_spec: String,
252        buyer_fingerprint: String,
253        role: Role,
254    ) -> Self {
255        let now = chrono::Utc::now().to_rfc3339();
256        Self {
257            contract_id,
258            contract_type,
259            status: ContractStatus::Issued,
260            witness_secret: None,
261            witness_proof: None,
262            amount_units,
263            work_spec,
264            buyer_fingerprint,
265            seller_fingerprint: None,
266            reference_post: None,
267            delivery_deadline: None,
268            role,
269            delivered_text: None,
270            certificate_id: None,
271            created_at: now.clone(),
272            updated_at: now,
273        }
274    }
275}
276
277// ── StablecashSecret (RGB20) ──────────────────────────────────────────────────
278//
279// PHASE:  SANDBOX — not yet in production.
280// Enable via the Exchange service once the BTC ↔ USDH bridge is live.
281// RGB20 is the fungible layer; all production contracts remain RGB21.
282
283/// RGB20 fungible bearer token — split/merge allowed, sum must balance.
284///
285/// **⚠ SANDBOX ONLY** — Stablecash (USDH) is not yet in production.
286/// Use only against testnet or local backends with `--sandbox` flag.
287///
288/// Wire format: `u{amount_units}:{contract_id}:secret:{hex64}`
289/// Proof format: `u{amount_units}:{contract_id}:public:{sha256_hex64}`
290///
291/// The canonical Stablecash contract_id is `USDH_MAIN`.
292/// Amount is in integer atomic units (minimum unit is 0.00000001): 1 USDH = 100_000_000 units.
293#[derive(Clone, Zeroize, ZeroizeOnDrop)]
294pub struct StablecashSecret {
295    pub amount_units: u64,
296    pub contract_id: String,
297    hex_value: String,
298}
299
300impl StablecashSecret {
301    /// Generate a fresh random Stablecash secret.
302    pub fn generate(amount_units: u64, contract_id: &str) -> Self {
303        Self {
304            amount_units,
305            contract_id: contract_id.to_string(),
306            hex_value: crate::crypto::generate_secret_hex(),
307        }
308    }
309
310    /// Parse from `u{amount}:{contract_id}:secret:{hex64}`.
311    pub fn parse(s: &str) -> Result<Self> {
312        if !s.starts_with('u') {
313            return Err(Error::InvalidFormat(format!(
314                "StablecashSecret must start with 'u': {s}"
315            )));
316        }
317        let rest = &s[1..];
318        let colon1 = rest
319            .find(':')
320            .ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
321        let amount_str = &rest[..colon1];
322        let amount_units: u64 = amount_str
323            .parse()
324            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
325        let after_amount = &rest[colon1 + 1..];
326
327        // Now split on ":secret:" (rfind to be robust)
328        let mid = ":secret:";
329        let sep = after_amount
330            .rfind(mid)
331            .ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
332        let contract_id = &after_amount[..sep];
333        let hex_value = &after_amount[sep + mid.len()..];
334
335        if hex_value.len() != 64 || !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
336            return Err(Error::InvalidFormat(format!(
337                "hex_value must be 64 lowercase hex chars in: {s}"
338            )));
339        }
340        Ok(Self {
341            amount_units,
342            contract_id: contract_id.to_string(),
343            hex_value: hex_value.to_string(),
344        })
345    }
346
347    /// Serialize to wire format: `u{amount}:{contract_id}:secret:{hex64}`
348    pub fn display(&self) -> String {
349        format!(
350            "u{}:{}:secret:{}",
351            self.amount_units, self.contract_id, self.hex_value
352        )
353    }
354
355    /// Compute the public proof.
356    pub fn public_proof(&self) -> StablecashProof {
357        let raw = hex::decode(&self.hex_value).expect("always valid hex");
358        StablecashProof {
359            amount_units: self.amount_units,
360            contract_id: self.contract_id.clone(),
361            public_hash: crate::crypto::sha256_bytes(&raw),
362        }
363    }
364
365    pub fn hex_value(&self) -> &str {
366        &self.hex_value
367    }
368}
369
370impl std::fmt::Debug for StablecashSecret {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        f.debug_struct("StablecashSecret")
373            .field("amount_units", &self.amount_units)
374            .field("contract_id", &self.contract_id)
375            .field("hex_value", &"[redacted]")
376            .finish()
377    }
378}
379
380/// RGB20 public proof — `u{amount}:{contract_id}:public:{sha256_hex64}`
381///
382/// **⚠ SANDBOX ONLY** — see [`StablecashSecret`].
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct StablecashProof {
385    pub amount_units: u64,
386    pub contract_id: String,
387    pub public_hash: String,
388}
389
390impl StablecashProof {
391    pub fn parse(s: &str) -> Result<Self> {
392        if !s.starts_with('u') {
393            return Err(Error::InvalidFormat(format!(
394                "StablecashProof must start with 'u': {s}"
395            )));
396        }
397        let rest = &s[1..];
398        let colon1 = rest
399            .find(':')
400            .ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
401        let amount_units: u64 = rest[..colon1]
402            .parse()
403            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
404        let after_amount = &rest[colon1 + 1..];
405        let mid = ":public:";
406        let sep = after_amount
407            .rfind(mid)
408            .ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
409        let contract_id = &after_amount[..sep];
410        let public_hash = &after_amount[sep + mid.len()..];
411        if public_hash.len() != 64 {
412            return Err(Error::InvalidFormat(format!(
413                "public_hash must be 64 chars in: {s}"
414            )));
415        }
416        Ok(Self {
417            amount_units,
418            contract_id: contract_id.to_string(),
419            public_hash: public_hash.to_string(),
420        })
421    }
422
423    pub fn display(&self) -> String {
424        format!(
425            "u{}:{}:public:{}",
426            self.amount_units, self.contract_id, self.public_hash
427        )
428    }
429}
430
431// ── Certificate ───────────────────────────────────────────────────────────────
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct Certificate {
435    pub certificate_id: String,
436    pub contract_id: Option<String>,
437    /// Stored as display string
438    pub witness_secret: Option<String>,
439    pub witness_proof: Option<String>,
440    pub created_at: String,
441}