1use std::sync::Arc;
13
14use chio_core::crypto::PublicKey;
15use chio_core::receipt::{body::ChioReceipt, economics::ChannelReceiptMetadataV1};
16use chio_settle::{
17 SettlementFailureClass, SettlementFailureCode, SettlementFailureReason, SettlementHook,
18 SettlementHookError, SettlementIdempotencyKey, SettlementObservation, SettlementOutcome,
19 SettlementSkipReason,
20};
21
22#[allow(dead_code)]
26pub const SETTLEMENT_OBSERVER_STATUS_SCHEMA: &str = "chio.settle.observer-status.v1";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum SettlementObserverStatus {
36 NotRegistered,
39 Skipped { reason: SettlementSkipReason },
41 Observed { outcome: SettlementOutcome },
45 HookFailed {
47 class: SettlementFailureClass,
48 reason: SettlementFailureReason,
49 },
50}
51
52impl SettlementObserverStatus {
53 #[must_use]
54 pub const fn skipped(reason: SettlementSkipReason) -> Self {
55 Self::Skipped { reason }
56 }
57
58 #[must_use]
59 pub fn hook_failed(err: &SettlementHookError) -> Self {
60 let (class, reason) = err.classification();
61 Self::HookFailed { class, reason }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum SettlementObservationBuild {
68 Observation(SettlementObservation),
70 Skipped(SettlementSkipReason),
72 Permanent(SettlementFailureReason),
74}
75
76fn permanent(code: SettlementFailureCode, detail: impl AsRef<[u8]>) -> SettlementObservationBuild {
77 SettlementObservationBuild::Permanent(SettlementFailureReason::from_detail(code, detail))
78}
79
80#[must_use]
81pub fn build_observation(
82 receipt: &ChioReceipt,
83 trusted_kernel_keys: &[PublicKey],
84) -> SettlementObservationBuild {
85 match receipt.verify_signature() {
86 Ok(true) => {}
87 Ok(false) => {
88 return permanent(
89 SettlementFailureCode::InvalidReceiptSignature,
90 "receipt signature did not verify",
91 );
92 }
93 Err(error) => {
94 return permanent(
95 SettlementFailureCode::InvalidReceiptSignature,
96 error.to_string(),
97 );
98 }
99 }
100 match receipt.action.verify_hash() {
101 Ok(true) => {}
102 Ok(false) => {
103 return permanent(
104 SettlementFailureCode::InvalidActionHash,
105 "receipt action hash did not verify",
106 );
107 }
108 Err(error) => {
109 return permanent(SettlementFailureCode::InvalidActionHash, error.to_string());
110 }
111 }
112 if trusted_kernel_keys.is_empty()
113 || !trusted_kernel_keys
114 .iter()
115 .any(|trusted| trusted == &receipt.kernel_key)
116 {
117 return permanent(
118 SettlementFailureCode::UntrustedReceiptSigner,
119 "receipt signer is not trusted",
120 );
121 }
122 let metadata = receipt.metadata.as_ref();
123 let channelized = if let Some(channel_value) = metadata.and_then(|value| value.get("channel")) {
124 match serde_json::from_value::<ChannelReceiptMetadataV1>(channel_value.clone()) {
125 Ok(channel) if channel.is_valid() => true,
126 Ok(_) | Err(_) => {
127 return permanent(
128 SettlementFailureCode::InvalidObservation,
129 "channel metadata is malformed",
130 );
131 }
132 }
133 } else {
134 false
135 };
136 if channelized {
137 if !receipt.is_allowed() && !receipt.is_denied() {
138 return permanent(
139 SettlementFailureCode::InvalidObservation,
140 "receipt does not contain an authorized terminal decision",
141 );
142 }
143 if receipt.financial_metadata().is_none() {
144 return permanent(
145 SettlementFailureCode::MalformedFinancialMetadata,
146 "channel financial metadata is malformed",
147 );
148 }
149 return permanent(
150 SettlementFailureCode::InvalidObservation,
151 "channel settlement handler is not configured",
152 );
153 }
154 if receipt.is_denied() {
155 return SettlementObservationBuild::Skipped(SettlementSkipReason::Denied);
156 }
157 if !receipt.is_allowed() {
158 return permanent(
159 SettlementFailureCode::InvalidObservation,
160 "receipt does not contain an authorized terminal decision",
161 );
162 }
163
164 let Some(metadata) = metadata else {
165 return SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent);
166 };
167 let Some(financial_value) = metadata.get("financial") else {
168 return SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent);
169 };
170 let Some(financial) = financial_value.as_object() else {
171 return permanent(
172 SettlementFailureCode::MalformedFinancialMetadata,
173 "financial metadata is not an object",
174 );
175 };
176 let Some(charged_value) = financial.get("cost_charged") else {
177 return permanent(
178 SettlementFailureCode::MalformedFinancialMetadata,
179 "financial metadata is missing cost_charged",
180 );
181 };
182 let Some(units) = charged_value.as_u64() else {
183 return permanent(
184 SettlementFailureCode::MalformedFinancialMetadata,
185 "cost_charged is not an unsigned integer",
186 );
187 };
188 if units == 0 {
189 return SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge);
190 }
191 let Some(currency) = financial
192 .get("currency")
193 .and_then(serde_json::Value::as_str)
194 else {
195 return permanent(
196 SettlementFailureCode::MalformedFinancialMetadata,
197 "positive cost_charged requires a currency",
198 );
199 };
200 if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
207 return permanent(
208 SettlementFailureCode::MalformedFinancialMetadata,
209 "positive cost_charged requires a three-letter uppercase currency",
210 );
211 }
212
213 let observation = SettlementObservation::new(
214 receipt.id.clone(),
215 receipt.timestamp,
216 receipt.tool_server.clone(),
217 receipt.tool_name.clone(),
218 receipt.capability_id.clone(),
219 chio_core::capability::scope::MonetaryAmount {
220 currency: currency.to_string(),
221 units,
222 },
223 receipt.content_hash.clone(),
224 receipt.policy_hash.clone(),
225 );
226
227 SettlementObservationBuild::Observation(if let Some(tenant_id) = receipt.tenant_id.clone() {
228 observation.with_tenant(tenant_id)
229 } else {
230 observation
231 })
232}
233
234#[must_use]
243pub fn run_observer(
244 hook: Option<&Arc<dyn SettlementHook>>,
245 receipt: &ChioReceipt,
246 trusted_kernel_keys: &[PublicKey],
247 idempotency_key: &SettlementIdempotencyKey,
248) -> SettlementObserverStatus {
249 let Some(hook) = hook else {
250 return SettlementObserverStatus::NotRegistered;
251 };
252
253 let observation = match build_observation(receipt, trusted_kernel_keys) {
254 SettlementObservationBuild::Observation(observation) => observation,
255 SettlementObservationBuild::Skipped(reason) => {
256 return SettlementObserverStatus::skipped(reason);
257 }
258 SettlementObservationBuild::Permanent(reason) => {
259 return SettlementObserverStatus::HookFailed {
260 class: SettlementFailureClass::Permanent,
261 reason,
262 };
263 }
264 };
265
266 if idempotency_key.receipt_id != observation.receipt_id || idempotency_key.row_version == 0 {
267 return SettlementObserverStatus::hook_failed(&SettlementHookError::InvalidObservation(
268 "settlement idempotency key does not match the claimed receipt".to_string(),
269 ));
270 }
271
272 match hook.observe(&observation, idempotency_key) {
273 Ok(outcome) => SettlementObserverStatus::Observed { outcome },
274 Err(error) => SettlementObserverStatus::hook_failed(&error),
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 use chio_core::capability::scope::MonetaryAmount;
283 use chio_core::crypto::Keypair;
284 use chio_core::receipt::{
285 body::ChioReceiptBody, decision::Decision, decision::ToolCallAction,
286 economics::CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA, kinds::TrustLevel,
287 metadata::GuardEvidence,
288 };
289
290 fn sign_with(body_metadata: serde_json::Value, decision: Decision) -> ChioReceipt {
291 let action = ToolCallAction::from_parameters(serde_json::json!({}))
292 .expect("test tool-call action constructs");
293 sign_with_action(body_metadata, decision, action)
294 }
295
296 fn sign_with_action(
297 body_metadata: serde_json::Value,
298 decision: Decision,
299 action: ToolCallAction,
300 ) -> ChioReceipt {
301 let kp = Keypair::generate();
302 let body = ChioReceiptBody {
303 id: "rcpt-test".to_string(),
304 timestamp: 100,
305 capability_id: "cap-1".to_string(),
306 tool_server: "srv-1".to_string(),
307 tool_name: "tool-1".to_string(),
308 action,
309 decision: Some(decision),
310 receipt_kind: chio_core::receipt::kinds::ReceiptKind::MediatedDecision,
311 boundary_class: chio_core::receipt::kinds::BoundaryClass::Prevent,
312 observation_outcome: None,
313 tool_origin: chio_core::receipt::kinds::ToolOrigin::CallerExecuted,
314 redaction_mode: chio_core::receipt::kinds::RedactionMode::None,
315 actor_chain: Vec::new(),
316 content_hash: "ch-1".to_string(),
317 policy_hash: "ph-1".to_string(),
318 evidence: vec![GuardEvidence {
319 guard_name: "G".to_string(),
320 verdict: true,
321 details: None,
322 }],
323 metadata: Some(body_metadata),
324 trust_level: TrustLevel::default(),
325 tenant_id: None,
326 kernel_key: kp.public_key(),
327 bbs_projection_version: None,
328 };
329 ChioReceipt::sign(body, &kp).expect("test receipt signs")
330 }
331
332 fn channel_metadata() -> serde_json::Value {
333 serde_json::json!({
334 "schema": CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA,
335 "channelId": "a".repeat(64),
336 "openDigest": "b".repeat(64),
337 "reservationId": "c".repeat(64),
338 "reservationDigest": "d".repeat(64),
339 "sequence": 1,
340 "settlementMode": "channelized",
341 })
342 }
343
344 fn idempotency_key(receipt: &ChioReceipt) -> SettlementIdempotencyKey {
345 SettlementIdempotencyKey {
346 receipt_id: receipt.id.clone(),
347 row_version: 1,
348 }
349 }
350
351 struct AcceptingHook;
352 impl SettlementHook for AcceptingHook {
353 fn observe(
354 &self,
355 observation: &SettlementObservation,
356 idempotency_key: &SettlementIdempotencyKey,
357 ) -> Result<SettlementOutcome, SettlementHookError> {
358 assert_eq!(idempotency_key.receipt_id, observation.receipt_id);
359 assert_eq!(idempotency_key.row_version, 1);
360 Ok(SettlementOutcome::accepted(format!(
361 "ts-{}",
362 observation.receipt_id
363 )))
364 }
365 }
366
367 struct FailingHook;
368 impl SettlementHook for FailingHook {
369 fn observe(
370 &self,
371 _observation: &SettlementObservation,
372 _idempotency_key: &SettlementIdempotencyKey,
373 ) -> Result<SettlementOutcome, SettlementHookError> {
374 Err(SettlementHookError::Transient("rpc lag".to_string()))
375 }
376 }
377
378 #[test]
379 fn build_observation_skips_explicit_deny() {
380 let receipt = sign_with(
381 serde_json::json!({
382 "financial": {"cost_charged": 100, "currency": "USD"}
383 }),
384 Decision::Deny {
385 reason: "denied".to_string(),
386 guard: "G".to_string(),
387 },
388 );
389 assert!(matches!(
390 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
391 SettlementObservationBuild::Skipped(SettlementSkipReason::Denied)
392 ));
393 }
394
395 #[test]
396 fn build_observation_rejects_nonterminal_decisions() {
397 let cases = [
398 Decision::Cancelled {
399 reason: "cancelled".to_string(),
400 },
401 Decision::Incomplete {
402 reason: "incomplete".to_string(),
403 },
404 ];
405
406 for decision in cases {
407 let receipt = sign_with(
408 serde_json::json!({
409 "financial": {"cost_charged": 100, "currency": "USD"}
410 }),
411 decision,
412 );
413 assert!(matches!(
414 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
415 SettlementObservationBuild::Permanent(ref reason)
416 if reason.code() == SettlementFailureCode::InvalidObservation
417 ));
418 }
419 }
420
421 #[test]
422 fn build_observation_skips_zero_priced_receipts() {
423 let receipt = sign_with(
424 serde_json::json!({
425 "financial": {"cost_charged": 0, "currency": "USD"}
426 }),
427 Decision::Allow,
428 );
429 assert!(matches!(
430 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
431 SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge)
432 ));
433 }
434
435 #[test]
436 fn build_observation_skips_when_metadata_missing_financial_section() {
437 let receipt = sign_with(serde_json::json!({}), Decision::Allow);
438 assert!(matches!(
439 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
440 SettlementObservationBuild::Skipped(SettlementSkipReason::NoEconomicIntent)
441 ));
442 }
443
444 #[test]
445 fn build_observation_rejects_channelized_receipts_with_malformed_financial_metadata() {
446 let receipt = sign_with(
447 serde_json::json!({
448 "channel": channel_metadata(),
449 "financial": "invalid"
450 }),
451 Decision::Allow,
452 );
453 assert!(matches!(
454 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
455 SettlementObservationBuild::Permanent(ref reason)
456 if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
457 ));
458 }
459
460 #[test]
461 fn build_observation_rejects_channelized_receipts_without_financial_metadata() {
462 let receipt = sign_with(
463 serde_json::json!({"channel": channel_metadata()}),
464 Decision::Allow,
465 );
466 assert!(matches!(
467 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
468 SettlementObservationBuild::Permanent(ref reason)
469 if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
470 ));
471 }
472
473 #[test]
474 fn build_observation_rejects_denied_channelized_receipts_without_a_channel_handler() {
475 let receipt = sign_with(
476 serde_json::json!({
477 "channel": channel_metadata(),
478 "financial": {
479 "grant_index": 0,
480 "cost_charged": 0,
481 "currency": "USD",
482 "budget_remaining": 1000,
483 "budget_total": 1000,
484 "delegation_depth": 0,
485 "root_budget_holder": "payer",
486 "settlement_status": "not_applicable"
487 }
488 }),
489 Decision::Deny {
490 reason: "denied".to_owned(),
491 guard: "G".to_owned(),
492 },
493 );
494 assert!(matches!(
495 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
496 SettlementObservationBuild::Permanent(ref reason)
497 if reason.code() == SettlementFailureCode::InvalidObservation
498 ));
499 }
500
501 #[test]
502 fn build_observation_rejects_channelized_receipts_without_a_channel_handler() {
503 let receipt = sign_with(
504 serde_json::json!({
505 "channel": channel_metadata(),
506 "financial": {
507 "grant_index": 0,
508 "cost_charged": 250,
509 "currency": "USD",
510 "budget_remaining": 750,
511 "budget_total": 1000,
512 "delegation_depth": 0,
513 "root_budget_holder": "payer",
514 "settlement_status": "pending"
515 }
516 }),
517 Decision::Allow,
518 );
519 assert!(matches!(
520 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
521 SettlementObservationBuild::Permanent(ref reason)
522 if reason.code() == SettlementFailureCode::InvalidObservation
523 ));
524 }
525
526 #[test]
527 fn build_observation_rejects_malformed_channel_metadata() {
528 let receipt = sign_with(
529 serde_json::json!({
530 "channel": {"schema": CHIO_CHANNEL_RECEIPT_METADATA_SCHEMA},
531 "financial": {"cost_charged": 250, "currency": "USD"}
532 }),
533 Decision::Allow,
534 );
535 assert!(matches!(
536 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
537 SettlementObservationBuild::Permanent(ref reason)
538 if reason.code() == SettlementFailureCode::InvalidObservation
539 ));
540 }
541
542 #[test]
543 fn build_observation_validates_integrity_and_trust_before_channel_skip() {
544 let mut invalid_signature = sign_with(
545 serde_json::json!({"channel": channel_metadata()}),
546 Decision::Allow,
547 );
548 invalid_signature.tool_name = "tampered".to_string();
549 assert!(matches!(
550 build_observation(
551 &invalid_signature,
552 std::slice::from_ref(&invalid_signature.kernel_key)
553 ),
554 SettlementObservationBuild::Permanent(ref reason)
555 if reason.code() == SettlementFailureCode::InvalidReceiptSignature
556 ));
557
558 let untrusted = sign_with(
559 serde_json::json!({"channel": channel_metadata()}),
560 Decision::Allow,
561 );
562 assert!(matches!(
563 build_observation(&untrusted, &[]),
564 SettlementObservationBuild::Permanent(ref reason)
565 if reason.code() == SettlementFailureCode::UntrustedReceiptSigner
566 ));
567 }
568
569 #[test]
570 fn build_observation_rejects_invalid_signature() {
571 let mut receipt = sign_with(
572 serde_json::json!({
573 "financial": {"cost_charged": 250, "currency": "USD"}
574 }),
575 Decision::Allow,
576 );
577 receipt.tool_name = "tampered".to_string();
578 assert!(matches!(
579 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
580 SettlementObservationBuild::Permanent(ref reason)
581 if reason.code() == SettlementFailureCode::InvalidReceiptSignature
582 ));
583 }
584
585 #[test]
586 fn build_observation_rejects_mismatched_action_hash() {
587 let mut action = ToolCallAction::from_parameters(serde_json::json!({"path": "/tmp/a"}))
588 .expect("test tool-call action constructs");
589 action.parameters = serde_json::json!({"path": "/tmp/b"});
590 let receipt = sign_with_action(
591 serde_json::json!({
592 "financial": {"cost_charged": 250, "currency": "USD"}
593 }),
594 Decision::Allow,
595 action,
596 );
597 assert!(matches!(
598 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
599 SettlementObservationBuild::Permanent(ref reason)
600 if reason.code() == SettlementFailureCode::InvalidActionHash
601 ));
602 }
603
604 #[test]
605 fn build_observation_constructs_priced_frame() {
606 let receipt = sign_with(
607 serde_json::json!({
608 "financial": {"cost_charged": 250, "currency": "USD"}
609 }),
610 Decision::Allow,
611 );
612 let SettlementObservationBuild::Observation(observation) =
613 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key))
614 else {
615 panic!("priced receipt did not yield an observation");
616 };
617 assert_eq!(observation.receipt_id, receipt.id);
618 assert_eq!(observation.finalized_at, 100);
619 assert_eq!(
620 observation.amount,
621 MonetaryAmount {
622 currency: "USD".to_string(),
623 units: 250,
624 }
625 );
626 assert_eq!(observation.content_hash, "ch-1");
627 }
628
629 #[test]
630 fn build_observation_rejects_untrusted_signer() {
631 let receipt = sign_with(
632 serde_json::json!({
633 "financial": {"cost_charged": 250, "currency": "USD"}
634 }),
635 Decision::Allow,
636 );
637 assert!(matches!(
638 build_observation(&receipt, &[]),
639 SettlementObservationBuild::Permanent(ref reason)
640 if reason.code() == SettlementFailureCode::UntrustedReceiptSigner
641 ));
642 }
643
644 #[test]
645 fn run_observer_returns_not_registered_without_hook() {
646 let receipt = sign_with(
647 serde_json::json!({
648 "financial": {"cost_charged": 250, "currency": "USD"}
649 }),
650 Decision::Allow,
651 );
652 let status = run_observer(
653 None,
654 &receipt,
655 std::slice::from_ref(&receipt.kernel_key),
656 &idempotency_key(&receipt),
657 );
658 assert!(matches!(status, SettlementObserverStatus::NotRegistered));
659 }
660
661 #[test]
662 fn run_observer_records_hook_outcome() {
663 let receipt = sign_with(
664 serde_json::json!({
665 "financial": {"cost_charged": 250, "currency": "USD"}
666 }),
667 Decision::Allow,
668 );
669 let hook: Arc<dyn SettlementHook> = Arc::new(AcceptingHook);
670 let status = run_observer(
671 Some(&hook),
672 &receipt,
673 std::slice::from_ref(&receipt.kernel_key),
674 &idempotency_key(&receipt),
675 );
676 match status {
677 SettlementObserverStatus::Observed {
678 outcome: SettlementOutcome::Accepted { transcript_id, .. },
679 } => assert_eq!(transcript_id, format!("ts-{}", receipt.id)),
680 other => panic!("expected accepted outcome, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn run_observer_skips_zero_price_without_invoking_hook() {
686 let receipt = sign_with(
687 serde_json::json!({
688 "financial": {"cost_charged": 0, "currency": "USD"}
689 }),
690 Decision::Allow,
691 );
692 let hook: Arc<dyn SettlementHook> = Arc::new(FailingHook);
693 let status = run_observer(
694 Some(&hook),
695 &receipt,
696 std::slice::from_ref(&receipt.kernel_key),
697 &idempotency_key(&receipt),
698 );
699 assert!(matches!(
700 status,
701 SettlementObserverStatus::Skipped {
702 reason: SettlementSkipReason::ZeroCharge,
703 }
704 ));
705 }
706
707 #[test]
708 fn build_observation_reads_canonical_financial_metadata() {
709 let receipt = sign_with(
715 serde_json::json!({
716 "financial": {
717 "grant_index": 0,
718 "cost_charged": 250,
719 "currency": "USD",
720 "budget_remaining": 750,
721 "budget_total": 1000,
722 "delegation_depth": 1,
723 "root_budget_holder": "tenant-a",
724 "settlement_status": "pending"
725 }
726 }),
727 Decision::Allow,
728 );
729 let SettlementObservationBuild::Observation(observation) =
730 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key))
731 else {
732 panic!("canonical financial metadata did not yield an observation");
733 };
734 assert_eq!(observation.amount.units, 250);
735 assert_eq!(observation.amount.currency, "USD");
736 }
737
738 #[test]
739 fn build_observation_skips_zero_cost_charged() {
740 let receipt = sign_with(
741 serde_json::json!({
742 "financial": {
743 "cost_charged": 0,
744 "currency": "USD"
745 }
746 }),
747 Decision::Allow,
748 );
749 assert!(matches!(
750 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
751 SettlementObservationBuild::Skipped(SettlementSkipReason::ZeroCharge)
752 ));
753 }
754
755 #[test]
756 fn run_observer_records_hook_failures_without_panicking() {
757 let receipt = sign_with(
758 serde_json::json!({
759 "financial": {"cost_charged": 250, "currency": "USD"}
760 }),
761 Decision::Allow,
762 );
763 let hook: Arc<dyn SettlementHook> = Arc::new(FailingHook);
764 let status = run_observer(
765 Some(&hook),
766 &receipt,
767 std::slice::from_ref(&receipt.kernel_key),
768 &idempotency_key(&receipt),
769 );
770 assert!(matches!(
771 status,
772 SettlementObserverStatus::HookFailed {
773 class: SettlementFailureClass::Retryable,
774 reason,
775 } if reason.code() == SettlementFailureCode::Backend
776 ));
777 }
778
779 #[test]
780 fn build_observation_rejects_malformed_positive_financial_metadata() {
781 let cases = [
782 serde_json::json!({"financial": {"currency": "USD"}}),
783 serde_json::json!({"financial": {"cost_charged": "250", "currency": "USD"}}),
784 serde_json::json!({"financial": {"cost_charged": 250}}),
785 serde_json::json!({"financial": {"cost_charged": 250, "currency": "usd"}}),
786 serde_json::json!({"financial": {"cost_charged": 250, "currency": "US D"}}),
787 serde_json::json!({"financial": "invalid"}),
788 ];
789
790 for metadata in cases {
791 let receipt = sign_with(metadata, Decision::Allow);
792 assert!(matches!(
793 build_observation(&receipt, std::slice::from_ref(&receipt.kernel_key)),
794 SettlementObservationBuild::Permanent(ref reason)
795 if reason.code() == SettlementFailureCode::MalformedFinancialMetadata
796 ));
797 }
798 }
799}