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 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#[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 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 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 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 pub fn display(&self) -> String {
349 format!(
350 "u{}:{}:secret:{}",
351 self.amount_units, self.contract_id, self.hex_value
352 )
353 }
354
355 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct Certificate {
435 pub certificate_id: String,
436 pub contract_id: Option<String>,
437 pub witness_secret: Option<String>,
439 pub witness_proof: Option<String>,
440 pub created_at: String,
441}