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}