Skip to main content

chio_credit/
obligation.rs

1use chio_core_types::canonical::canonical_json_bytes;
2use chio_core_types::capability::scope::MonetaryAmount;
3use chio_core_types::crypto::sha256_hex;
4use serde::{Deserialize, Serialize};
5
6mod credit_admission;
7mod credit_facility_bind;
8mod status;
9
10pub use credit_admission::*;
11pub use credit_facility_bind::*;
12pub use status::*;
13
14pub const OBLIGATION_ATOM_SCHEMA: &str = "chio.obligation.atom.v1";
15pub const OBLIGATION_DISPOSITION_SCHEMA: &str = "chio.obligation.disposition.v1";
16pub const OBLIGATION_CLAIM_INDEX_V1: u32 = 0;
17
18const OBLIGATION_ID_DOMAIN: &str = "chio.obligation.id.v1";
19const OBLIGATION_ATOM_DIGEST_DOMAIN: &[u8] = b"chio.obligation.atom.digest.v1\0";
20const OBLIGATION_PAYEE_BINDING_DIGEST_DOMAIN: &[u8] = b"chio.obligation.payee-binding.digest.v1\0";
21const OBLIGATION_DISPOSITION_DIGEST_DOMAIN: &[u8] = b"chio.obligation.disposition.digest.v1\0";
22const OBLIGATION_TRANSITION_DIGEST_DOMAIN: &[u8] =
23    b"chio.obligation.disposition-transition.digest.v1\0";
24const MAX_TEXT_CHARS: usize = 2_048;
25const I_JSON_MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
26
27#[derive(Serialize)]
28#[serde(rename_all = "camelCase")]
29struct ObligationPayeeBindingDigestBodyV1<'a> {
30    beneficiary_id: &'a str,
31    settlement_destination_ref: &'a str,
32}
33
34pub fn derive_obligation_payee_binding_digest(
35    beneficiary_id: &str,
36    settlement_destination_ref: &str,
37) -> Result<String, ObligationError> {
38    validate_text("payee_beneficiary_id", beneficiary_id)?;
39    validate_text(
40        "payee_settlement_destination_ref",
41        settlement_destination_ref,
42    )?;
43    domain_digest(
44        OBLIGATION_PAYEE_BINDING_DIGEST_DOMAIN,
45        &ObligationPayeeBindingDigestBodyV1 {
46            beneficiary_id,
47            settlement_destination_ref,
48        },
49    )
50}
51
52#[derive(Debug, thiserror::Error, PartialEq, Eq)]
53pub enum ObligationError {
54    #[error("invalid obligation field `{0}`")]
55    InvalidField(&'static str),
56    #[error("illegal obligation disposition transition")]
57    IllegalDispositionTransition,
58    #[error("assignment requires an exact supplemental authorization compare-and-swap")]
59    AssignmentRequiresCompareAndSwap,
60    #[error("obligation status proof authority verification failed")]
61    StatusProofAuthorityVerification,
62    #[error("obligation status proof is not current")]
63    StatusProofNotCurrent,
64    #[error("obligation compare-and-swap conflicted")]
65    CompareAndSwapConflict,
66    #[error("obligation assignment is missing supplemental authorization")]
67    MissingSupplementalAuthorization,
68    #[error("obligation assignment supplemental authorization does not match")]
69    SupplementalAuthorizationMismatch,
70    #[error("obligation canonicalization failed: {0}")]
71    Canonicalization(String),
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
76pub enum ObligationCreditElectionV1 {
77    NotCredit,
78    CreditFacility {
79        facility_id: String,
80        authority_digest: String,
81    },
82}
83
84impl ObligationCreditElectionV1 {
85    fn validate(&self) -> Result<(), ObligationError> {
86        match self {
87            Self::NotCredit => Ok(()),
88            Self::CreditFacility {
89                facility_id,
90                authority_digest,
91            } => {
92                validate_text("facility_id", facility_id)?;
93                validate_digest("credit_authority_digest", authority_digest)
94            }
95        }
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ObligationAtomInputV1 {
101    pub economic_intent_digest: String,
102    pub source_receipt_id: String,
103    pub source_receipt_digest: String,
104    pub debtor_id: String,
105    pub original_creditor_id: String,
106    pub original_settlement_destination_ref: String,
107    pub payee_binding_digest: String,
108    pub amount: MonetaryAmount,
109    pub credit_election: ObligationCreditElectionV1,
110    pub pre_action_authority_digest: String,
111    pub created_at_unix_ms: u64,
112    pub due_at_unix_ms: u64,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct ObligationAtomV1 {
118    schema: String,
119    obligation_id: String,
120    claim_index: u32,
121    economic_intent_digest: String,
122    source_receipt_id: String,
123    source_receipt_digest: String,
124    debtor_id: String,
125    original_creditor_id: String,
126    original_settlement_destination_ref: String,
127    payee_binding_digest: String,
128    amount: MonetaryAmount,
129    credit_election: ObligationCreditElectionV1,
130    pre_action_authority_digest: String,
131    created_at_unix_ms: u64,
132    due_at_unix_ms: u64,
133}
134
135impl ObligationAtomV1 {
136    pub fn new(input: ObligationAtomInputV1) -> Result<Self, ObligationError> {
137        let obligation_id =
138            derive_obligation_id(&input.economic_intent_digest, &input.source_receipt_digest)?;
139        let atom = Self {
140            schema: OBLIGATION_ATOM_SCHEMA.to_owned(),
141            obligation_id,
142            claim_index: OBLIGATION_CLAIM_INDEX_V1,
143            economic_intent_digest: input.economic_intent_digest,
144            source_receipt_id: input.source_receipt_id,
145            source_receipt_digest: input.source_receipt_digest,
146            debtor_id: input.debtor_id,
147            original_creditor_id: input.original_creditor_id,
148            original_settlement_destination_ref: input.original_settlement_destination_ref,
149            payee_binding_digest: input.payee_binding_digest,
150            amount: input.amount,
151            credit_election: input.credit_election,
152            pre_action_authority_digest: input.pre_action_authority_digest,
153            created_at_unix_ms: input.created_at_unix_ms,
154            due_at_unix_ms: input.due_at_unix_ms,
155        };
156        atom.validate()?;
157        Ok(atom)
158    }
159
160    pub fn validate(&self) -> Result<(), ObligationError> {
161        if self.schema != OBLIGATION_ATOM_SCHEMA {
162            return Err(ObligationError::InvalidField("schema"));
163        }
164        if self.claim_index != OBLIGATION_CLAIM_INDEX_V1 {
165            return Err(ObligationError::InvalidField("claim_index"));
166        }
167        validate_digest("economic_intent_digest", &self.economic_intent_digest)?;
168        validate_text("source_receipt_id", &self.source_receipt_id)?;
169        validate_digest("source_receipt_digest", &self.source_receipt_digest)?;
170        validate_text("debtor_id", &self.debtor_id)?;
171        validate_text("original_creditor_id", &self.original_creditor_id)?;
172        validate_text(
173            "original_settlement_destination_ref",
174            &self.original_settlement_destination_ref,
175        )?;
176        validate_digest("payee_binding_digest", &self.payee_binding_digest)?;
177        if derive_obligation_payee_binding_digest(
178            &self.original_creditor_id,
179            &self.original_settlement_destination_ref,
180        )? != self.payee_binding_digest
181        {
182            return Err(ObligationError::InvalidField("payee_binding_digest"));
183        }
184        validate_money(&self.amount)?;
185        self.credit_election.validate()?;
186        validate_digest(
187            "pre_action_authority_digest",
188            &self.pre_action_authority_digest,
189        )?;
190        validate_time("created_at_unix_ms", self.created_at_unix_ms)?;
191        validate_time("due_at_unix_ms", self.due_at_unix_ms)?;
192        if self.debtor_id == self.original_creditor_id
193            || self.due_at_unix_ms <= self.created_at_unix_ms
194        {
195            return Err(ObligationError::InvalidField("obligation_terms"));
196        }
197        if derive_obligation_id(&self.economic_intent_digest, &self.source_receipt_digest)?
198            != self.obligation_id
199        {
200            return Err(ObligationError::InvalidField("obligation_id"));
201        }
202        Ok(())
203    }
204
205    #[must_use]
206    pub fn obligation_id(&self) -> &str {
207        &self.obligation_id
208    }
209
210    #[must_use]
211    pub const fn claim_index(&self) -> u32 {
212        self.claim_index
213    }
214
215    #[must_use]
216    pub fn economic_intent_digest(&self) -> &str {
217        &self.economic_intent_digest
218    }
219
220    #[must_use]
221    pub fn source_receipt_id(&self) -> &str {
222        &self.source_receipt_id
223    }
224
225    #[must_use]
226    pub fn source_receipt_digest(&self) -> &str {
227        &self.source_receipt_digest
228    }
229
230    #[must_use]
231    pub fn debtor_id(&self) -> &str {
232        &self.debtor_id
233    }
234
235    #[must_use]
236    pub fn original_creditor_id(&self) -> &str {
237        &self.original_creditor_id
238    }
239
240    #[must_use]
241    pub fn original_settlement_destination_ref(&self) -> &str {
242        &self.original_settlement_destination_ref
243    }
244
245    #[must_use]
246    pub fn payee_binding_digest(&self) -> &str {
247        &self.payee_binding_digest
248    }
249
250    #[must_use]
251    pub const fn amount(&self) -> &MonetaryAmount {
252        &self.amount
253    }
254
255    #[must_use]
256    pub const fn credit_election(&self) -> &ObligationCreditElectionV1 {
257        &self.credit_election
258    }
259
260    #[must_use]
261    pub fn pre_action_authority_digest(&self) -> &str {
262        &self.pre_action_authority_digest
263    }
264
265    #[must_use]
266    pub const fn created_at_unix_ms(&self) -> u64 {
267        self.created_at_unix_ms
268    }
269
270    #[must_use]
271    pub const fn due_at_unix_ms(&self) -> u64 {
272        self.due_at_unix_ms
273    }
274
275    pub fn digest(&self) -> Result<String, ObligationError> {
276        self.validate()?;
277        domain_digest(OBLIGATION_ATOM_DIGEST_DOMAIN, self)
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283pub struct ObligationCreditorBindingV1 {
284    creditor_id: String,
285    settlement_destination_ref: String,
286}
287
288impl ObligationCreditorBindingV1 {
289    fn new(
290        creditor_id: String,
291        settlement_destination_ref: String,
292    ) -> Result<Self, ObligationError> {
293        validate_text("creditor_id", &creditor_id)?;
294        validate_text("settlement_destination_ref", &settlement_destination_ref)?;
295        Ok(Self {
296            creditor_id,
297            settlement_destination_ref,
298        })
299    }
300
301    #[must_use]
302    pub fn creditor_id(&self) -> &str {
303        &self.creditor_id
304    }
305
306    #[must_use]
307    pub fn settlement_destination_ref(&self) -> &str {
308        &self.settlement_destination_ref
309    }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
314pub enum ObligationDispositionV1 {
315    PerCall,
316    Assigned {
317        agreement_id: String,
318        creditor_id: String,
319        settlement_destination_ref: String,
320    },
321    Channelized {
322        channel_id: String,
323        reservation_id: String,
324    },
325    ClearingReserved {
326        round_id: String,
327    },
328    ClearingSatisfied {
329        round_id: String,
330        satisfaction_digest: String,
331    },
332}
333
334impl ObligationDispositionV1 {
335    fn validate(&self) -> Result<(), ObligationError> {
336        match self {
337            Self::PerCall => Ok(()),
338            Self::Assigned {
339                agreement_id,
340                creditor_id,
341                settlement_destination_ref,
342            } => {
343                validate_text("agreement_id", agreement_id)?;
344                validate_text("creditor_id", creditor_id)?;
345                validate_text("settlement_destination_ref", settlement_destination_ref)
346            }
347            Self::Channelized {
348                channel_id,
349                reservation_id,
350            } => {
351                validate_text("channel_id", channel_id)?;
352                validate_text("reservation_id", reservation_id)
353            }
354            Self::ClearingReserved { round_id } => validate_text("round_id", round_id),
355            Self::ClearingSatisfied {
356                round_id,
357                satisfaction_digest,
358            } => {
359                validate_text("round_id", round_id)?;
360                validate_digest("satisfaction_digest", satisfaction_digest)
361            }
362        }
363    }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
368pub enum ObligationDispositionTransitionV1 {
369    Produced {
370        authority_digest: String,
371    },
372    Assign {
373        operation_id: String,
374        normalized_request_digest: String,
375        status_proof_digest: String,
376        agreement_id: String,
377        creditor_id: String,
378        settlement_destination_ref: String,
379        authority_digest: String,
380    },
381    ReserveChannel {
382        channel_id: String,
383        reservation_id: String,
384        authority_digest: String,
385    },
386    ReserveClearing {
387        round_id: String,
388        authority_digest: String,
389    },
390    ReleaseClearing {
391        round_id: String,
392        abort_digest: String,
393        zero_dispatch_proof_digest: String,
394        authority_digest: String,
395    },
396    SatisfyClearing {
397        round_id: String,
398        satisfaction_digest: String,
399        authority_digest: String,
400    },
401}
402
403impl ObligationDispositionTransitionV1 {
404    fn validate(&self) -> Result<(), ObligationError> {
405        match self {
406            Self::Produced { authority_digest } => {
407                validate_digest("authority_digest", authority_digest)
408            }
409            Self::Assign {
410                operation_id,
411                normalized_request_digest,
412                status_proof_digest,
413                agreement_id,
414                creditor_id,
415                settlement_destination_ref,
416                authority_digest,
417            } => {
418                validate_digest("assignment_operation_id", operation_id)?;
419                validate_digest(
420                    "assignment_normalized_request_digest",
421                    normalized_request_digest,
422                )?;
423                validate_digest("assignment_status_proof_digest", status_proof_digest)?;
424                validate_text("agreement_id", agreement_id)?;
425                validate_text("creditor_id", creditor_id)?;
426                validate_text("settlement_destination_ref", settlement_destination_ref)?;
427                validate_digest("authority_digest", authority_digest)
428            }
429            Self::ReserveChannel {
430                channel_id,
431                reservation_id,
432                authority_digest,
433            } => {
434                validate_text("channel_id", channel_id)?;
435                validate_text("reservation_id", reservation_id)?;
436                validate_digest("authority_digest", authority_digest)
437            }
438            Self::ReserveClearing {
439                round_id,
440                authority_digest,
441            } => {
442                validate_text("round_id", round_id)?;
443                validate_digest("authority_digest", authority_digest)
444            }
445            Self::ReleaseClearing {
446                round_id,
447                abort_digest,
448                zero_dispatch_proof_digest,
449                authority_digest,
450            } => {
451                validate_text("round_id", round_id)?;
452                validate_digest("abort_digest", abort_digest)?;
453                validate_digest("zero_dispatch_proof_digest", zero_dispatch_proof_digest)?;
454                validate_digest("authority_digest", authority_digest)
455            }
456            Self::SatisfyClearing {
457                round_id,
458                satisfaction_digest,
459                authority_digest,
460            } => {
461                validate_text("round_id", round_id)?;
462                validate_digest("satisfaction_digest", satisfaction_digest)?;
463                validate_digest("authority_digest", authority_digest)
464            }
465        }
466    }
467
468    pub fn digest(&self) -> Result<String, ObligationError> {
469        self.validate()?;
470        domain_digest(OBLIGATION_TRANSITION_DIGEST_DOMAIN, self)
471    }
472}
473
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase", deny_unknown_fields)]
476pub struct ObligationDispositionRecordV1 {
477    schema: String,
478    obligation_id: String,
479    atom_digest: String,
480    disposition: ObligationDispositionV1,
481    version: u64,
482    lifecycle_fence: u64,
483    last_transition: ObligationDispositionTransitionV1,
484}
485
486impl ObligationDispositionRecordV1 {
487    pub fn produced(atom: &ObligationAtomV1) -> Result<Self, ObligationError> {
488        atom.validate()?;
489        let record = Self {
490            schema: OBLIGATION_DISPOSITION_SCHEMA.to_owned(),
491            obligation_id: atom.obligation_id.clone(),
492            atom_digest: atom.digest()?,
493            disposition: ObligationDispositionV1::PerCall,
494            version: 1,
495            lifecycle_fence: 1,
496            last_transition: ObligationDispositionTransitionV1::Produced {
497                authority_digest: atom.pre_action_authority_digest.clone(),
498            },
499        };
500        record.validate_against(atom)?;
501        Ok(record)
502    }
503
504    pub fn advance(
505        &self,
506        atom: &ObligationAtomV1,
507        transition: ObligationDispositionTransitionV1,
508    ) -> Result<Self, ObligationError> {
509        if matches!(transition, ObligationDispositionTransitionV1::Assign { .. }) {
510            return Err(ObligationError::AssignmentRequiresCompareAndSwap);
511        }
512        self.advance_inner(atom, transition)
513    }
514
515    pub fn validate_successor(
516        &self,
517        atom: &ObligationAtomV1,
518        successor: &Self,
519    ) -> Result<(), ObligationError> {
520        self.validate_against(atom)?;
521        successor.validate_against(atom)?;
522        let expected = match self.advance_inner(atom, successor.last_transition.clone()) {
523            Ok(expected) => expected,
524            Err(ObligationError::IllegalDispositionTransition) => {
525                return Err(ObligationError::InvalidField("disposition_successor"));
526            }
527            Err(error) => return Err(error),
528        };
529        if &expected == successor {
530            Ok(())
531        } else {
532            Err(ObligationError::InvalidField("disposition_successor"))
533        }
534    }
535
536    pub(super) fn advance_assignment(
537        &self,
538        atom: &ObligationAtomV1,
539        transition: ObligationDispositionTransitionV1,
540    ) -> Result<Self, ObligationError> {
541        if !matches!(transition, ObligationDispositionTransitionV1::Assign { .. }) {
542            return Err(ObligationError::IllegalDispositionTransition);
543        }
544        self.advance_inner(atom, transition)
545    }
546
547    fn advance_inner(
548        &self,
549        atom: &ObligationAtomV1,
550        transition: ObligationDispositionTransitionV1,
551    ) -> Result<Self, ObligationError> {
552        self.validate_against(atom)?;
553        transition.validate()?;
554        let disposition = match (&self.disposition, &transition) {
555            (
556                ObligationDispositionV1::PerCall,
557                ObligationDispositionTransitionV1::Assign {
558                    agreement_id,
559                    creditor_id,
560                    settlement_destination_ref,
561                    ..
562                },
563            ) if creditor_id != atom.debtor_id() && creditor_id != atom.original_creditor_id() => {
564                ObligationDispositionV1::Assigned {
565                    agreement_id: agreement_id.clone(),
566                    creditor_id: creditor_id.clone(),
567                    settlement_destination_ref: settlement_destination_ref.clone(),
568                }
569            }
570            (
571                ObligationDispositionV1::PerCall,
572                ObligationDispositionTransitionV1::ReserveChannel {
573                    channel_id,
574                    reservation_id,
575                    ..
576                },
577            ) => ObligationDispositionV1::Channelized {
578                channel_id: channel_id.clone(),
579                reservation_id: reservation_id.clone(),
580            },
581            (
582                ObligationDispositionV1::PerCall,
583                ObligationDispositionTransitionV1::ReserveClearing { round_id, .. },
584            ) => ObligationDispositionV1::ClearingReserved {
585                round_id: round_id.clone(),
586            },
587            (
588                ObligationDispositionV1::ClearingReserved { round_id },
589                ObligationDispositionTransitionV1::ReleaseClearing {
590                    round_id: released, ..
591                },
592            ) if round_id == released => ObligationDispositionV1::PerCall,
593            (
594                ObligationDispositionV1::ClearingReserved { round_id },
595                ObligationDispositionTransitionV1::SatisfyClearing {
596                    round_id: satisfied,
597                    satisfaction_digest,
598                    ..
599                },
600            ) if round_id == satisfied => ObligationDispositionV1::ClearingSatisfied {
601                round_id: round_id.clone(),
602                satisfaction_digest: satisfaction_digest.clone(),
603            },
604            _ => return Err(ObligationError::IllegalDispositionTransition),
605        };
606        let version = self
607            .version
608            .checked_add(1)
609            .ok_or(ObligationError::InvalidField("version"))?;
610        let lifecycle_fence = self
611            .lifecycle_fence
612            .checked_add(1)
613            .ok_or(ObligationError::InvalidField("lifecycle_fence"))?;
614        let next = Self {
615            schema: self.schema.clone(),
616            obligation_id: self.obligation_id.clone(),
617            atom_digest: self.atom_digest.clone(),
618            disposition,
619            version,
620            lifecycle_fence,
621            last_transition: transition,
622        };
623        next.validate_against(atom)?;
624        Ok(next)
625    }
626
627    pub fn validate_against(&self, atom: &ObligationAtomV1) -> Result<(), ObligationError> {
628        atom.validate()?;
629        if self.schema != OBLIGATION_DISPOSITION_SCHEMA {
630            return Err(ObligationError::InvalidField("schema"));
631        }
632        validate_digest("obligation_id", &self.obligation_id)?;
633        validate_digest("atom_digest", &self.atom_digest)?;
634        if self.obligation_id != atom.obligation_id || self.atom_digest != atom.digest()? {
635            return Err(ObligationError::InvalidField("atom_binding"));
636        }
637        validate_time("version", self.version)?;
638        validate_time("lifecycle_fence", self.lifecycle_fence)?;
639        if self.version != self.lifecycle_fence {
640            return Err(ObligationError::InvalidField("lifecycle_fence"));
641        }
642        self.disposition.validate()?;
643        self.last_transition.validate()?;
644        let non_genesis = !matches!(
645            &self.last_transition,
646            ObligationDispositionTransitionV1::Produced { .. }
647        );
648        if non_genesis && self.version == 1 {
649            return Err(ObligationError::InvalidField("disposition_transition"));
650        }
651        let valid = match (&self.disposition, &self.last_transition) {
652            (
653                ObligationDispositionV1::PerCall,
654                ObligationDispositionTransitionV1::Produced { authority_digest },
655            ) => {
656                self.version == 1
657                    && self.lifecycle_fence == 1
658                    && authority_digest == atom.pre_action_authority_digest()
659            }
660            (
661                ObligationDispositionV1::PerCall,
662                ObligationDispositionTransitionV1::ReleaseClearing { .. },
663            ) => self.version > 1,
664            (
665                ObligationDispositionV1::Assigned {
666                    agreement_id,
667                    creditor_id,
668                    settlement_destination_ref,
669                },
670                ObligationDispositionTransitionV1::Assign {
671                    agreement_id: transitioned_agreement,
672                    creditor_id: transitioned_creditor,
673                    settlement_destination_ref: transitioned_destination,
674                    ..
675                },
676            ) => {
677                agreement_id == transitioned_agreement
678                    && creditor_id == transitioned_creditor
679                    && settlement_destination_ref == transitioned_destination
680                    && creditor_id != atom.debtor_id()
681                    && creditor_id != atom.original_creditor_id()
682            }
683            (
684                ObligationDispositionV1::Channelized {
685                    channel_id,
686                    reservation_id,
687                },
688                ObligationDispositionTransitionV1::ReserveChannel {
689                    channel_id: transitioned_channel,
690                    reservation_id: transitioned_reservation,
691                    ..
692                },
693            ) => channel_id == transitioned_channel && reservation_id == transitioned_reservation,
694            (
695                ObligationDispositionV1::ClearingReserved { round_id },
696                ObligationDispositionTransitionV1::ReserveClearing {
697                    round_id: transitioned,
698                    ..
699                },
700            ) => round_id == transitioned,
701            (
702                ObligationDispositionV1::ClearingSatisfied {
703                    round_id,
704                    satisfaction_digest,
705                },
706                ObligationDispositionTransitionV1::SatisfyClearing {
707                    round_id: transitioned_round,
708                    satisfaction_digest: transitioned_satisfaction,
709                    ..
710                },
711            ) => round_id == transitioned_round && satisfaction_digest == transitioned_satisfaction,
712            _ => false,
713        };
714        if valid {
715            Ok(())
716        } else {
717            Err(ObligationError::InvalidField("disposition_transition"))
718        }
719    }
720
721    pub fn current_creditor(
722        &self,
723        atom: &ObligationAtomV1,
724    ) -> Result<ObligationCreditorBindingV1, ObligationError> {
725        self.validate_against(atom)?;
726        match &self.disposition {
727            ObligationDispositionV1::Assigned {
728                creditor_id,
729                settlement_destination_ref,
730                ..
731            } => ObligationCreditorBindingV1::new(
732                creditor_id.clone(),
733                settlement_destination_ref.clone(),
734            ),
735            _ => ObligationCreditorBindingV1::new(
736                atom.original_creditor_id.clone(),
737                atom.original_settlement_destination_ref.clone(),
738            ),
739        }
740    }
741
742    #[must_use]
743    pub const fn disposition(&self) -> &ObligationDispositionV1 {
744        &self.disposition
745    }
746
747    #[must_use]
748    pub fn obligation_id(&self) -> &str {
749        &self.obligation_id
750    }
751
752    #[must_use]
753    pub fn atom_digest(&self) -> &str {
754        &self.atom_digest
755    }
756
757    #[must_use]
758    pub const fn version(&self) -> u64 {
759        self.version
760    }
761
762    #[must_use]
763    pub const fn lifecycle_fence(&self) -> u64 {
764        self.lifecycle_fence
765    }
766
767    #[must_use]
768    pub const fn last_transition(&self) -> &ObligationDispositionTransitionV1 {
769        &self.last_transition
770    }
771
772    pub fn digest(&self, atom: &ObligationAtomV1) -> Result<String, ObligationError> {
773        self.validate_against(atom)?;
774        domain_digest(OBLIGATION_DISPOSITION_DIGEST_DOMAIN, self)
775    }
776}
777
778fn derive_obligation_id(
779    economic_intent_digest: &str,
780    source_receipt_digest: &str,
781) -> Result<String, ObligationError> {
782    validate_digest("economic_intent_digest", economic_intent_digest)?;
783    validate_digest("source_receipt_digest", source_receipt_digest)?;
784    let preimage = (
785        OBLIGATION_ID_DOMAIN,
786        economic_intent_digest,
787        source_receipt_digest,
788        OBLIGATION_CLAIM_INDEX_V1,
789    );
790    let bytes = canonical_json_bytes(&preimage)
791        .map_err(|error| ObligationError::Canonicalization(error.to_string()))?;
792    Ok(sha256_hex(&bytes))
793}
794
795fn domain_digest<T: Serialize>(domain: &[u8], value: &T) -> Result<String, ObligationError> {
796    let bytes = canonical_json_bytes(value)
797        .map_err(|error| ObligationError::Canonicalization(error.to_string()))?;
798    let mut preimage = Vec::with_capacity(domain.len() + bytes.len());
799    preimage.extend_from_slice(domain);
800    preimage.extend_from_slice(&bytes);
801    Ok(sha256_hex(&preimage))
802}
803
804fn validate_text(field: &'static str, value: &str) -> Result<(), ObligationError> {
805    if value.is_empty()
806        || value.trim() != value
807        || value.chars().count() > MAX_TEXT_CHARS
808        || value.chars().any(char::is_control)
809    {
810        Err(ObligationError::InvalidField(field))
811    } else {
812        Ok(())
813    }
814}
815
816fn validate_digest(field: &'static str, value: &str) -> Result<(), ObligationError> {
817    if value.len() == 64
818        && value
819            .bytes()
820            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
821    {
822        Ok(())
823    } else {
824        Err(ObligationError::InvalidField(field))
825    }
826}
827
828fn validate_money(amount: &MonetaryAmount) -> Result<(), ObligationError> {
829    if amount.units == 0
830        || amount.units > I_JSON_MAX_SAFE_INTEGER
831        || amount.currency.len() != 3
832        || !amount
833            .currency
834            .bytes()
835            .all(|byte| byte.is_ascii_uppercase())
836    {
837        Err(ObligationError::InvalidField("amount"))
838    } else {
839        Ok(())
840    }
841}
842
843fn validate_time(field: &'static str, value: u64) -> Result<(), ObligationError> {
844    if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
845        Err(ObligationError::InvalidField(field))
846    } else {
847        Ok(())
848    }
849}