1use crate::{
2 crypto::{generate_secret_hex, sha256_bytes},
3 error::{Error, Result},
4};
5use serde::{Deserialize, Serialize};
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8#[derive(Clone, Zeroize, ZeroizeOnDrop)]
13pub struct WitnessSecret {
14 contract_id: String,
15 hex_value: String,
16}
17
18impl WitnessSecret {
19 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 pub fn parse(s: &str) -> Result<Self> {
29 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 pub fn display(&self) -> String {
63 format!("n:{}:secret:{}", self.contract_id, self.hex_value)
64 }
65
66 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#[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 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 pub fn display(&self) -> String {
134 format!("n:{}:public:{}", self.contract_id, self.public_hash)
135 }
136}
137
138#[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 pub witness_secret: Option<String>,
231 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#[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 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 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 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 pub fn display(&self) -> String {
353 format!(
354 "u{}:{}:secret:{}",
355 self.amount_units, self.contract_id, self.hex_value
356 )
357 }
358
359 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#[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#[derive(Clone, Zeroize, ZeroizeOnDrop)]
444pub struct VoucherSecret {
445 pub amount_units: u64,
446 hex_value: String,
447}
448
449impl VoucherSecret {
450 pub fn generate(amount_units: u64) -> Self {
452 Self {
453 amount_units,
454 hex_value: crate::crypto::generate_secret_hex(),
455 }
456 }
457
458 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 pub fn display(&self) -> String {
488 format!("v{}:secret:{}", self.amount_units, self.hex_value)
489 }
490
491 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct Certificate {
557 pub certificate_id: String,
558 pub contract_id: Option<String>,
559 pub witness_secret: Option<String>,
561 pub witness_proof: Option<String>,
562 pub created_at: String,
563}