Skip to main content

webylib/
webcash.rs

1//! Webcash types and serialization
2//!
3//! This module contains the core Webcash data structures:
4//! - `SecretWebcash`: Contains the secret value and amount
5//! - `PublicWebcash`: Contains the public hash and amount
6//!
7//! Both types support serialization to/from the standard Webcash string format.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::amount::Amount;
16use crate::error::{Error, Result};
17
18/// UTF-8 byte length of the ₩ symbol
19const WEBCASH_SYMBOL_BYTES: usize = 3;
20
21/// Secure string type for sensitive data with zeroize-on-drop
22#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct SecureString(Vec<u8>);
24
25impl SecureString {
26    /// Create a new SecureString from a string
27    pub fn new<S: Into<String>>(s: S) -> Self {
28        let string = s.into();
29        let bytes = string.into_bytes();
30        SecureString(bytes)
31    }
32
33    /// Create a new SecureString from bytes
34    pub fn from_bytes(bytes: Vec<u8>) -> Self {
35        SecureString(bytes)
36    }
37
38    /// Get the string value (use with caution - data remains in memory)
39    pub fn as_str(&self) -> std::result::Result<&str, std::str::Utf8Error> {
40        std::str::from_utf8(&self.0)
41    }
42
43    /// Get the raw bytes (use with caution)
44    pub fn as_bytes(&self) -> &[u8] {
45        &self.0
46    }
47
48    /// Get the length
49    pub fn len(&self) -> usize {
50        self.0.len()
51    }
52
53    /// Check if empty
54    pub fn is_empty(&self) -> bool {
55        self.0.is_empty()
56    }
57
58    /// Convert to hex string
59    pub fn to_hex(&self) -> String {
60        hex::encode(&self.0)
61    }
62}
63
64impl fmt::Debug for SecureString {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "SecureString([redacted, {} bytes])", self.0.len())
67    }
68}
69
70impl fmt::Display for SecureString {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "[redacted]")
73    }
74}
75
76impl Drop for SecureString {
77    fn drop(&mut self) {
78        // Secure zeroization of sensitive data
79        self.0.iter_mut().for_each(|byte| *byte = 0);
80    }
81}
82
83/// Secret Webcash containing the actual secret value
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SecretWebcash {
86    /// The secret value (32 bytes hex)
87    pub secret: SecureString,
88    /// The amount
89    pub amount: Amount,
90}
91
92impl SecretWebcash {
93    /// Create a new SecretWebcash
94    pub fn new(secret: SecureString, amount: Amount) -> Self {
95        SecretWebcash { secret, amount }
96    }
97
98    /// Parse from Webcash string format
99    pub fn parse(s: &str) -> Result<Self> {
100        let s = s.trim();
101        if !s.starts_with('e') {
102            return Err(Error::parse("SecretWebcash must start with 'e'"));
103        }
104
105        let parts: Vec<&str> = s[1..].split(':').collect();
106        if parts.len() < 3 {
107            return Err(Error::parse("Invalid SecretWebcash format"));
108        }
109
110        if parts[1] != "secret" {
111            return Err(Error::parse("Expected 'secret' type"));
112        }
113
114        let amount_str = parts[0];
115        let amount = Amount::from_str(amount_str)?;
116
117        let secret = parts[2..].join(":");
118
119        // Validate secret is valid hex and 32 bytes
120        if secret.len() != 64 {
121            return Err(Error::parse("Secret must be 64 hex characters (32 bytes)"));
122        }
123
124        hex::decode(&secret).map_err(|_| Error::parse("Secret must be valid hex"))?;
125
126        Ok(SecretWebcash {
127            secret: SecureString::new(secret),
128            amount,
129        })
130    }
131
132    /// Convert to PublicWebcash
133    /// CRITICAL: Hash must match Python implementation exactly
134    /// Python: hashlib.sha256(bytes(str(secret_value), "ascii")).hexdigest()
135    /// This means we hash the secret STRING as ASCII bytes, NOT the hex-decoded bytes
136    pub fn to_public(&self) -> PublicWebcash {
137        let secret_str = self.secret.as_str().unwrap_or("");
138        // Hash the ASCII string representation of the secret (matches Python implementation)
139        // Python does: hashlib.sha256(bytes(str(secret_value), "ascii")).hexdigest()
140        let hash = Sha256::digest(secret_str.as_bytes());
141        PublicWebcash {
142            hash: hash.into(),
143            amount: self.amount,
144        }
145    }
146
147    /// Serialize to Webcash string format
148    /// Webcash strings use DECIMAL format for amounts (e.g., e0.0001:secret:...)
149    /// The amount prefix is the decimal representation, not wats
150    pub fn to_webcash_string(&self) -> String {
151        let secret_str = self.secret.as_str().unwrap_or("");
152        // Use decimal format (Display trait formats as decimal)
153        format!("e{}:secret:{}", self.amount, secret_str)
154    }
155}
156
157impl fmt::Display for SecretWebcash {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "{}", self.to_webcash_string())
160    }
161}
162
163impl FromStr for SecretWebcash {
164    type Err = Error;
165
166    fn from_str(s: &str) -> Result<Self> {
167        Self::parse(s)
168    }
169}
170
171/// Public Webcash containing the hash of the secret
172#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
173pub struct PublicWebcash {
174    /// SHA256 hash of the secret (32 bytes)
175    pub hash: [u8; 32],
176    /// The amount
177    pub amount: Amount,
178}
179
180impl PublicWebcash {
181    /// Create a new PublicWebcash
182    pub fn new(hash: [u8; 32], amount: Amount) -> Self {
183        PublicWebcash { hash, amount }
184    }
185
186    /// Parse from Webcash string format
187    pub fn parse(s: &str) -> Result<Self> {
188        let s = s.trim();
189
190        // Handle different prefixes
191        let s = if let Some(stripped) = s.strip_prefix('e') {
192            stripped
193        } else if s.starts_with('₩') {
194            &s[WEBCASH_SYMBOL_BYTES..]
195        } else {
196            s
197        };
198
199        let parts: Vec<&str> = s.split(':').collect();
200        if parts.len() != 3 {
201            return Err(Error::parse("Invalid PublicWebcash format"));
202        }
203
204        if parts[1] != "public" {
205            return Err(Error::parse("Expected 'public' type"));
206        }
207
208        let amount_str = parts[0];
209        let amount = Amount::from_str(amount_str)?;
210
211        let hash_str = parts[2];
212        if hash_str.len() != 64 {
213            return Err(Error::parse("Hash must be 64 hex characters (32 bytes)"));
214        }
215
216        let hash_bytes =
217            hex::decode(hash_str).map_err(|_| Error::parse("Hash must be valid hex"))?;
218
219        let mut hash = [0u8; 32];
220        hash.copy_from_slice(&hash_bytes);
221
222        Ok(PublicWebcash { hash, amount })
223    }
224
225    /// Get the hash as a hex string
226    pub fn hash_hex(&self) -> String {
227        hex::encode(self.hash)
228    }
229
230    /// Serialize to Webcash string format
231    /// Webcash strings use DECIMAL format for amounts (e.g., e0.0001:public:...)
232    /// The amount prefix is the decimal representation, not wats
233    pub fn to_webcash_string(&self) -> String {
234        // Use decimal format (Display trait formats as decimal)
235        format!("e{}:public:{}", self.amount, self.hash_hex())
236    }
237}
238
239impl fmt::Display for PublicWebcash {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        write!(f, "{}", self.to_webcash_string())
242    }
243}
244
245impl FromStr for PublicWebcash {
246    type Err = Error;
247
248    fn from_str(s: &str) -> Result<Self> {
249        Self::parse(s)
250    }
251}
252
253impl From<&SecretWebcash> for PublicWebcash {
254    fn from(secret: &SecretWebcash) -> Self {
255        secret.to_public()
256    }
257}