1use chio_core::capability::scope::MonetaryAmount;
2pub use chio_credit::obligation::ObligationDispositionV1;
3#[cfg(any(test, feature = "admission-test-support"))]
4use chio_credit::obligation::{ObligationAtomInputV1, ObligationCreditElectionV1};
5use chio_credit::obligation::{
6 ObligationAtomV1, ObligationDispositionRecordV1, ObligationDispositionTransitionV1,
7};
8
9use super::*;
10use crate::kernel::VerifiedGovernedPayeeBinding;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13struct VerifiedTerminalParticipantSourceV1 {
14 binding: AdmissionExactProjectionBindingV1,
15 source_authority_digest: AdmissionDigest,
16 source_record_id: AdmissionIdentifier,
17 source_record_digest: AdmissionDigest,
18 source_recorded_at_unix_ms: u64,
19 consumer_receipt_id: AdmissionIdentifier,
20 consumer_receipt_digest: AdmissionDigest,
21 outcome_id: AdmissionDigest,
22 outcome_version: u64,
23}
24
25impl VerifiedTerminalParticipantSourceV1 {
26 #[allow(clippy::too_many_arguments)]
27 fn from_source_verified(
28 operation: &AdmissionOperationV1,
29 context: &AdmissionProjectionContext,
30 receipt: &VerifiedAdmissionReceipt,
31 source_authority_digest: AdmissionDigest,
32 source_record_id: AdmissionIdentifier,
33 source_record_digest: AdmissionDigest,
34 source_recorded_at_unix_ms: u64,
35 outcome_id: AdmissionDigest,
36 outcome_version: u64,
37 ) -> Result<Self, AdmissionOperationError> {
38 let receipt = receipt.receipt();
39 validate_positive_ijson("source_recorded_at_unix_ms", source_recorded_at_unix_ms)?;
40 validate_positive_ijson("participant_outcome_version", outcome_version)?;
41 operation.validate_completed_tool_outcome_attachment(&outcome_id)?;
42 if source_recorded_at_unix_ms > context.trusted_time_unix_ms {
43 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
44 }
45 Ok(Self {
46 binding: AdmissionExactProjectionBindingV1::from_verified(
47 operation,
48 context,
49 AdmissionOperationState::Completed,
50 )?,
51 source_authority_digest,
52 source_record_id,
53 source_record_digest,
54 source_recorded_at_unix_ms,
55 consumer_receipt_id: AdmissionIdentifier::try_new(
56 "consumer_receipt_id",
57 receipt.id.clone(),
58 )?,
59 consumer_receipt_digest: receipt_digest(receipt)?,
60 outcome_id,
61 outcome_version,
62 })
63 }
64
65 fn validate_against(
66 &self,
67 operation: &AdmissionOperationV1,
68 context: &AdmissionProjectionContext,
69 receipt: &VerifiedAdmissionReceipt,
70 outcome_id: &AdmissionDigest,
71 outcome_version: u64,
72 ) -> Result<(), AdmissionOperationError> {
73 let receipt = receipt.receipt();
74 self.binding
75 .validate_against(operation, context, AdmissionOperationState::Completed)?;
76 validate_positive_ijson(
77 "source_recorded_at_unix_ms",
78 self.source_recorded_at_unix_ms,
79 )?;
80 validate_positive_ijson("participant_outcome_version", self.outcome_version)?;
81 operation.validate_completed_tool_outcome_attachment(outcome_id)?;
82 if self.source_recorded_at_unix_ms > context.trusted_time_unix_ms
83 || self.consumer_receipt_id.as_str() != receipt.id
84 || self.consumer_receipt_digest != receipt_digest(receipt)?
85 || self.outcome_id != *outcome_id
86 || self.outcome_version != outcome_version
87 {
88 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
89 }
90 Ok(())
91 }
92}
93
94macro_rules! attached_terminal_participant {
95 ($name:ident, $field:ident, $ty:ty, $kind:ident, $variant:ident) => {
96 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97 pub struct $name {
98 source: VerifiedTerminalParticipantSourceV1,
99 $field: $ty,
100 }
101
102 impl $name {
103 #[allow(clippy::too_many_arguments, dead_code)]
104 pub(crate) fn from_source_verified(
105 operation: &AdmissionOperationV1,
106 context: &AdmissionProjectionContext,
107 receipt: &VerifiedAdmissionReceipt,
108 $field: $ty,
109 source_authority_digest: AdmissionDigest,
110 source_record_id: AdmissionIdentifier,
111 source_record_digest: AdmissionDigest,
112 source_recorded_at_unix_ms: u64,
113 outcome_id: AdmissionDigest,
114 outcome_version: u64,
115 ) -> Result<Self, AdmissionOperationError> {
116 if !matches!(
117 operation.attachment(AdmissionAttachmentKind::$kind),
118 Some(AdmissionAttachment::$variant(expected)) if expected == &$field
119 ) {
120 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
121 }
122 Ok(Self {
123 source: VerifiedTerminalParticipantSourceV1::from_source_verified(
124 operation,
125 context,
126 receipt,
127 source_authority_digest,
128 source_record_id,
129 source_record_digest,
130 source_recorded_at_unix_ms,
131 outcome_id,
132 outcome_version,
133 )?,
134 $field,
135 })
136 }
137
138 pub(in crate::admission_operation) fn validate_against(
139 &self,
140 operation: &AdmissionOperationV1,
141 context: &AdmissionProjectionContext,
142 receipt: &VerifiedAdmissionReceipt,
143 outcome_id: &AdmissionDigest,
144 outcome_version: u64,
145 ) -> Result<(), AdmissionOperationError> {
146 if !matches!(
147 operation.attachment(AdmissionAttachmentKind::$kind),
148 Some(AdmissionAttachment::$variant(expected)) if expected == &self.$field
149 ) {
150 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
151 }
152 self.source.validate_against(
153 operation,
154 context,
155 receipt,
156 outcome_id,
157 outcome_version,
158 )
159 }
160 }
161 };
162}
163
164attached_terminal_participant!(
165 PaymentTerminalEvidence,
166 payment_participant_id,
167 AdmissionIdentifier,
168 PaymentParticipant,
169 PaymentParticipantId
170);
171attached_terminal_participant!(
172 OutcomeEligibilityFinalization,
173 outcome_eligibility_digest,
174 AdmissionDigest,
175 OutcomeEligibility,
176 OutcomeEligibilityDigest
177);
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
180pub struct ObligationProjection {
181 source: VerifiedTerminalParticipantSourceV1,
182 atom: ObligationAtomV1,
183 disposition_record: ObligationDispositionRecordV1,
184}
185
186impl ObligationProjection {
187 #[allow(dead_code)]
188 pub(crate) fn from_verified_economic_receipt(
189 operation: &AdmissionOperationV1,
190 context: &AdmissionProjectionContext,
191 receipt: &VerifiedAdmissionReceipt,
192 payee_binding: &VerifiedGovernedPayeeBinding,
193 atom: ObligationAtomV1,
194 outcome_id: AdmissionDigest,
195 outcome_version: u64,
196 ) -> Result<Self, AdmissionOperationError> {
197 Self::from_verified_economic_receipt_inner(
198 operation,
199 context,
200 receipt,
201 Some(payee_binding),
202 atom,
203 outcome_id,
204 outcome_version,
205 )
206 }
207
208 #[cfg(any(test, feature = "admission-test-support"))]
209 #[allow(clippy::too_many_arguments)]
210 pub fn from_credit_source_verified(
211 operation: &AdmissionOperationV1,
212 context: &AdmissionProjectionContext,
213 receipt: &VerifiedAdmissionReceipt,
214 atom: ObligationAtomV1,
215 outcome_id: AdmissionDigest,
216 outcome_version: u64,
217 ) -> Result<Self, AdmissionOperationError> {
218 if !matches!(
219 atom.credit_election(),
220 ObligationCreditElectionV1::CreditFacility { .. }
221 ) {
222 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
223 }
224 Self::from_verified_economic_receipt_inner(
225 operation,
226 context,
227 receipt,
228 None,
229 atom,
230 outcome_id,
231 outcome_version,
232 )
233 }
234
235 #[allow(clippy::too_many_arguments)]
236 fn from_verified_economic_receipt_inner(
237 operation: &AdmissionOperationV1,
238 context: &AdmissionProjectionContext,
239 receipt: &VerifiedAdmissionReceipt,
240 payee_binding: Option<&VerifiedGovernedPayeeBinding>,
241 atom: ObligationAtomV1,
242 outcome_id: AdmissionDigest,
243 outcome_version: u64,
244 ) -> Result<Self, AdmissionOperationError> {
245 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
246 let requirements = operation.binding().participant_requirements();
247 if !requirements.obligation || requirements.channel {
248 return Err(mismatch());
249 }
250 validate_economic_receipt_obligation(&atom, receipt.receipt(), payee_binding)?;
251 validate_obligation_terms(
252 atom.amount(),
253 atom.due_at_unix_ms(),
254 atom.created_at_unix_ms(),
255 )?;
256 let source_authority_digest = AdmissionDigest::try_new(
257 "obligation_source_authority_digest",
258 atom.pre_action_authority_digest().to_owned(),
259 )?;
260 let obligation_id =
261 AdmissionIdentifier::try_new("obligation_id", atom.obligation_id().to_owned())?;
262 let obligation_atom_digest = AdmissionDigest::try_new(
263 "obligation_atom_digest",
264 atom.digest().map_err(|_| mismatch())?,
265 )?;
266 let disposition_record =
267 ObligationDispositionRecordV1::produced(&atom).map_err(|_| mismatch())?;
268 let projection = Self {
269 source: VerifiedTerminalParticipantSourceV1::from_source_verified(
270 operation,
271 context,
272 receipt,
273 source_authority_digest,
274 obligation_id,
275 obligation_atom_digest,
276 atom.created_at_unix_ms(),
277 outcome_id.clone(),
278 outcome_version,
279 )?,
280 atom,
281 disposition_record,
282 };
283 projection.validate_against(operation, context, receipt, &outcome_id, outcome_version)?;
284 Ok(projection)
285 }
286
287 pub(crate) fn from_verified_channel_advance(
288 operation: &AdmissionOperationV1,
289 context: &AdmissionProjectionContext,
290 receipt: &VerifiedAdmissionReceipt,
291 tool_outcome: &ToolOutcomeTerminalEvidenceV1,
292 advance: &VerifiedChannelTerminalAdvanceV1,
293 ) -> Result<Option<Self>, AdmissionOperationError> {
294 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
295 let Some(atom) = advance.obligation_atom() else {
296 if advance.actual_charge().units != 0
297 || advance.obligation_atom_id().is_some()
298 || advance.obligation_atom_digest().is_some()
299 {
300 return Err(mismatch());
301 }
302 return Ok(None);
303 };
304 atom.validate().map_err(|_| mismatch())?;
305 let atom_digest = AdmissionDigest::try_new(
306 "channel_obligation_atom_digest",
307 atom.digest().map_err(|_| mismatch())?,
308 )?;
309 let obligation_id = AdmissionIdentifier::try_new(
310 "channel_obligation_atom_id",
311 atom.obligation_id().to_owned(),
312 )?;
313 if advance.actual_charge().units == 0
314 || advance.obligation_atom_id() != Some(obligation_id.as_str())
315 || advance.obligation_atom_digest() != Some(atom_digest.as_str())
316 || atom.amount() != advance.actual_charge()
317 || atom.source_receipt_id() != receipt.receipt().id
318 || atom.source_receipt_digest() != receipt_digest(receipt.receipt())?.as_str()
319 || atom.pre_action_authority_digest() != advance.reservation_digest()
320 {
321 return Err(mismatch());
322 }
323 validate_obligation_terms(
324 atom.amount(),
325 atom.due_at_unix_ms(),
326 atom.created_at_unix_ms(),
327 )?;
328 let disposition_record = ObligationDispositionRecordV1::produced(atom)
329 .and_then(|produced| {
330 produced.advance(
331 atom,
332 ObligationDispositionTransitionV1::ReserveChannel {
333 channel_id: advance.channel_id().to_owned(),
334 reservation_id: advance.reservation_id().to_owned(),
335 authority_digest: advance.reservation_digest().to_owned(),
336 },
337 )
338 })
339 .map_err(|_| mismatch())?;
340 let projection = Self {
341 source: VerifiedTerminalParticipantSourceV1::from_source_verified(
342 operation,
343 context,
344 receipt,
345 AdmissionDigest::try_new(
346 "channel_obligation_source_authority_digest",
347 atom.pre_action_authority_digest().to_owned(),
348 )?,
349 obligation_id,
350 atom_digest,
351 atom.created_at_unix_ms(),
352 tool_outcome.outcome_id().clone(),
353 tool_outcome.outcome_version(),
354 )?,
355 atom: atom.clone(),
356 disposition_record,
357 };
358 projection.validate_against(
359 operation,
360 context,
361 receipt,
362 tool_outcome.outcome_id(),
363 tool_outcome.outcome_version(),
364 )?;
365 Ok(Some(projection))
366 }
367
368 #[cfg(any(test, feature = "admission-test-support"))]
369 #[allow(clippy::too_many_arguments)]
370 pub fn from_source_verified(
371 operation: &AdmissionOperationV1,
372 context: &AdmissionProjectionContext,
373 receipt: &VerifiedAdmissionReceipt,
374 debtor_id: AdmissionIdentifier,
375 original_creditor_id: AdmissionIdentifier,
376 amount: MonetaryAmount,
377 due_at_unix_ms: u64,
378 disposition: ObligationDispositionV1,
379 source_authority_digest: AdmissionDigest,
380 outcome_id: AdmissionDigest,
381 outcome_version: u64,
382 ) -> Result<Self, AdmissionOperationError> {
383 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
384 let receipt_digest = receipt_digest(receipt.receipt())?;
385 let atom = ObligationAtomV1::new(ObligationAtomInputV1 {
386 economic_intent_digest: operation
387 .binding()
388 .request_binding_hash()
389 .as_str()
390 .to_owned(),
391 source_receipt_id: receipt.receipt().id.clone(),
392 source_receipt_digest: receipt_digest.as_str().to_owned(),
393 debtor_id: debtor_id.as_str().to_owned(),
394 original_creditor_id: original_creditor_id.as_str().to_owned(),
395 original_settlement_destination_ref: format!(
396 "settlement:{}",
397 original_creditor_id.as_str()
398 ),
399 payee_binding_digest: chio_credit::obligation::derive_obligation_payee_binding_digest(
400 original_creditor_id.as_str(),
401 &format!("settlement:{}", original_creditor_id.as_str()),
402 )
403 .map_err(|_| mismatch())?,
404 amount,
405 credit_election: ObligationCreditElectionV1::NotCredit,
406 pre_action_authority_digest: source_authority_digest.as_str().to_owned(),
407 created_at_unix_ms: context.trusted_time_unix_ms,
408 due_at_unix_ms,
409 })
410 .map_err(|_| mismatch())?;
411 let disposition_record =
412 ObligationDispositionRecordV1::produced(&atom).map_err(|_| mismatch())?;
413 if disposition_record.disposition() != &disposition {
414 return Err(mismatch());
415 }
416 validate_obligation_terms(
417 atom.amount(),
418 atom.due_at_unix_ms(),
419 atom.created_at_unix_ms(),
420 )?;
421 let obligation_id =
422 AdmissionIdentifier::try_new("obligation_id", atom.obligation_id().to_owned())?;
423 let obligation_atom_digest = AdmissionDigest::try_new(
424 "obligation_atom_digest",
425 atom.digest()
426 .map_err(|_| AdmissionOperationError::TerminalProjectionBindingMismatch)?,
427 )?;
428 Ok(Self {
429 source: VerifiedTerminalParticipantSourceV1::from_source_verified(
430 operation,
431 context,
432 receipt,
433 source_authority_digest,
434 obligation_id,
435 obligation_atom_digest,
436 atom.created_at_unix_ms(),
437 outcome_id,
438 outcome_version,
439 )?,
440 atom,
441 disposition_record,
442 })
443 }
444
445 pub(in crate::admission_operation) fn validate_against(
446 &self,
447 operation: &AdmissionOperationV1,
448 context: &AdmissionProjectionContext,
449 receipt: &VerifiedAdmissionReceipt,
450 outcome_id: &AdmissionDigest,
451 outcome_version: u64,
452 ) -> Result<(), AdmissionOperationError> {
453 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
454 self.atom.validate().map_err(|_| mismatch())?;
455 self.disposition_record
456 .validate_against(&self.atom)
457 .map_err(|_| mismatch())?;
458 validate_obligation_terms(
459 self.atom.amount(),
460 self.atom.due_at_unix_ms(),
461 self.atom.created_at_unix_ms(),
462 )?;
463 if self.source.source_recorded_at_unix_ms > context.trusted_time_unix_ms
464 || self.source.source_record_id.as_str() != self.atom.obligation_id()
465 || self.source.source_record_digest.as_str()
466 != self.atom.digest().map_err(|_| mismatch())?
467 || self.source.source_authority_digest.as_str()
468 != self.atom.pre_action_authority_digest()
469 || self.disposition_record.obligation_id() != self.atom.obligation_id()
470 {
471 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
472 }
473 if receipt.receipt().channel_metadata().is_none()
474 && receipt.receipt().governed_transaction_metadata().is_some()
475 {
476 validate_economic_receipt_obligation(&self.atom, receipt.receipt(), None)?;
477 }
478 self.source
479 .validate_against(operation, context, receipt, outcome_id, outcome_version)
480 }
481
482 pub(in crate::admission_operation) fn obligation_id(&self) -> &AdmissionIdentifier {
483 &self.source.source_record_id
484 }
485
486 pub(in crate::admission_operation) fn atom_digest(&self) -> &AdmissionDigest {
487 &self.source.source_record_digest
488 }
489
490 #[cfg(test)]
491 pub(crate) const fn atom(&self) -> &ObligationAtomV1 {
492 &self.atom
493 }
494
495 #[cfg(test)]
496 pub(crate) const fn disposition_record(&self) -> &ObligationDispositionRecordV1 {
497 &self.disposition_record
498 }
499
500 pub(in crate::admission_operation) const fn amount(&self) -> &MonetaryAmount {
501 self.atom.amount()
502 }
503
504 pub(in crate::admission_operation) const fn disposition(&self) -> &ObligationDispositionV1 {
505 self.disposition_record.disposition()
506 }
507}
508
509fn validate_economic_receipt_obligation(
510 atom: &ObligationAtomV1,
511 receipt: &ChioReceipt,
512 payee_binding: Option<&VerifiedGovernedPayeeBinding>,
513) -> Result<(), AdmissionOperationError> {
514 let mismatch = || AdmissionOperationError::TerminalProjectionBindingMismatch;
515 atom.validate().map_err(|_| mismatch())?;
516 let governed = receipt
517 .governed_transaction_metadata()
518 .ok_or_else(mismatch)?;
519 let commerce = governed.commerce.as_ref().ok_or_else(mismatch)?;
520 let approval = governed.approval.as_ref().ok_or_else(mismatch)?;
521 let economic = governed
522 .economic_authorization
523 .as_ref()
524 .ok_or_else(mismatch)?;
525 let financial = receipt.financial_metadata().ok_or_else(mismatch)?;
526 let receipt_digest = receipt_digest(receipt)?;
527 let payee_binding_digest = chio_credit::obligation::derive_obligation_payee_binding_digest(
528 &economic.payee.beneficiary_id,
529 &economic.payee.settlement_destination_ref,
530 )
531 .map_err(|_| mismatch())?;
532 if !receipt.is_allowed()
533 || !approval.approved
534 || approval.approval_artifact_digest.as_deref() != Some(atom.pre_action_authority_digest())
535 || financial.cost_charged == 0
536 || economic.budget.cost_charged != financial.cost_charged
537 || economic.budget.currency != financial.currency
538 || economic.settlement.settlement_status != financial.settlement_status
539 || economic.economic_intent_digest.as_deref() != Some(governed.intent_hash.as_str())
540 || economic.payee_binding_digest.as_deref() != Some(atom.payee_binding_digest())
541 || payee_binding_digest != atom.payee_binding_digest()
542 || economic.pre_action_authority_digest.as_deref()
543 != Some(atom.pre_action_authority_digest())
544 || commerce.seller != economic.payee.beneficiary_id
545 || commerce.settlement_destination_ref.as_deref()
546 != Some(economic.payee.settlement_destination_ref.as_str())
547 || economic.merchant.merchant_id != commerce.seller
548 || atom.economic_intent_digest() != governed.intent_hash
549 || atom.source_receipt_id() != receipt.id
550 || atom.source_receipt_digest() != receipt_digest.as_str()
551 || atom.debtor_id() != economic.payer.party_id
552 || atom.original_creditor_id() != economic.payee.beneficiary_id
553 || atom.original_settlement_destination_ref() != economic.payee.settlement_destination_ref
554 || atom.amount().units != financial.cost_charged
555 || atom.amount().currency != financial.currency
556 {
557 return Err(mismatch());
558 }
559 if let Some(payee_binding) = payee_binding {
560 if payee_binding.economic_intent_digest() != governed.intent_hash
561 || payee_binding.beneficiary_id() != economic.payee.beneficiary_id
562 || payee_binding.settlement_destination_ref()
563 != economic.payee.settlement_destination_ref
564 || payee_binding.payee_binding_digest() != atom.payee_binding_digest()
565 || payee_binding.pre_action_authority_digest() != atom.pre_action_authority_digest()
566 {
567 return Err(mismatch());
568 }
569 }
570 Ok(())
571}
572
573fn validate_obligation_terms(
574 amount: &MonetaryAmount,
575 due_at_unix_ms: u64,
576 created_at_unix_ms: u64,
577) -> Result<(), AdmissionOperationError> {
578 validate_positive_ijson("obligation_amount_units", amount.units)?;
579 validate_positive_ijson("obligation_due_at_unix_ms", due_at_unix_ms)?;
580 if amount.currency.len() != 3
581 || !amount
582 .currency
583 .bytes()
584 .all(|byte| byte.is_ascii_uppercase())
585 || due_at_unix_ms <= created_at_unix_ms
586 {
587 return Err(AdmissionOperationError::TerminalProjectionBindingMismatch);
588 }
589 Ok(())
590}