use crate::{
crypto::{generate_secret_hex, sha256_bytes},
error::{Error, Result},
};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct WitnessSecret {
contract_id: String,
hex_value: String,
}
impl WitnessSecret {
pub fn generate(contract_id: &str) -> Self {
Self {
contract_id: contract_id.to_string(),
hex_value: generate_secret_hex(),
}
}
pub fn parse(s: &str) -> Result<Self> {
let prefix = "n:";
let mid = ":secret:";
if !s.starts_with(prefix) {
return Err(Error::InvalidFormat(format!(
"WitnessSecret must start with 'n:': {s}"
)));
}
let without_prefix = &s[prefix.len()..];
let sep_pos = without_prefix
.rfind(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
let contract_id = &without_prefix[..sep_pos];
let hex_value = &without_prefix[sep_pos + mid.len()..];
if hex_value.len() != 64 {
return Err(Error::InvalidFormat(format!(
"hex_value must be 64 chars, got {}: {s}",
hex_value.len()
)));
}
if !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::InvalidFormat(format!(
"hex_value must be hex digits: {s}"
)));
}
Ok(Self {
contract_id: contract_id.to_string(),
hex_value: hex_value.to_string(),
})
}
pub fn display(&self) -> String {
format!("n:{}:secret:{}", self.contract_id, self.hex_value)
}
pub fn public_proof(&self) -> WitnessProof {
let raw = hex::decode(&self.hex_value)
.expect("hex_value is always valid hex; generated/parsed that way");
let public_hash = sha256_bytes(&raw);
WitnessProof {
contract_id: self.contract_id.clone(),
public_hash,
}
}
pub fn contract_id(&self) -> &str {
&self.contract_id
}
pub fn hex_value(&self) -> &str {
&self.hex_value
}
}
impl std::fmt::Debug for WitnessSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WitnessSecret")
.field("contract_id", &self.contract_id)
.field("hex_value", &"[redacted]")
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WitnessProof {
pub contract_id: String,
pub public_hash: String,
}
impl WitnessProof {
pub fn parse(s: &str) -> Result<Self> {
let prefix = "n:";
let mid = ":public:";
if !s.starts_with(prefix) {
return Err(Error::InvalidFormat(format!(
"WitnessProof must start with 'n:': {s}"
)));
}
let without_prefix = &s[prefix.len()..];
let sep_pos = without_prefix
.rfind(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
let contract_id = &without_prefix[..sep_pos];
let public_hash = &without_prefix[sep_pos + mid.len()..];
if public_hash.len() != 64 {
return Err(Error::InvalidFormat(format!(
"public_hash must be 64 chars, got {}: {s}",
public_hash.len()
)));
}
Ok(Self {
contract_id: contract_id.to_string(),
public_hash: public_hash.to_string(),
})
}
pub fn display(&self) -> String {
format!("n:{}:public:{}", self.contract_id, self.public_hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContractStatus {
Issued,
Active,
Delivered,
Burned,
Refunded,
}
impl ContractStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Issued => "issued",
Self::Active => "active",
Self::Delivered => "delivered",
Self::Burned => "burned",
Self::Refunded => "refunded",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"issued" => Ok(Self::Issued),
"active" => Ok(Self::Active),
"delivered" => Ok(Self::Delivered),
"burned" => Ok(Self::Burned),
"refunded" => Ok(Self::Refunded),
_ => Err(Error::InvalidFormat(format!("unknown status: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContractType {
Service,
ProductDigital,
ProductPhysical,
}
impl ContractType {
pub fn as_str(&self) -> &str {
match self {
Self::Service => "service",
Self::ProductDigital => "product_digital",
Self::ProductPhysical => "product_physical",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"service" => Ok(Self::Service),
"product_digital" => Ok(Self::ProductDigital),
"product_physical" => Ok(Self::ProductPhysical),
_ => Err(Error::InvalidFormat(format!("unknown contract type: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Buyer,
Seller,
}
impl Role {
pub fn as_str(&self) -> &str {
match self {
Self::Buyer => "buyer",
Self::Seller => "seller",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"buyer" => Ok(Self::Buyer),
"seller" => Ok(Self::Seller),
_ => Err(Error::InvalidFormat(format!("unknown role: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract {
pub contract_id: String,
pub contract_type: ContractType,
pub status: ContractStatus,
pub witness_secret: Option<String>,
pub witness_proof: Option<String>,
pub amount_units: u64,
pub work_spec: String,
pub buyer_fingerprint: String,
pub seller_fingerprint: Option<String>,
pub reference_post: Option<String>,
pub delivery_deadline: Option<String>,
pub role: Role,
pub delivered_text: Option<String>,
pub certificate_id: Option<String>,
pub arbitration_profit_wats: Option<u64>,
pub seller_value_wats: Option<u64>,
pub created_at: String,
pub updated_at: String,
}
impl Contract {
pub fn new(
contract_id: String,
contract_type: ContractType,
amount_units: u64,
work_spec: String,
buyer_fingerprint: String,
role: Role,
) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
contract_id,
contract_type,
status: ContractStatus::Issued,
witness_secret: None,
witness_proof: None,
amount_units,
work_spec,
buyer_fingerprint,
seller_fingerprint: None,
reference_post: None,
delivery_deadline: None,
role,
delivered_text: None,
certificate_id: None,
arbitration_profit_wats: None,
seller_value_wats: None,
created_at: now.clone(),
updated_at: now,
}
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct StablecashSecret {
pub amount_units: u64,
pub contract_id: String,
hex_value: String,
}
impl StablecashSecret {
pub fn generate(amount_units: u64, contract_id: &str) -> Self {
Self {
amount_units,
contract_id: contract_id.to_string(),
hex_value: crate::crypto::generate_secret_hex(),
}
}
pub fn parse(s: &str) -> Result<Self> {
if !s.starts_with('u') {
return Err(Error::InvalidFormat(format!(
"StablecashSecret must start with 'u': {s}"
)));
}
let rest = &s[1..];
let colon1 = rest
.find(':')
.ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
let amount_str = &rest[..colon1];
let amount_units: u64 = amount_str
.parse()
.map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
let after_amount = &rest[colon1 + 1..];
let mid = ":secret:";
let sep = after_amount
.rfind(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
let contract_id = &after_amount[..sep];
let hex_value = &after_amount[sep + mid.len()..];
if hex_value.len() != 64 || !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::InvalidFormat(format!(
"hex_value must be 64 lowercase hex chars in: {s}"
)));
}
Ok(Self {
amount_units,
contract_id: contract_id.to_string(),
hex_value: hex_value.to_string(),
})
}
pub fn display(&self) -> String {
format!(
"u{}:{}:secret:{}",
self.amount_units, self.contract_id, self.hex_value
)
}
pub fn public_proof(&self) -> StablecashProof {
let raw = hex::decode(&self.hex_value).expect("always valid hex");
StablecashProof {
amount_units: self.amount_units,
contract_id: self.contract_id.clone(),
public_hash: crate::crypto::sha256_bytes(&raw),
}
}
pub fn hex_value(&self) -> &str {
&self.hex_value
}
}
impl std::fmt::Debug for StablecashSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StablecashSecret")
.field("amount_units", &self.amount_units)
.field("contract_id", &self.contract_id)
.field("hex_value", &"[redacted]")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StablecashProof {
pub amount_units: u64,
pub contract_id: String,
pub public_hash: String,
}
impl StablecashProof {
pub fn parse(s: &str) -> Result<Self> {
if !s.starts_with('u') {
return Err(Error::InvalidFormat(format!(
"StablecashProof must start with 'u': {s}"
)));
}
let rest = &s[1..];
let colon1 = rest
.find(':')
.ok_or_else(|| Error::InvalidFormat(format!("missing first ':' in: {s}")))?;
let amount_units: u64 = rest[..colon1]
.parse()
.map_err(|_| Error::InvalidFormat(format!("invalid amount in: {s}")))?;
let after_amount = &rest[colon1 + 1..];
let mid = ":public:";
let sep = after_amount
.rfind(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
let contract_id = &after_amount[..sep];
let public_hash = &after_amount[sep + mid.len()..];
if public_hash.len() != 64 {
return Err(Error::InvalidFormat(format!(
"public_hash must be 64 chars in: {s}"
)));
}
Ok(Self {
amount_units,
contract_id: contract_id.to_string(),
public_hash: public_hash.to_string(),
})
}
pub fn display(&self) -> String {
format!(
"u{}:{}:public:{}",
self.amount_units, self.contract_id, self.public_hash
)
}
}
const VOUCHER_ATOMIC_PER_CREDIT: u64 = 100_000_000;
fn voucher_parse_decimal(amount_str: &str) -> Result<u64> {
if let Some(dot_pos) = amount_str.find('.') {
let int_part = &amount_str[..dot_pos];
let frac_part = &amount_str[dot_pos + 1..];
if frac_part.is_empty() {
return Err(Error::InvalidFormat(format!(
"trailing dot with no decimals: {amount_str}"
)));
}
if frac_part.len() > 8 {
return Err(Error::InvalidFormat(format!(
"too many decimal places (max 8): {amount_str}"
)));
}
let int_val: u64 = if int_part.is_empty() {
0
} else {
int_part
.parse()
.map_err(|_| Error::InvalidFormat(format!("invalid integer part: {amount_str}")))?
};
let padded = format!("{:0<8}", frac_part);
let frac_val: u64 = padded
.parse()
.map_err(|_| Error::InvalidFormat(format!("invalid fractional part: {amount_str}")))?;
let total = int_val
.checked_mul(VOUCHER_ATOMIC_PER_CREDIT)
.and_then(|v| v.checked_add(frac_val))
.ok_or_else(|| Error::InvalidFormat(format!("amount overflow: {amount_str}")))?;
Ok(total)
} else {
let int_val: u64 = amount_str
.parse()
.map_err(|_| Error::InvalidFormat(format!("invalid amount: {amount_str}")))?;
int_val
.checked_mul(VOUCHER_ATOMIC_PER_CREDIT)
.ok_or_else(|| Error::InvalidFormat(format!("amount overflow: {amount_str}")))
}
}
pub fn voucher_format_decimal(atomic: u64) -> String {
let whole = atomic / VOUCHER_ATOMIC_PER_CREDIT;
let frac = atomic % VOUCHER_ATOMIC_PER_CREDIT;
if frac == 0 {
whole.to_string()
} else {
let frac_str = format!("{:08}", frac);
let trimmed = frac_str.trim_end_matches('0');
format!("{whole}.{trimmed}")
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct VoucherSecret {
pub amount_units: u64,
hex_value: String,
}
impl VoucherSecret {
pub fn generate(amount_units: u64) -> Self {
Self {
amount_units,
hex_value: crate::crypto::generate_secret_hex(),
}
}
pub fn parse(s: &str) -> Result<Self> {
if !s.starts_with('v') {
return Err(Error::InvalidFormat(format!(
"VoucherSecret must start with 'v': {s}"
)));
}
let rest = &s[1..];
let mid = ":secret:";
let sep = rest
.find(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':secret:' in: {s}")))?;
let amount_str = &rest[..sep];
let amount_units = voucher_parse_decimal(amount_str)?;
let hex_value = &rest[sep + mid.len()..];
if hex_value.len() != 64 || !hex_value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::InvalidFormat(format!(
"hex_value must be 64 lowercase hex chars in: {s}"
)));
}
Ok(Self {
amount_units,
hex_value: hex_value.to_string(),
})
}
pub fn display(&self) -> String {
format!(
"v{}:secret:{}",
voucher_format_decimal(self.amount_units),
self.hex_value
)
}
pub fn display_amount(&self) -> String {
voucher_format_decimal(self.amount_units)
}
pub fn public_proof(&self) -> VoucherProof {
let raw = hex::decode(&self.hex_value).expect("always valid hex");
VoucherProof {
amount_units: self.amount_units,
public_hash: crate::crypto::sha256_bytes(&raw),
}
}
pub fn hex_value(&self) -> &str {
&self.hex_value
}
}
impl std::fmt::Debug for VoucherSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VoucherSecret")
.field("amount_units", &self.amount_units)
.field("amount_display", &voucher_format_decimal(self.amount_units))
.field("hex_value", &"[redacted]")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VoucherProof {
pub amount_units: u64,
pub public_hash: String,
}
impl VoucherProof {
pub fn parse(s: &str) -> Result<Self> {
if !s.starts_with('v') {
return Err(Error::InvalidFormat(format!(
"VoucherProof must start with 'v': {s}"
)));
}
let rest = &s[1..];
let mid = ":public:";
let sep = rest
.find(mid)
.ok_or_else(|| Error::InvalidFormat(format!("missing ':public:' in: {s}")))?;
let amount_units = voucher_parse_decimal(&rest[..sep])?;
let public_hash = &rest[sep + mid.len()..];
if public_hash.len() != 64 {
return Err(Error::InvalidFormat(format!(
"public_hash must be 64 chars in: {s}"
)));
}
Ok(Self {
amount_units,
public_hash: public_hash.to_string(),
})
}
pub fn display(&self) -> String {
format!(
"v{}:public:{}",
voucher_format_decimal(self.amount_units),
self.public_hash
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Certificate {
pub certificate_id: String,
pub contract_id: Option<String>,
pub witness_secret: Option<String>,
pub witness_proof: Option<String>,
pub created_at: String,
}