chio_credit/factor/
mod.rs1use chio_core_types::canonical::canonical_json_bytes;
2use chio_core_types::capability::scope::MonetaryAmount;
3use chio_core_types::crypto::{sha256_hex, Keypair, PublicKey, Signature};
4use chio_core_types::receipt::lineage::SignedExportEnvelope;
5pub use chio_core_types::{
6 CHIO_FACTOR_ASSIGNMENT_ACKNOWLEDGEMENT_V1_SCHEMA, CHIO_FACTOR_ASSIGNMENT_AGREEMENT_V1_SCHEMA,
7 CHIO_FACTOR_ASSIGNMENT_BIND_AUTHORIZATION_V1_SCHEMA,
8 CHIO_FACTOR_ASSIGNMENT_NOT_APPLIED_V1_SCHEMA,
9};
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11
12pub const FACTOR_ASSIGNMENT_BIND_ACTION: &str = "factor.assignment_bind";
13pub const FACTOR_ASSIGNMENT_BIND_AUTHORIZATION_SCHEMA: &str =
14 CHIO_FACTOR_ASSIGNMENT_BIND_AUTHORIZATION_V1_SCHEMA;
15pub const FACTOR_NORMALIZED_ASSIGNMENT_REQUEST_SCHEMA: &str =
16 "chio.factor.normalized-assignment-request.v1";
17pub const FACTOR_RECEIVABLE_CLAIM_SCHEMA: &str = "chio.factor.receivable-claim.v1";
18pub const FACTOR_ASSIGNMENT_OFFER_SCHEMA: &str = "chio.factor.assignment-offer.v1";
19pub const FACTOR_DISCOUNT_QUOTE_SCHEMA: &str = "chio.factor.discount-quote.v1";
20pub const FACTOR_ASSIGNMENT_AGREEMENT_SCHEMA: &str = CHIO_FACTOR_ASSIGNMENT_AGREEMENT_V1_SCHEMA;
21pub const FACTOR_ASSIGNMENT_ACKNOWLEDGEMENT_SCHEMA: &str =
22 CHIO_FACTOR_ASSIGNMENT_ACKNOWLEDGEMENT_V1_SCHEMA;
23pub const FACTOR_ASSIGNMENT_NOT_APPLIED_SCHEMA: &str = CHIO_FACTOR_ASSIGNMENT_NOT_APPLIED_V1_SCHEMA;
24
25const NORMALIZED_REQUEST_DIGEST_DOMAIN: &[u8] =
26 b"chio.factor.normalized-assignment-request.digest.v1\0";
27const CLAIM_ID_DOMAIN: &[u8] = b"chio.factor.receivable-claim.id.v1\0";
28const CLAIM_DIGEST_DOMAIN: &[u8] = b"chio.factor.receivable-claim.digest.v1\0";
29const OFFER_ID_DOMAIN: &[u8] = b"chio.factor.assignment-offer.id.v1\0";
30const OFFER_DIGEST_DOMAIN: &[u8] = b"chio.factor.assignment-offer.digest.v1\0";
31const QUOTE_ID_DOMAIN: &[u8] = b"chio.factor.discount-quote.id.v1\0";
32const QUOTE_DIGEST_DOMAIN: &[u8] = b"chio.factor.discount-quote.digest.v1\0";
33const AUTHORIZATION_BODY_DIGEST_DOMAIN: &[u8] =
34 b"chio.factor.assignment-bind-authorization.body.v1\0";
35const AGREEMENT_BODY_DIGEST_DOMAIN: &[u8] = b"chio.factor.assignment-agreement.body.v1\0";
36const AGREEMENT_SIGNATURE_DIGEST_DOMAIN: &[u8] = b"chio.factor.assignment-agreement.signature.v1\0";
37const AUTHORIZATION_SET_DIGEST_DOMAIN: &[u8] = b"chio.factor.assignment-authorization-set.v1\0";
38const ACKNOWLEDGEMENT_ID_DOMAIN: &[u8] = b"chio.factor.assignment-acknowledgement.id.v1\0";
39const ACKNOWLEDGEMENT_BODY_DIGEST_DOMAIN: &[u8] =
40 b"chio.factor.assignment-acknowledgement.body.v1\0";
41const NOT_APPLIED_ID_DOMAIN: &[u8] = b"chio.factor.assignment-not-applied.id.v1\0";
42const NOT_APPLIED_BODY_DIGEST_DOMAIN: &[u8] = b"chio.factor.assignment-not-applied.body.v1\0";
43const MAX_TEXT_CHARS: usize = 2_048;
44const I_JSON_MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
45const BASIS_POINTS_DENOMINATOR: u16 = 10_000;
46
47#[derive(Debug, thiserror::Error, PartialEq, Eq)]
48pub enum FactorError {
49 #[error("invalid factor field '{0}'")]
50 InvalidField(&'static str),
51 #[error("factor binding does not match")]
52 BindingMismatch,
53 #[error("factor authority verification failed")]
54 AuthorityVerification,
55 #[error("factor artifact is not current")]
56 NotCurrent,
57 #[error("factor arithmetic overflow")]
58 ArithmeticOverflow,
59 #[error("factor canonicalization failed: {0}")]
60 Canonicalization(String),
61}
62
63pub type FactorAuthorizationError = FactorError;
64
65mod authorization;
66mod claim;
67mod request;
68mod result;
69
70pub use authorization::*;
71pub use claim::*;
72pub use request::*;
73pub use result::*;
74
75fn discounted_amount(
76 face_value: &MonetaryAmount,
77 discount_bps: u16,
78) -> Result<MonetaryAmount, FactorError> {
79 validate_amount("discount_face_value", face_value, false)?;
80 validate_basis_points(discount_bps)?;
81 let retained_bps = BASIS_POINTS_DENOMINATOR
82 .checked_sub(discount_bps)
83 .ok_or(FactorError::ArithmeticOverflow)?;
84 let numerator = u128::from(face_value.units)
85 .checked_mul(u128::from(retained_bps))
86 .ok_or(FactorError::ArithmeticOverflow)?;
87 let units = numerator / u128::from(BASIS_POINTS_DENOMINATOR);
88 Ok(MonetaryAmount {
89 units: u64::try_from(units).map_err(|_| FactorError::ArithmeticOverflow)?,
90 currency: face_value.currency.clone(),
91 })
92}
93
94fn canonical_bytes(value: &impl Serialize) -> Result<Vec<u8>, FactorError> {
95 canonical_json_bytes(value).map_err(|error| FactorError::Canonicalization(error.to_string()))
96}
97
98fn parse_canonical<T>(bytes: &[u8], artifact: &str) -> Result<T, FactorError>
99where
100 T: DeserializeOwned + Serialize,
101{
102 let value: T = serde_json::from_slice(bytes)
103 .map_err(|error| FactorError::Canonicalization(error.to_string()))?;
104 if canonical_bytes(&value)?.as_slice() != bytes {
105 return Err(FactorError::Canonicalization(format!(
106 "{artifact} is not canonical"
107 )));
108 }
109 Ok(value)
110}
111
112fn domain_digest(domain: &[u8], value: &impl Serialize) -> Result<String, FactorError> {
113 let bytes = canonical_bytes(value)?;
114 let mut preimage = Vec::with_capacity(domain.len() + bytes.len());
115 preimage.extend_from_slice(domain);
116 preimage.extend_from_slice(&bytes);
117 Ok(sha256_hex(&preimage))
118}
119
120fn validate_text(field: &'static str, value: &str) -> Result<(), FactorError> {
121 let disallowed_control =
122 |character: char| matches!(character as u32, 0x00..=0x1f | 0x7f..=0x9f);
123 if value.is_empty()
124 || value.trim() != value
125 || value.chars().count() > MAX_TEXT_CHARS
126 || value.chars().any(disallowed_control)
127 {
128 Err(FactorError::InvalidField(field))
129 } else {
130 Ok(())
131 }
132}
133
134fn validate_digest(field: &'static str, value: &str) -> Result<(), FactorError> {
135 if value.len() == 64
136 && value
137 .bytes()
138 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
139 {
140 Ok(())
141 } else {
142 Err(FactorError::InvalidField(field))
143 }
144}
145
146fn validate_positive(field: &'static str, value: u64) -> Result<(), FactorError> {
147 if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
148 Err(FactorError::InvalidField(field))
149 } else {
150 Ok(())
151 }
152}
153
154fn validate_basis_points(value: u16) -> Result<(), FactorError> {
155 if value > BASIS_POINTS_DENOMINATOR {
156 Err(FactorError::InvalidField("discount_bps"))
157 } else {
158 Ok(())
159 }
160}
161
162fn validate_amount(
163 field: &'static str,
164 amount: &MonetaryAmount,
165 allow_zero: bool,
166) -> Result<(), FactorError> {
167 if (!allow_zero && amount.units == 0)
168 || amount.units > I_JSON_MAX_SAFE_INTEGER
169 || amount.currency.len() != 3
170 || !amount
171 .currency
172 .bytes()
173 .all(|byte| byte.is_ascii_uppercase())
174 {
175 Err(FactorError::InvalidField(field))
176 } else {
177 Ok(())
178 }
179}