Skip to main content

chio_credit/factor/
mod.rs

1use 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}