Skip to main content

r402_client/
spend.rs

1//! Client spend controls. Default-on: default assets only, capped at `$1`.
2
3use compact_str::CompactString;
4use r402_protocol::{ChainIdPattern, ClientError, MoneyAmount};
5
6use crate::candidate::{DefaultAssetInfo, PaymentCandidate};
7use crate::register::PaymentClient;
8use crate::select::PaymentSelector;
9
10/// Per-payment USD cap on assets `find_default_asset` recognizes.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MaxAmountPerPayment {
13    /// Cap in USD (`MoneyAmount` of `1` is the default `$1`).
14    Usd(MoneyAmount),
15    /// No USD cap (`maxAmountPerPayment: false`).
16    Disabled,
17}
18
19impl Default for MaxAmountPerPayment {
20    fn default() -> Self {
21        Self::Usd(MoneyAmount::from(1))
22    }
23}
24
25/// Opt-in non-default assets for [`SpendControls`].
26#[derive(Debug, Clone, Default)]
27pub enum AllowedAssets {
28    /// Default assets only (omit `allowedAssets`).
29    #[default]
30    DefaultOnly,
31    /// Allow any asset; USD cap still applies to defaults.
32    Any,
33    /// Defaults plus listed entries; optional integer atomic cap per entry.
34    List(Vec<SpendControlAsset>),
35}
36
37/// Opt-in asset for [`AllowedAssets::List`].
38#[derive(Debug, Clone)]
39pub struct SpendControlAsset {
40    /// CAIP-2 network or pattern (`eip155:8453`, `eip155:*`).
41    pub network: ChainIdPattern,
42    /// On-chain asset id, or a default-asset symbol (e.g. `"PYUSD"`).
43    pub asset: CompactString,
44    /// Optional integer atomic per-payment cap. `None` means uncapped.
45    pub max_amount_per_payment: Option<CompactString>,
46}
47
48/// Client spend controls enforced before policies.
49#[derive(Debug, Clone, Default)]
50pub struct SpendControls {
51    /// Per-payment USD cap on assets `find_default_asset` recognizes.
52    pub max_amount_per_payment: MaxAmountPerPayment,
53    /// Opt-in non-default assets.
54    pub allowed_assets: AllowedAssets,
55}
56
57impl<S: PaymentSelector> PaymentClient<S> {
58    pub(crate) fn apply_spend_controls<'a>(
59        &self,
60        requirements: Vec<&'a PaymentCandidate>,
61    ) -> Result<Vec<&'a PaymentCandidate>, ClientError> {
62        let Some(controls) = self.spend_controls.as_ref() else {
63            return Ok(requirements);
64        };
65        let allowed = apply_allowlist(self, controls, &requirements);
66        if allowed.is_empty() {
67            return Err(ClientError::SpendControls(
68                "all payment requirements were rejected by spendControls: only default assets \
69                 or entries in spendControls.allowedAssets are allowed. Add an allowedAssets \
70                 entry for non-default tokens, set allowedAssets: true, or set spendControls: false."
71                    .into(),
72            ));
73        }
74        apply_amount_caps(self, controls, &allowed)
75    }
76
77    fn default_asset_for(&self, candidate: &PaymentCandidate) -> Option<DefaultAssetInfo> {
78        self.schemes.iter().find_map(|scheme| {
79            if scheme.scheme() != candidate.scheme.as_str() {
80                return None;
81            }
82            if scheme.namespace() != candidate.chain_id.namespace() {
83                return None;
84            }
85            scheme.find_default_asset(candidate.asset.as_str(), &candidate.chain_id)
86        })
87    }
88}
89
90fn is_atomic_amount(amount: &str) -> bool {
91    !amount.is_empty() && amount.bytes().all(|b| b.is_ascii_digit())
92}
93
94fn parse_atomic(amount: &str) -> Option<u128> {
95    if !is_atomic_amount(amount) {
96        return None;
97    }
98    amount.parse().ok()
99}
100
101fn amount_at_most(amount: &str, cap: u128) -> Option<bool> {
102    match parse_atomic(amount) {
103        Some(value) => Some(value <= cap),
104        None if is_atomic_amount(amount) => Some(false),
105        None => None,
106    }
107}
108
109fn matches_asset_entry(
110    entry: &SpendControlAsset,
111    candidate: &PaymentCandidate,
112    default_asset: Option<&DefaultAssetInfo>,
113) -> bool {
114    if !entry.network.matches(&candidate.chain_id) {
115        return false;
116    }
117    if entry.asset.eq_ignore_ascii_case(candidate.asset.as_str()) {
118        return true;
119    }
120    default_asset.is_some_and(|info| entry.asset.eq_ignore_ascii_case(info.symbol.as_str()))
121}
122
123fn find_asset_entry<'a>(
124    entries: &'a [SpendControlAsset],
125    candidate: &PaymentCandidate,
126    default_asset: Option<&DefaultAssetInfo>,
127) -> Option<&'a SpendControlAsset> {
128    entries
129        .iter()
130        .find(|entry| matches_asset_entry(entry, candidate, default_asset))
131}
132
133const fn listed_assets(controls: &SpendControls) -> Option<&[SpendControlAsset]> {
134    match &controls.allowed_assets {
135        AllowedAssets::Any => None,
136        AllowedAssets::DefaultOnly => Some(&[]),
137        AllowedAssets::List(entries) => Some(entries.as_slice()),
138    }
139}
140
141fn apply_allowlist<'a>(
142    client: &PaymentClient<impl PaymentSelector>,
143    controls: &SpendControls,
144    requirements: &[&'a PaymentCandidate],
145) -> Vec<&'a PaymentCandidate> {
146    if matches!(controls.allowed_assets, AllowedAssets::Any) {
147        return requirements.to_vec();
148    }
149    let entries = listed_assets(controls).unwrap_or(&[]);
150    requirements
151        .iter()
152        .copied()
153        .filter(|candidate| {
154            let default_asset = client.default_asset_for(candidate);
155            default_asset.is_some()
156                || find_asset_entry(entries, candidate, default_asset.as_ref()).is_some()
157        })
158        .collect()
159}
160
161struct AmountCapRejects {
162    by_asset_cap: bool,
163    usd_symbol: Option<CompactString>,
164}
165
166fn apply_amount_caps<'a>(
167    client: &PaymentClient<impl PaymentSelector>,
168    controls: &SpendControls,
169    requirements: &[&'a PaymentCandidate],
170) -> Result<Vec<&'a PaymentCandidate>, ClientError> {
171    let entries = listed_assets(controls).unwrap_or(&[]);
172    let mut rejects = AmountCapRejects {
173        by_asset_cap: false,
174        usd_symbol: None,
175    };
176    let mut kept = Vec::new();
177    for candidate in requirements {
178        let default_asset = client.default_asset_for(candidate);
179        let asset_entry = find_asset_entry(entries, candidate, default_asset.as_ref());
180        match candidate_cap_decision(candidate, controls, asset_entry, default_asset.as_ref())? {
181            CapDecision::Keep => kept.push(*candidate),
182            CapDecision::RejectAssetCap => rejects.by_asset_cap = true,
183            CapDecision::RejectUsd { symbol } => rejects.usd_symbol = Some(symbol),
184        }
185    }
186    if kept.is_empty() {
187        return Err(amount_cap_error(
188            client,
189            controls,
190            requirements,
191            entries,
192            &rejects,
193        ));
194    }
195    Ok(kept)
196}
197
198enum CapDecision {
199    Keep,
200    RejectAssetCap,
201    RejectUsd { symbol: CompactString },
202}
203
204fn candidate_cap_decision(
205    candidate: &PaymentCandidate,
206    controls: &SpendControls,
207    asset_entry: Option<&SpendControlAsset>,
208    default_asset: Option<&DefaultAssetInfo>,
209) -> Result<CapDecision, ClientError> {
210    if let Some(cap) = asset_entry.and_then(|entry| entry.max_amount_per_payment.as_deref()) {
211        if !is_atomic_amount(cap) {
212            return Err(ClientError::SpendControls(format!(
213                "spendControls.allowedAssets[].maxAmountPerPayment must be an integer atomic amount, not a dollar value; got {cap:?}"
214            )));
215        }
216        let cap_n = parse_atomic(cap).ok_or_else(|| {
217            ClientError::SpendControls(format!(
218                "spendControls.allowedAssets[].maxAmountPerPayment must be an integer atomic amount, not a dollar value; got {cap:?}"
219            ))
220        })?;
221        return Ok(match amount_at_most(candidate.amount.as_str(), cap_n) {
222            Some(true) => CapDecision::Keep,
223            _ => CapDecision::RejectAssetCap,
224        });
225    }
226
227    let Some(default_asset) = default_asset else {
228        return Ok(CapDecision::Keep);
229    };
230    let MaxAmountPerPayment::Usd(usd) = controls.max_amount_per_payment else {
231        return Ok(CapDecision::Keep);
232    };
233    let decimals = u8::try_from(default_asset.decimals).map_err(|_| {
234        ClientError::SpendControls(format!(
235            "default asset {} decimals {} exceed u8",
236            default_asset.symbol, default_asset.decimals
237        ))
238    })?;
239    let max_atomic: u128 = usd.to_token_amount(decimals).map_err(|err| {
240        ClientError::SpendControls(format!(
241            "spendControls.maxAmountPerPayment cannot convert {usd} at {} decimals: {err}",
242            default_asset.decimals
243        ))
244    })?;
245    Ok(
246        match amount_at_most(candidate.amount.as_str(), max_atomic) {
247            Some(true) => CapDecision::Keep,
248            _ => CapDecision::RejectUsd {
249                symbol: default_asset.symbol.clone(),
250            },
251        },
252    )
253}
254
255fn amount_cap_error(
256    client: &PaymentClient<impl PaymentSelector>,
257    controls: &SpendControls,
258    before_caps: &[&PaymentCandidate],
259    entries: &[SpendControlAsset],
260    rejects: &AmountCapRejects,
261) -> ClientError {
262    let all_asset_capped = rejects.by_asset_cap
263        && before_caps.iter().all(|candidate| {
264            let default_asset = client.default_asset_for(candidate);
265            find_asset_entry(entries, candidate, default_asset.as_ref())
266                .and_then(|entry| entry.max_amount_per_payment.as_ref())
267                .is_some()
268        });
269    if all_asset_capped {
270        return ClientError::SpendControls(
271            "all payment requirements were rejected by spendControls.allowedAssets maxAmountPerPayment. \
272             Raise the per-asset cap, or omit maxAmountPerPayment to allow uncapped \
273             (default assets then fall back to the top-level USD cap)."
274                .into(),
275        );
276    }
277    let usd_limit = match controls.max_amount_per_payment {
278        MaxAmountPerPayment::Disabled => "false".to_owned(),
279        MaxAmountPerPayment::Usd(amount) => format!("${amount}"),
280    };
281    let symbol_note = rejects
282        .usd_symbol
283        .as_ref()
284        .map(|symbol| format!(", including {symbol}"))
285        .unwrap_or_default();
286    ClientError::SpendControls(format!(
287        "all payment requirements were rejected by spendControls.maxAmountPerPayment \
288         ({usd_limit}{symbol_note}). Raise maxAmountPerPayment, set it to false to disable, \
289         set allowedAssets[].maxAmountPerPayment for a per-asset atomic cap, \
290         or set spendControls: false to disable all spend controls."
291    ))
292}