Skip to main content

ves_stark_primitives/
commerce_intent.rs

1//! Canonical commerce intent and authorization receipt primitives.
2//!
3//! These types define the stable, hashable contract for delegated agentic
4//! commerce. They are intentionally proof-system agnostic so the prover,
5//! verifier, sequencer, and anchoring layers can all bind to the same intent.
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use uuid::Uuid;
10
11use crate::hash::Hash256;
12use crate::public_inputs::canonical_json;
13use crate::rescue::rescue_hash;
14use crate::{felt_from_u64, FELT_ZERO};
15
16/// Domain separator for commerce intent hashing.
17pub const DOMAIN_COMMERCE_INTENT_HASH: &[u8] = b"STATESET_VES_COMMERCE_INTENT_HASH_V1";
18
19/// Domain separator for authorization receipt hashing.
20pub const DOMAIN_COMMERCE_AUTHORIZATION_RECEIPT_HASH: &[u8] =
21    b"STATESET_VES_COMMERCE_AUTHORIZATION_RECEIPT_HASH_V1";
22
23/// Errors that can occur when handling commerce intents and receipts.
24#[derive(Debug, Error)]
25pub enum CommerceIntentError {
26    /// A field failed validation.
27    #[error("Invalid {field}: {reason}")]
28    InvalidField { field: &'static str, reason: String },
29    /// JSON serialization failed.
30    #[error("JSON serialization failed: {0}")]
31    Serialization(#[from] serde_json::Error),
32    /// Canonicalization failed.
33    #[error("JCS canonicalization failed: {0}")]
34    Canonicalization(String),
35    /// The execution exceeded the authorized amount.
36    #[error("Execution amount {amount} exceeds max_total {max_total}")]
37    AmountExceedsLimit { amount: u64, max_total: u64 },
38    /// The execution happened after the intent expired.
39    #[error("Execution time {executed_at} exceeds intent expiry {expires_at}")]
40    IntentExpired { executed_at: u64, expires_at: u64 },
41    /// The execution currency does not match the intent currency.
42    #[error("Currency mismatch: expected {expected}, got {actual}")]
43    CurrencyMismatch { expected: String, actual: String },
44    /// The execution merchant does not match the authorized merchant.
45    #[error("Merchant mismatch: expected {expected}, got {actual}")]
46    MerchantMismatch { expected: String, actual: String },
47    /// The execution payee does not match the authorized payee.
48    #[error("Payee mismatch: expected {expected}, got {actual}")]
49    PayeeMismatch { expected: String, actual: String },
50    /// The execution shipping country does not match the authorized country.
51    #[error("Shipping country mismatch: expected {expected}, got {actual}")]
52    ShippingCountryMismatch { expected: String, actual: String },
53    /// An executed SKU is outside the authorized scope.
54    #[error("SKU '{sku}' is not in the authorized allowlist")]
55    UnauthorizedSku { sku: String },
56    /// An executed category is outside the authorized scope.
57    #[error("Category '{category}' is not in the authorized allowlist")]
58    UnauthorizedCategory { category: String },
59}
60
61/// Canonical delegated commerce intent.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "camelCase")]
64pub struct CommerceIntent {
65    /// Unique intent identifier.
66    pub intent_id: Uuid,
67    /// Tenant that delegated the intent.
68    pub tenant_id: Uuid,
69    /// Store context for the delegated intent.
70    pub store_id: Uuid,
71    /// Agent identifier acting under the delegation.
72    pub agent_id: Uuid,
73    /// Delegation or session identifier for auditing.
74    pub delegation_id: Uuid,
75    /// ISO 4217 currency code.
76    pub currency: String,
77    /// Maximum amount the agent may spend.
78    pub max_total: u64,
79    /// Optional fixed merchant binding.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub merchant: Option<String>,
82    /// Optional fixed payee binding.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub payee: Option<String>,
85    /// Optional SKU allowlist. Empty means unrestricted.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub allowed_skus: Vec<String>,
88    /// Optional category allowlist. Empty means unrestricted.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub allowed_categories: Vec<String>,
91    /// Optional shipping country binding.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub shipping_country: Option<String>,
94    /// Unix timestamp after which the intent is invalid.
95    pub expires_at: u64,
96    /// Replay-protection nonce, canonicalized as 32-byte lowercase hex.
97    pub nonce: String,
98}
99
100/// Concrete commerce execution to validate against an intent.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "camelCase")]
103pub struct CommerceExecution {
104    /// Event identifier for the executed order/payment.
105    pub event_id: Uuid,
106    /// Sequencer or order sequence number.
107    pub sequence_number: u64,
108    /// Executed currency code.
109    pub currency: String,
110    /// Executed amount.
111    pub amount: u64,
112    /// Merchant receiving the payment.
113    pub merchant: String,
114    /// Payee receiving the funds.
115    pub payee: String,
116    /// Executed SKU set.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub sku_ids: Vec<String>,
119    /// Executed category set.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub category_ids: Vec<String>,
122    /// Optional shipping country for the execution.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub shipping_country: Option<String>,
125    /// Unix timestamp at execution time.
126    pub executed_at: u64,
127}
128
129/// Domain-separated receipt binding an execution to a delegated commerce intent.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct CommerceAuthorizationReceipt {
133    /// Intent identifier.
134    pub intent_id: Uuid,
135    /// Tenant identifier.
136    pub tenant_id: Uuid,
137    /// Store identifier.
138    pub store_id: Uuid,
139    /// Agent identifier.
140    pub agent_id: Uuid,
141    /// Delegation identifier.
142    pub delegation_id: Uuid,
143    /// Intent nonce.
144    pub nonce: String,
145    /// Intent expiry timestamp.
146    pub expires_at: u64,
147    /// Executed event identifier.
148    pub event_id: Uuid,
149    /// Executed sequence number.
150    pub sequence_number: u64,
151    /// Executed currency.
152    pub currency: String,
153    /// Executed amount.
154    pub amount: u64,
155    /// Executed merchant.
156    pub merchant: String,
157    /// Executed payee.
158    pub payee: String,
159    /// Executed SKU set.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub sku_ids: Vec<String>,
162    /// Executed category set.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub category_ids: Vec<String>,
165    /// Executed shipping country.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub shipping_country: Option<String>,
168    /// Execution timestamp.
169    pub executed_at: u64,
170    /// Canonical commerce intent hash.
171    pub intent_hash: String,
172    /// Domain-separated authorization receipt hash.
173    pub receipt_hash: String,
174}
175
176impl CommerceIntent {
177    /// Validate the intent.
178    pub fn validate(&self) -> Result<(), CommerceIntentError> {
179        let _ = self.normalized()?;
180        Ok(())
181    }
182
183    /// Return a normalized form used for canonical hashing.
184    pub fn normalized(&self) -> Result<Self, CommerceIntentError> {
185        if self.max_total == 0 {
186            return Err(CommerceIntentError::InvalidField {
187                field: "max_total",
188                reason: "must be greater than zero".to_string(),
189            });
190        }
191        if self.expires_at == 0 {
192            return Err(CommerceIntentError::InvalidField {
193                field: "expires_at",
194                reason: "must be greater than zero".to_string(),
195            });
196        }
197
198        Ok(Self {
199            intent_id: self.intent_id,
200            tenant_id: self.tenant_id,
201            store_id: self.store_id,
202            agent_id: self.agent_id,
203            delegation_id: self.delegation_id,
204            currency: normalize_currency(&self.currency)?,
205            max_total: self.max_total,
206            merchant: normalize_optional_text_field("merchant", self.merchant.as_deref())?,
207            payee: normalize_optional_text_field("payee", self.payee.as_deref())?,
208            allowed_skus: normalize_scope_list("allowed_skus", &self.allowed_skus)?,
209            allowed_categories: normalize_scope_list(
210                "allowed_categories",
211                &self.allowed_categories,
212            )?,
213            shipping_country: normalize_optional_country(self.shipping_country.as_deref())?,
214            expires_at: self.expires_at,
215            nonce: normalize_nonce(&self.nonce)?,
216        })
217    }
218
219    /// Canonical JSON representation used for hashing.
220    pub fn canonical_json(&self) -> Result<String, CommerceIntentError> {
221        let normalized = self.normalized()?;
222        canonical_json(&serde_json::to_value(&normalized)?)
223            .map_err(|e| CommerceIntentError::Canonicalization(e.to_string()))
224    }
225
226    /// Domain-separated canonical intent hash.
227    pub fn compute_hash(&self) -> Result<Hash256, CommerceIntentError> {
228        let canonical = self.canonical_json()?;
229        Ok(Hash256::sha256_with_domain(
230            DOMAIN_COMMERCE_INTENT_HASH,
231            canonical.as_bytes(),
232        ))
233    }
234
235    /// Domain-separated canonical intent hash as lowercase hex.
236    pub fn compute_hash_hex(&self) -> Result<String, CommerceIntentError> {
237        Ok(self.compute_hash()?.to_hex())
238    }
239
240    /// Validate an execution against this intent and return a receipt when authorized.
241    pub fn authorize_execution(
242        &self,
243        execution: &CommerceExecution,
244    ) -> Result<CommerceAuthorizationReceipt, CommerceIntentError> {
245        let normalized_intent = self.normalized()?;
246        let normalized_execution = execution.normalized()?;
247
248        if normalized_execution.currency != normalized_intent.currency {
249            return Err(CommerceIntentError::CurrencyMismatch {
250                expected: normalized_intent.currency,
251                actual: normalized_execution.currency,
252            });
253        }
254        if normalized_execution.amount > normalized_intent.max_total {
255            return Err(CommerceIntentError::AmountExceedsLimit {
256                amount: normalized_execution.amount,
257                max_total: normalized_intent.max_total,
258            });
259        }
260        if normalized_execution.executed_at > normalized_intent.expires_at {
261            return Err(CommerceIntentError::IntentExpired {
262                executed_at: normalized_execution.executed_at,
263                expires_at: normalized_intent.expires_at,
264            });
265        }
266
267        if let Some(expected) = normalized_intent.merchant.as_deref() {
268            if normalized_execution.merchant != expected {
269                return Err(CommerceIntentError::MerchantMismatch {
270                    expected: expected.to_string(),
271                    actual: normalized_execution.merchant,
272                });
273            }
274        }
275        if let Some(expected) = normalized_intent.payee.as_deref() {
276            if normalized_execution.payee != expected {
277                return Err(CommerceIntentError::PayeeMismatch {
278                    expected: expected.to_string(),
279                    actual: normalized_execution.payee,
280                });
281            }
282        }
283        if let Some(expected) = normalized_intent.shipping_country.as_deref() {
284            match normalized_execution.shipping_country.as_deref() {
285                Some(actual) if actual == expected => {}
286                Some(actual) => {
287                    return Err(CommerceIntentError::ShippingCountryMismatch {
288                        expected: expected.to_string(),
289                        actual: actual.to_string(),
290                    });
291                }
292                None => {
293                    return Err(CommerceIntentError::ShippingCountryMismatch {
294                        expected: expected.to_string(),
295                        actual: "<missing>".to_string(),
296                    });
297                }
298            }
299        }
300
301        for sku in &normalized_execution.sku_ids {
302            if !normalized_intent.allowed_skus.is_empty()
303                && !normalized_intent.allowed_skus.contains(sku)
304            {
305                return Err(CommerceIntentError::UnauthorizedSku { sku: sku.clone() });
306            }
307        }
308        for category in &normalized_execution.category_ids {
309            if !normalized_intent.allowed_categories.is_empty()
310                && !normalized_intent.allowed_categories.contains(category)
311            {
312                return Err(CommerceIntentError::UnauthorizedCategory {
313                    category: category.clone(),
314                });
315            }
316        }
317
318        let intent_hash = normalized_intent.compute_hash_hex()?;
319        let mut receipt = CommerceAuthorizationReceipt {
320            intent_id: normalized_intent.intent_id,
321            tenant_id: normalized_intent.tenant_id,
322            store_id: normalized_intent.store_id,
323            agent_id: normalized_intent.agent_id,
324            delegation_id: normalized_intent.delegation_id,
325            nonce: normalized_intent.nonce,
326            expires_at: normalized_intent.expires_at,
327            event_id: normalized_execution.event_id,
328            sequence_number: normalized_execution.sequence_number,
329            currency: normalized_execution.currency,
330            amount: normalized_execution.amount,
331            merchant: normalized_execution.merchant,
332            payee: normalized_execution.payee,
333            sku_ids: normalized_execution.sku_ids,
334            category_ids: normalized_execution.category_ids,
335            shipping_country: normalized_execution.shipping_country,
336            executed_at: normalized_execution.executed_at,
337            intent_hash,
338            receipt_hash: String::new(),
339        };
340        receipt.receipt_hash = receipt.compute_hash_hex()?;
341        Ok(receipt)
342    }
343}
344
345impl CommerceExecution {
346    /// Validate the execution.
347    pub fn validate(&self) -> Result<(), CommerceIntentError> {
348        let _ = self.normalized()?;
349        Ok(())
350    }
351
352    /// Return a normalized form used for authorization receipt generation.
353    pub fn normalized(&self) -> Result<Self, CommerceIntentError> {
354        if self.amount == 0 {
355            return Err(CommerceIntentError::InvalidField {
356                field: "amount",
357                reason: "must be greater than zero".to_string(),
358            });
359        }
360        if self.executed_at == 0 {
361            return Err(CommerceIntentError::InvalidField {
362                field: "executed_at",
363                reason: "must be greater than zero".to_string(),
364            });
365        }
366
367        Ok(Self {
368            event_id: self.event_id,
369            sequence_number: self.sequence_number,
370            currency: normalize_currency(&self.currency)?,
371            amount: self.amount,
372            merchant: normalize_required_text_field("merchant", &self.merchant)?,
373            payee: normalize_required_text_field("payee", &self.payee)?,
374            sku_ids: normalize_scope_list("sku_ids", &self.sku_ids)?,
375            category_ids: normalize_scope_list("category_ids", &self.category_ids)?,
376            shipping_country: normalize_optional_country(self.shipping_country.as_deref())?,
377            executed_at: self.executed_at,
378        })
379    }
380}
381
382impl CommerceAuthorizationReceipt {
383    /// Validate the receipt and its canonical receipt hash.
384    pub fn validate(&self) -> Result<(), CommerceIntentError> {
385        let _ = self.normalized()?;
386        if !self.validate_hash()? {
387            return Err(CommerceIntentError::InvalidField {
388                field: "receipt_hash",
389                reason: "does not match canonical authorization receipt payload".to_string(),
390            });
391        }
392        Ok(())
393    }
394
395    /// Return a normalized form used for canonical receipt hashing.
396    pub fn normalized(&self) -> Result<Self, CommerceIntentError> {
397        if self.amount == 0 {
398            return Err(CommerceIntentError::InvalidField {
399                field: "amount",
400                reason: "must be greater than zero".to_string(),
401            });
402        }
403        if self.expires_at == 0 {
404            return Err(CommerceIntentError::InvalidField {
405                field: "expires_at",
406                reason: "must be greater than zero".to_string(),
407            });
408        }
409        if self.executed_at == 0 {
410            return Err(CommerceIntentError::InvalidField {
411                field: "executed_at",
412                reason: "must be greater than zero".to_string(),
413            });
414        }
415
416        Ok(Self {
417            intent_id: self.intent_id,
418            tenant_id: self.tenant_id,
419            store_id: self.store_id,
420            agent_id: self.agent_id,
421            delegation_id: self.delegation_id,
422            nonce: normalize_nonce(&self.nonce)?,
423            expires_at: self.expires_at,
424            event_id: self.event_id,
425            sequence_number: self.sequence_number,
426            currency: normalize_currency(&self.currency)?,
427            amount: self.amount,
428            merchant: normalize_required_text_field("merchant", &self.merchant)?,
429            payee: normalize_required_text_field("payee", &self.payee)?,
430            sku_ids: normalize_scope_list("sku_ids", &self.sku_ids)?,
431            category_ids: normalize_scope_list("category_ids", &self.category_ids)?,
432            shipping_country: normalize_optional_country(self.shipping_country.as_deref())?,
433            executed_at: self.executed_at,
434            intent_hash: normalize_hash_field("intent_hash", &self.intent_hash)?,
435            receipt_hash: normalize_hash_field("receipt_hash", &self.receipt_hash)?,
436        })
437    }
438
439    /// Canonical JSON representation of the signed payload used for receipt hashing.
440    pub fn canonical_json(&self) -> Result<String, CommerceIntentError> {
441        let payload = self.normalized_payload()?.payload_value();
442        canonical_json(&payload).map_err(|e| CommerceIntentError::Canonicalization(e.to_string()))
443    }
444
445    /// Recompute the receipt hash from the canonical payload.
446    pub fn compute_hash(&self) -> Result<Hash256, CommerceIntentError> {
447        let canonical = self.canonical_json()?;
448        Ok(Hash256::sha256_with_domain(
449            DOMAIN_COMMERCE_AUTHORIZATION_RECEIPT_HASH,
450            canonical.as_bytes(),
451        ))
452    }
453
454    /// Recompute the receipt hash from the canonical payload as lowercase hex.
455    pub fn compute_hash_hex(&self) -> Result<String, CommerceIntentError> {
456        Ok(self.compute_hash()?.to_hex())
457    }
458
459    /// Validate that `receipt_hash` matches the canonical payload.
460    pub fn validate_hash(&self) -> Result<bool, CommerceIntentError> {
461        Ok(self.compute_hash_hex()? == normalize_hash_field("receipt_hash", &self.receipt_hash)?)
462    }
463
464    /// Compute the Rescue witness commitment for the receipt amount.
465    ///
466    /// This matches the commitment derived by the prover from the private amount.
467    pub fn witness_commitment_u64(&self) -> [u64; 4] {
468        let mut amount_limbs = [FELT_ZERO; 8];
469        amount_limbs[0] = felt_from_u64(self.amount & 0xFFFF_FFFF);
470        amount_limbs[1] = felt_from_u64(self.amount >> 32);
471
472        let hash_output = rescue_hash(&amount_limbs);
473        [
474            hash_output[0].as_int(),
475            hash_output[1].as_int(),
476            hash_output[2].as_int(),
477            hash_output[3].as_int(),
478        ]
479    }
480
481    fn payload_value(&self) -> serde_json::Value {
482        serde_json::json!({
483            "agentId": self.agent_id,
484            "amount": self.amount,
485            "categoryIds": self.category_ids,
486            "currency": self.currency,
487            "delegationId": self.delegation_id,
488            "eventId": self.event_id,
489            "executedAt": self.executed_at,
490            "expiresAt": self.expires_at,
491            "intentHash": self.intent_hash,
492            "intentId": self.intent_id,
493            "merchant": self.merchant,
494            "nonce": self.nonce,
495            "payee": self.payee,
496            "sequenceNumber": self.sequence_number,
497            "shippingCountry": self.shipping_country,
498            "skuIds": self.sku_ids,
499            "storeId": self.store_id,
500            "tenantId": self.tenant_id,
501        })
502    }
503
504    fn normalized_payload(&self) -> Result<Self, CommerceIntentError> {
505        if self.amount == 0 {
506            return Err(CommerceIntentError::InvalidField {
507                field: "amount",
508                reason: "must be greater than zero".to_string(),
509            });
510        }
511        if self.expires_at == 0 {
512            return Err(CommerceIntentError::InvalidField {
513                field: "expires_at",
514                reason: "must be greater than zero".to_string(),
515            });
516        }
517        if self.executed_at == 0 {
518            return Err(CommerceIntentError::InvalidField {
519                field: "executed_at",
520                reason: "must be greater than zero".to_string(),
521            });
522        }
523
524        Ok(Self {
525            intent_id: self.intent_id,
526            tenant_id: self.tenant_id,
527            store_id: self.store_id,
528            agent_id: self.agent_id,
529            delegation_id: self.delegation_id,
530            nonce: normalize_nonce(&self.nonce)?,
531            expires_at: self.expires_at,
532            event_id: self.event_id,
533            sequence_number: self.sequence_number,
534            currency: normalize_currency(&self.currency)?,
535            amount: self.amount,
536            merchant: normalize_required_text_field("merchant", &self.merchant)?,
537            payee: normalize_required_text_field("payee", &self.payee)?,
538            sku_ids: normalize_scope_list("sku_ids", &self.sku_ids)?,
539            category_ids: normalize_scope_list("category_ids", &self.category_ids)?,
540            shipping_country: normalize_optional_country(self.shipping_country.as_deref())?,
541            executed_at: self.executed_at,
542            intent_hash: normalize_hash_field("intent_hash", &self.intent_hash)?,
543            receipt_hash: self.receipt_hash.clone(),
544        })
545    }
546}
547
548fn normalize_currency(value: &str) -> Result<String, CommerceIntentError> {
549    normalize_ascii_code("currency", value, 3)
550}
551
552fn normalize_optional_country(value: Option<&str>) -> Result<Option<String>, CommerceIntentError> {
553    value
554        .map(|country| normalize_ascii_code("shipping_country", country, 2))
555        .transpose()
556}
557
558fn normalize_ascii_code(
559    field: &'static str,
560    value: &str,
561    expected_len: usize,
562) -> Result<String, CommerceIntentError> {
563    let normalized = value.trim().to_ascii_uppercase();
564    if normalized.len() != expected_len {
565        return Err(CommerceIntentError::InvalidField {
566            field,
567            reason: format!("must be exactly {expected_len} ASCII letters"),
568        });
569    }
570    if !normalized.chars().all(|ch| ch.is_ascii_uppercase()) {
571        return Err(CommerceIntentError::InvalidField {
572            field,
573            reason: "must contain only ASCII letters".to_string(),
574        });
575    }
576    Ok(normalized)
577}
578
579fn normalize_optional_text_field(
580    field: &'static str,
581    value: Option<&str>,
582) -> Result<Option<String>, CommerceIntentError> {
583    value
584        .map(|text| normalize_required_text_field(field, text))
585        .transpose()
586}
587
588fn normalize_required_text_field(
589    field: &'static str,
590    value: &str,
591) -> Result<String, CommerceIntentError> {
592    let normalized = value.trim();
593    if normalized.is_empty() {
594        return Err(CommerceIntentError::InvalidField {
595            field,
596            reason: "must not be empty".to_string(),
597        });
598    }
599    if normalized.chars().any(|ch| ch.is_control()) {
600        return Err(CommerceIntentError::InvalidField {
601            field,
602            reason: "must not contain control characters".to_string(),
603        });
604    }
605    Ok(normalized.to_string())
606}
607
608fn normalize_scope_list(
609    field: &'static str,
610    values: &[String],
611) -> Result<Vec<String>, CommerceIntentError> {
612    let mut normalized = Vec::with_capacity(values.len());
613    for value in values {
614        normalized.push(normalize_required_text_field(field, value)?);
615    }
616    normalized.sort();
617    normalized.dedup();
618    Ok(normalized)
619}
620
621fn normalize_nonce(value: &str) -> Result<String, CommerceIntentError> {
622    let trimmed = value.trim();
623    let normalized = trimmed
624        .strip_prefix("0x")
625        .or_else(|| trimmed.strip_prefix("0X"))
626        .unwrap_or(trimmed)
627        .to_ascii_lowercase();
628
629    if normalized.len() != 64 {
630        return Err(CommerceIntentError::InvalidField {
631            field: "nonce",
632            reason: "must be exactly 32 bytes of hex".to_string(),
633        });
634    }
635    if !normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
636        return Err(CommerceIntentError::InvalidField {
637            field: "nonce",
638            reason: "must contain only hexadecimal characters".to_string(),
639        });
640    }
641
642    Ok(normalized)
643}
644
645fn normalize_hash_field(field: &'static str, value: &str) -> Result<String, CommerceIntentError> {
646    let trimmed = value.trim();
647    let normalized = trimmed
648        .strip_prefix("0x")
649        .or_else(|| trimmed.strip_prefix("0X"))
650        .unwrap_or(trimmed)
651        .to_ascii_lowercase();
652
653    if normalized.len() != 64 {
654        return Err(CommerceIntentError::InvalidField {
655            field,
656            reason: "must be exactly 32 bytes of hex".to_string(),
657        });
658    }
659    if !normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
660        return Err(CommerceIntentError::InvalidField {
661            field,
662            reason: "must contain only hexadecimal characters".to_string(),
663        });
664    }
665
666    Ok(normalized)
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    fn sample_intent() -> CommerceIntent {
674        CommerceIntent {
675            intent_id: Uuid::parse_str("9f7f314e-80c3-45dc-af6d-11d6c1a68701").unwrap(),
676            tenant_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
677            store_id: Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
678            agent_id: Uuid::parse_str("6ba7b811-9dad-11d1-80b4-00c04fd430c8").unwrap(),
679            delegation_id: Uuid::parse_str("d9428888-122b-11e1-b85c-61cd3cbb3210").unwrap(),
680            currency: "usd".to_string(),
681            max_total: 25_000,
682            merchant: Some(" Acme Market ".to_string()),
683            payee: Some("settlement@stateset.app".to_string()),
684            allowed_skus: vec![
685                "sku-b".to_string(),
686                "sku-a".to_string(),
687                "sku-a".to_string(),
688            ],
689            allowed_categories: vec!["grocery".to_string(), "produce".to_string()],
690            shipping_country: Some("us".to_string()),
691            expires_at: 1_700_000_100,
692            nonce: "0X0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF".to_string(),
693        }
694    }
695
696    fn sample_execution() -> CommerceExecution {
697        CommerceExecution {
698            event_id: Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(),
699            sequence_number: 42,
700            currency: "USD".to_string(),
701            amount: 12_500,
702            merchant: "Acme Market".to_string(),
703            payee: "settlement@stateset.app".to_string(),
704            sku_ids: vec!["sku-a".to_string(), "sku-b".to_string()],
705            category_ids: vec!["produce".to_string()],
706            shipping_country: Some("US".to_string()),
707            executed_at: 1_700_000_000,
708        }
709    }
710
711    #[test]
712    fn test_commerce_intent_hash_normalizes_equivalent_inputs() {
713        let messy = sample_intent();
714        let mut normalized = sample_intent();
715        normalized.currency = "USD".to_string();
716        normalized.merchant = Some("Acme Market".to_string());
717        normalized.allowed_skus = vec!["sku-a".to_string(), "sku-b".to_string()];
718        normalized.shipping_country = Some("US".to_string());
719        normalized.nonce =
720            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
721
722        assert_eq!(
723            messy.normalized().unwrap(),
724            normalized.normalized().unwrap()
725        );
726        assert_eq!(
727            messy.compute_hash().unwrap(),
728            normalized.compute_hash().unwrap()
729        );
730    }
731
732    #[test]
733    fn test_authorize_execution_generates_deterministic_receipt() {
734        let intent = sample_intent();
735        let execution = sample_execution();
736
737        let receipt1 = intent.authorize_execution(&execution).unwrap();
738        let receipt2 = intent.authorize_execution(&execution).unwrap();
739
740        assert_eq!(receipt1, receipt2);
741        receipt1.validate().unwrap();
742        assert!(receipt1.validate_hash().unwrap());
743    }
744
745    #[test]
746    fn test_authorization_receipt_depends_on_nonce_and_sequence_number() {
747        let mut intent = sample_intent();
748        let execution = sample_execution();
749
750        let receipt1 = intent.authorize_execution(&execution).unwrap();
751
752        intent.nonce =
753            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
754        let receipt2 = intent.authorize_execution(&execution).unwrap();
755        assert_ne!(receipt1.intent_hash, receipt2.intent_hash);
756        assert_ne!(receipt1.receipt_hash, receipt2.receipt_hash);
757
758        let mut next_execution = sample_execution();
759        next_execution.sequence_number += 1;
760        let receipt3 = sample_intent()
761            .authorize_execution(&next_execution)
762            .unwrap();
763        assert_ne!(receipt1.receipt_hash, receipt3.receipt_hash);
764    }
765
766    #[test]
767    fn test_authorize_execution_rejects_expired_intent() {
768        let intent = sample_intent();
769        let mut execution = sample_execution();
770        execution.executed_at = intent.expires_at + 1;
771
772        let err = intent.authorize_execution(&execution).unwrap_err();
773        assert!(matches!(err, CommerceIntentError::IntentExpired { .. }));
774    }
775
776    #[test]
777    fn test_authorize_execution_rejects_scope_violation() {
778        let intent = sample_intent();
779        let mut execution = sample_execution();
780        execution.sku_ids.push("sku-z".to_string());
781
782        let err = intent.authorize_execution(&execution).unwrap_err();
783        assert!(matches!(err, CommerceIntentError::UnauthorizedSku { .. }));
784    }
785
786    #[test]
787    fn test_authorize_execution_rejects_merchant_mismatch() {
788        let intent = sample_intent();
789        let mut execution = sample_execution();
790        execution.merchant = "Other Merchant".to_string();
791
792        let err = intent.authorize_execution(&execution).unwrap_err();
793        assert!(matches!(err, CommerceIntentError::MerchantMismatch { .. }));
794    }
795
796    #[test]
797    fn test_authorization_receipt_hash_rejects_non_canonical_fields() {
798        let mut receipt = sample_intent()
799            .authorize_execution(&sample_execution())
800            .unwrap();
801        receipt.intent_hash = format!("0X{}", receipt.intent_hash.to_ascii_uppercase());
802        receipt.receipt_hash = format!("0X{}", receipt.receipt_hash.to_ascii_uppercase());
803
804        receipt.validate().unwrap();
805        assert!(receipt.validate_hash().unwrap());
806    }
807
808    #[test]
809    fn test_authorization_receipt_validate_rejects_tampered_hash() {
810        let mut receipt = sample_intent()
811            .authorize_execution(&sample_execution())
812            .unwrap();
813        receipt.receipt_hash = "0".repeat(64);
814
815        let err = receipt.validate().unwrap_err();
816        assert!(matches!(
817            err,
818            CommerceIntentError::InvalidField {
819                field: "receipt_hash",
820                ..
821            }
822        ));
823    }
824
825    #[test]
826    fn test_authorization_receipt_witness_commitment_depends_on_amount() {
827        let mut receipt = sample_intent()
828            .authorize_execution(&sample_execution())
829            .unwrap();
830        let commitment1 = receipt.witness_commitment_u64();
831
832        receipt.amount += 1;
833        let commitment2 = receipt.witness_commitment_u64();
834
835        assert_ne!(commitment1, commitment2);
836    }
837}