1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MaxAmountPerPayment {
13 Usd(MoneyAmount),
15 Disabled,
17}
18
19impl Default for MaxAmountPerPayment {
20 fn default() -> Self {
21 Self::Usd(MoneyAmount::from(1))
22 }
23}
24
25#[derive(Debug, Clone, Default)]
27pub enum AllowedAssets {
28 #[default]
30 DefaultOnly,
31 Any,
33 List(Vec<SpendControlAsset>),
35}
36
37#[derive(Debug, Clone)]
39pub struct SpendControlAsset {
40 pub network: ChainIdPattern,
42 pub asset: CompactString,
44 pub max_amount_per_payment: Option<CompactString>,
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct SpendControls {
51 pub max_amount_per_payment: MaxAmountPerPayment,
53 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}