1use 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
16pub const DOMAIN_COMMERCE_INTENT_HASH: &[u8] = b"STATESET_VES_COMMERCE_INTENT_HASH_V1";
18
19pub const DOMAIN_COMMERCE_AUTHORIZATION_RECEIPT_HASH: &[u8] =
21 b"STATESET_VES_COMMERCE_AUTHORIZATION_RECEIPT_HASH_V1";
22
23#[derive(Debug, Error)]
25pub enum CommerceIntentError {
26 #[error("Invalid {field}: {reason}")]
28 InvalidField { field: &'static str, reason: String },
29 #[error("JSON serialization failed: {0}")]
31 Serialization(#[from] serde_json::Error),
32 #[error("JCS canonicalization failed: {0}")]
34 Canonicalization(String),
35 #[error("Execution amount {amount} exceeds max_total {max_total}")]
37 AmountExceedsLimit { amount: u64, max_total: u64 },
38 #[error("Execution time {executed_at} exceeds intent expiry {expires_at}")]
40 IntentExpired { executed_at: u64, expires_at: u64 },
41 #[error("Currency mismatch: expected {expected}, got {actual}")]
43 CurrencyMismatch { expected: String, actual: String },
44 #[error("Merchant mismatch: expected {expected}, got {actual}")]
46 MerchantMismatch { expected: String, actual: String },
47 #[error("Payee mismatch: expected {expected}, got {actual}")]
49 PayeeMismatch { expected: String, actual: String },
50 #[error("Shipping country mismatch: expected {expected}, got {actual}")]
52 ShippingCountryMismatch { expected: String, actual: String },
53 #[error("SKU '{sku}' is not in the authorized allowlist")]
55 UnauthorizedSku { sku: String },
56 #[error("Category '{category}' is not in the authorized allowlist")]
58 UnauthorizedCategory { category: String },
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "camelCase")]
64pub struct CommerceIntent {
65 pub intent_id: Uuid,
67 pub tenant_id: Uuid,
69 pub store_id: Uuid,
71 pub agent_id: Uuid,
73 pub delegation_id: Uuid,
75 pub currency: String,
77 pub max_total: u64,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub merchant: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub payee: Option<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub allowed_skus: Vec<String>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub allowed_categories: Vec<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub shipping_country: Option<String>,
94 pub expires_at: u64,
96 pub nonce: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "camelCase")]
103pub struct CommerceExecution {
104 pub event_id: Uuid,
106 pub sequence_number: u64,
108 pub currency: String,
110 pub amount: u64,
112 pub merchant: String,
114 pub payee: String,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub sku_ids: Vec<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub category_ids: Vec<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub shipping_country: Option<String>,
125 pub executed_at: u64,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct CommerceAuthorizationReceipt {
133 pub intent_id: Uuid,
135 pub tenant_id: Uuid,
137 pub store_id: Uuid,
139 pub agent_id: Uuid,
141 pub delegation_id: Uuid,
143 pub nonce: String,
145 pub expires_at: u64,
147 pub event_id: Uuid,
149 pub sequence_number: u64,
151 pub currency: String,
153 pub amount: u64,
155 pub merchant: String,
157 pub payee: String,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub sku_ids: Vec<String>,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub category_ids: Vec<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub shipping_country: Option<String>,
168 pub executed_at: u64,
170 pub intent_hash: String,
172 pub receipt_hash: String,
174}
175
176impl CommerceIntent {
177 pub fn validate(&self) -> Result<(), CommerceIntentError> {
179 let _ = self.normalized()?;
180 Ok(())
181 }
182
183 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 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 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 pub fn compute_hash_hex(&self) -> Result<String, CommerceIntentError> {
237 Ok(self.compute_hash()?.to_hex())
238 }
239
240 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 pub fn validate(&self) -> Result<(), CommerceIntentError> {
348 let _ = self.normalized()?;
349 Ok(())
350 }
351
352 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 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 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 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 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 pub fn compute_hash_hex(&self) -> Result<String, CommerceIntentError> {
456 Ok(self.compute_hash()?.to_hex())
457 }
458
459 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 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}