Skip to main content

chio_kernel/admission_operation/projection/
participant_evidence.rs

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}