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 arbitration_profit_wats: Option<u64>,
243    pub seller_value_wats: Option<u64>,
244    pub created_at: String,
245    pub updated_at: String,
246}
247
248impl Contract {
249    pub fn new(
250        contract_id: String,
251        contract_type: ContractType,
252        amount_units: u64,
253        work_spec: String,
254        buyer_fingerprint: String,
255        role: Role,
256    ) -> Self {
257        let now = chrono::Utc::now().to_rfc3339();
258        Self {
259            contract_id,
260            contract_type,
261            status: ContractStatus::Issued,
262            witness_secret: None,
263            witness_proof: None,
264            amount_units,
265            work_spec,
266            buyer_fingerprint,
267            seller_fingerprint: None,
268            reference_post: None,
269            delivery_deadline: None,
270            role,
271            delivered_text: None,
272            certificate_id: None,
273            arbitration_profit_wats: None,
274            seller_value_wats: None,
275            created_at: now.clone(),
276            updated_at: now,
277        }
278    }
279}
280
281// ── StablecashSecret (RGB20) ──────────────────────────────────────────────────
282//
283// PHASE:  SANDBOX — not yet in production.
284// Enable via the Exchange service once the BTC ↔ USDH bridge is live.
285// RGB20 is the fungible layer; all production contracts remain RGB21.
286
287/// RGB20 fungible bearer token — split/merge allowed, sum must balance.
288///
289/// **⚠ SANDBOX ONLY** — Stablecash (USDH) is not yet in production.
290/// Use only against testnet or local backends with `--sandbox` flag.
291///
292/// Wire format: `u{amount_units}:{contract_id}:secret:{hex64}`
293/// Proof format: `u{amount_units}:{contract_id}:public:{sha256_hex64}`
294///
295/// The canonical Stablecash contract_id is `USDH_MAIN`.
296/// Amount is in integer atomic units (minimum unit is 0.00000001): 1 USDH = 100_000_000 units.
297#[derive(Clone, Zeroize, ZeroizeOnDrop)]
298pub struct StablecashSecret {
299    pub amount_units: u64,
300    pub contract_id: String,
301    hex_value: String,
302}
303
304impl StablecashSecret {
305    /// Generate a fresh random Stablecash secret.
306    pub fn generate(amount_units: u64, contract_id: &str) -> Self {
307        Self {
308            amount_units,
309            contract_id: contract_id.to_string(),
310            hex_value: crate::crypto::generate_secret_hex(),
311        }
312    }
313
314    /// Parse from `u{amount}:{contract_id}:secret:{hex64}`.
315    pub fn parse(s: &str) -> Result<Self> {
316        if !s.starts_with('u') {
317            return Err(Error::InvalidFormat(format!(
318                "StablecashSecret must start with 'u': {s}"
319            )));
320        }
321        let rest = &s[1..];
322        let colon1 = rest
323            .find(':')
324            .ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
325        let amount_str = &rest[..colon1];
326        let amount_units: u64 = amount_str
327            .parse()
328            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
329        let after_amount = &rest[colon1 + 1..];
330
331        // Now split on ":secret:" (rfind to be robust)
332        let mid = ":secret:";
333        let sep = after_amount
334            .rfind(mid)
335            .ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
336        let contract_id = &after_amount[..sep];
337        let hex_value = &after_amount[sep + mid.len()..];
338
339        if hex_value.len() != 64 || !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
340            return Err(Error::InvalidFormat(format!(
341                "hex_value must be 64 lowercase hex chars in: {s}"
342            )));
343        }
344        Ok(Self {
345            amount_units,
346            contract_id: contract_id.to_string(),
347            hex_value: hex_value.to_string(),
348        })
349    }
350
351    /// Serialize to wire format: `u{amount}:{contract_id}:secret:{hex64}`
352    pub fn display(&self) -> String {
353        format!(
354            "u{}:{}:secret:{}",
355            self.amount_units, self.contract_id, self.hex_value
356        )
357    }
358
359    /// Compute the public proof.
360    pub fn public_proof(&self) -> StablecashProof {
361        let raw = hex::decode(&self.hex_value).expect("always valid hex");
362        StablecashProof {
363            amount_units: self.amount_units,
364            contract_id: self.contract_id.clone(),
365            public_hash: crate::crypto::sha256_bytes(&raw),
366        }
367    }
368
369    pub fn hex_value(&self) -> &str {
370        &self.hex_value
371    }
372}
373
374impl std::fmt::Debug for StablecashSecret {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        f.debug_struct("StablecashSecret")
377            .field("amount_units", &self.amount_units)
378            .field("contract_id", &self.contract_id)
379            .field("hex_value", &"[redacted]")
380            .finish()
381    }
382}
383
384/// RGB20 public proof — `u{amount}:{contract_id}:public:{sha256_hex64}`
385///
386/// **⚠ SANDBOX ONLY** — see [`StablecashSecret`].
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct StablecashProof {
389    pub amount_units: u64,
390    pub contract_id: String,
391    pub public_hash: String,
392}
393
394impl StablecashProof {
395    pub fn parse(s: &str) -> Result<Self> {
396        if !s.starts_with('u') {
397            return Err(Error::InvalidFormat(format!(
398                "StablecashProof must start with 'u': {s}"
399            )));
400        }
401        let rest = &s[1..];
402        let colon1 = rest
403            .find(':')
404            .ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
405        let amount_units: u64 = rest[..colon1]
406            .parse()
407            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
408        let after_amount = &rest[colon1 + 1..];
409        let mid = ":public:";
410        let sep = after_amount
411            .rfind(mid)
412            .ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
413        let contract_id = &after_amount[..sep];
414        let public_hash = &after_amount[sep + mid.len()..];
415        if public_hash.len() != 64 {
416            return Err(Error::InvalidFormat(format!(
417                "public_hash must be 64 chars in: {s}"
418            )));
419        }
420        Ok(Self {
421            amount_units,
422            contract_id: contract_id.to_string(),
423            public_hash: public_hash.to_string(),
424        })
425    }
426
427    pub fn display(&self) -> String {
428        format!(
429            "u{}:{}:public:{}",
430            self.amount_units, self.contract_id, self.public_hash
431        )
432    }
433}
434
435// ── VoucherSecret ────────────────────────────────────────────────────────────
436
437/// Prepaid credit bearer token — split/merge via replace endpoint.
438///
439/// Wire format: `v{amount}:secret:{hex64}`
440/// Proof format: `v{amount}:public:{sha256_hex64}`
441///
442/// Amount is in integer credits (1 credit = $1).
443#[derive(Clone, Zeroize, ZeroizeOnDrop)]
444pub struct VoucherSecret {
445    pub amount_units: u64,
446    hex_value: String,
447}
448
449impl VoucherSecret {
450    /// Generate a fresh random Voucher secret.
451    pub fn generate(amount_units: u64) -> Self {
452        Self {
453            amount_units,
454            hex_value: crate::crypto::generate_secret_hex(),
455        }
456    }
457
458    /// Parse from `v{amount}:secret:{hex64}`.
459    pub fn parse(s: &str) -> Result<Self> {
460        if !s.starts_with('v') {
461            return Err(Error::InvalidFormat(format!(
462                "VoucherSecret must start with 'v': {s}"
463            )));
464        }
465        let rest = &s[1..];
466        let mid = ":secret:";
467        let sep = rest
468            .find(mid)
469            .ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
470        let amount_str = &rest[..sep];
471        let amount_units: u64 = amount_str
472            .parse()
473            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
474        let hex_value = &rest[sep + mid.len()..];
475        if hex_value.len() != 64 || !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
476            return Err(Error::InvalidFormat(format!(
477                "hex_value must be 64 lowercase hex chars in: {s}"
478            )));
479        }
480        Ok(Self {
481            amount_units,
482            hex_value: hex_value.to_string(),
483        })
484    }
485
486    /// Serialize to wire format: `v{amount}:secret:{hex64}`
487    pub fn display(&self) -> String {
488        format!("v{}:secret:{}", self.amount_units, self.hex_value)
489    }
490
491    /// Compute the public proof.
492    pub fn public_proof(&self) -> VoucherProof {
493        let raw = hex::decode(&self.hex_value).expect("always valid hex");
494        VoucherProof {
495            amount_units: self.amount_units,
496            public_hash: crate::crypto::sha256_bytes(&raw),
497        }
498    }
499
500    pub fn hex_value(&self) -> &str {
501        &self.hex_value
502    }
503}
504
505impl std::fmt::Debug for VoucherSecret {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("VoucherSecret")
508            .field("amount_units", &self.amount_units)
509            .field("hex_value", &"[redacted]")
510            .finish()
511    }
512}
513
514/// Voucher public proof — `v{amount}:public:{sha256_hex64}`
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct VoucherProof {
517    pub amount_units: u64,
518    pub public_hash: String,
519}
520
521impl VoucherProof {
522    pub fn parse(s: &str) -> Result<Self> {
523        if !s.starts_with('v') {
524            return Err(Error::InvalidFormat(format!(
525                "VoucherProof must start with 'v': {s}"
526            )));
527        }
528        let rest = &s[1..];
529        let mid = ":public:";
530        let sep = rest
531            .find(mid)
532            .ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
533        let amount_units: u64 = rest[..sep]
534            .parse()
535            .map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
536        let public_hash = &rest[sep + mid.len()..];
537        if public_hash.len() != 64 {
538            return Err(Error::InvalidFormat(format!(
539                "public_hash must be 64 chars in: {s}"
540            )));
541        }
542        Ok(Self {
543            amount_units,
544            public_hash: public_hash.to_string(),
545        })
546    }
547
548    pub fn display(&self) -> String {
549        format!("v{}:public:{}", self.amount_units, self.public_hash)
550    }
551}
552
553// ── Certificate ───────────────────────────────────────────────────────────────
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct Certificate {
557    pub certificate_id: String,
558    pub contract_id: Option<String>,
559    /// Stored as display string
560    pub witness_secret: Option<String>,
561    pub witness_proof: Option<String>,
562    pub created_at: String,
563}