Skip to main content

r402_core/wire/
payment_requirements.rs

1//! Seller-declared payment terms.
2
3use std::str::FromStr;
4
5use compact_str::CompactString;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8
9use crate::chain::ChainId;
10
11/// Payment terms set by the seller, carried inside `PaymentRequired.accepts[]`.
12///
13/// Generic parameters allow concrete chain crates to specialise the scheme
14/// name, amount representation, addresses, and scheme-specific `extra` blob.
15/// The wire-level defaults are all strings plus an opaque JSON value for
16/// `extra`, mirroring the protocol exactly.
17#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase", deny_unknown_fields)]
19#[non_exhaustive]
20pub struct PaymentRequirements<
21    TScheme = CompactString,
22    TAmount = CompactString,
23    TAddress = CompactString,
24    TExtra = serde_json::Value,
25> {
26    /// The payment scheme, e.g. `"exact"` or `"upto"`.
27    pub scheme: TScheme,
28    /// CAIP-2 chain identifier (e.g. `"eip155:8453"`).
29    pub network: ChainId,
30    /// Payment amount in the token's smallest unit (as string for precision).
31    pub amount: TAmount,
32    /// Recipient address on the target chain.
33    pub pay_to: TAddress,
34    /// Maximum time in seconds the authorization remains valid.
35    pub max_timeout_seconds: u64,
36    /// Token asset address / mint.
37    pub asset: TAddress,
38    /// Scheme-specific auxiliary data.
39    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
40    pub extra: Option<TExtra>,
41}
42
43/// Finds the first entry in `available` that matches `accepted`
44/// ([`PaymentRequirements::matches_payload_accepted`]).
45///
46/// Mirrors Go `x402ResourceServer.FindMatchingRequirements` (`server.go`).
47#[must_use]
48pub fn find_matching_requirements<'a>(
49    available: &'a [PaymentRequirements],
50    accepted: &PaymentRequirements,
51) -> Option<&'a PaymentRequirements> {
52    available
53        .iter()
54        .find(|req| req.matches_payload_accepted(accepted))
55}
56
57impl<TScheme, TAmount, TAddress, TExtra> PaymentRequirements<TScheme, TAmount, TAddress, TExtra> {
58    /// Constructs the requirements from the six required wire fields.
59    /// Use [`Self::with_extra`] / [`Self::with_optional_extra`] to attach
60    /// the scheme-specific blob.
61    #[must_use]
62    pub const fn new(
63        scheme: TScheme,
64        network: ChainId,
65        amount: TAmount,
66        pay_to: TAddress,
67        asset: TAddress,
68        max_timeout_seconds: u64,
69    ) -> Self {
70        Self {
71            scheme,
72            network,
73            amount,
74            pay_to,
75            asset,
76            max_timeout_seconds,
77            extra: None,
78        }
79    }
80
81    /// Builder: attaches the scheme-specific `extra` blob.
82    #[must_use]
83    pub fn with_extra(mut self, extra: TExtra) -> Self {
84        self.extra = Some(extra);
85        self
86    }
87
88    /// Builder: passes through an optional `extra` blob (useful when the
89    /// value is produced via `Option::map` upstream).
90    #[must_use]
91    pub fn with_optional_extra(mut self, extra: Option<TExtra>) -> Self {
92        self.extra = extra;
93        self
94    }
95}
96
97impl PaymentRequirements {
98    /// Returns true when `accepted` matches this requirement under Go
99    /// `FindMatchingRequirements` rules (`server.go`):
100    /// `scheme`, `network`, `amount`, `asset`, `payTo` must be equal.
101    ///
102    /// `maxTimeoutSeconds` and `extra` are intentionally **not** compared
103    /// (same as the foundation Go / Casper preflight convention).
104    #[must_use]
105    pub fn matches_payload_accepted(&self, accepted: &Self) -> bool {
106        self.scheme == accepted.scheme
107            && self.network == accepted.network
108            && self.amount == accepted.amount
109            && self.asset == accepted.asset
110            && self.pay_to == accepted.pay_to
111    }
112
113    /// Attempts to convert the wire-level requirements (all-strings) into
114    /// a concrete, strongly-typed variant.
115    ///
116    /// Returns `None` if any component fails to parse.
117    #[must_use]
118    pub fn as_concrete<TScheme, TAmount, TAddress, TExtra>(
119        &self,
120    ) -> Option<PaymentRequirements<TScheme, TAmount, TAddress, TExtra>>
121    where
122        TScheme: FromStr,
123        TAmount: FromStr,
124        TAddress: FromStr,
125        TExtra: DeserializeOwned,
126    {
127        let scheme = self.scheme.parse::<TScheme>().ok()?;
128        let amount = self.amount.parse::<TAmount>().ok()?;
129        let pay_to = self.pay_to.parse::<TAddress>().ok()?;
130        let asset = self.asset.parse::<TAddress>().ok()?;
131        let extra = self
132            .extra
133            .as_ref()
134            .and_then(|v| serde_json::from_value(v.clone()).ok());
135        Some(PaymentRequirements {
136            scheme,
137            network: self.network.clone(),
138            amount,
139            pay_to,
140            max_timeout_seconds: self.max_timeout_seconds,
141            asset,
142            extra,
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// F-001 regression: typo in a top-level field is rejected at parse time
152    /// rather than silently ignored, leading to clearer diagnostics.
153    #[test]
154    fn rejects_unknown_top_level_field() {
155        let json = serde_json::json!({
156            "scheme": "exact",
157            "network": "eip155:8453",
158            "amount": "1",
159            "payTo": "0x0",
160            "maxTimeoutSeconds": 60,
161            "asset": "0x0",
162            "unknownField": 1
163        });
164        assert!(serde_json::from_value::<PaymentRequirements>(json).is_err());
165    }
166
167    /// Go `FindMatchingRequirements`: scheme/network/amount/asset/payTo only.
168    #[test]
169    fn find_matching_requirements_go_semantics() {
170        let a = PaymentRequirements::new(
171            "exact".into(),
172            "eip155:1".parse().unwrap(),
173            "1000000".into(),
174            "0xrecipient1".into(),
175            "USDC".into(),
176            60,
177        );
178        let b = PaymentRequirements::new(
179            "exact".into(),
180            "eip155:8453".parse().unwrap(),
181            "2000000".into(),
182            "0xrecipient2".into(),
183            "USDC".into(),
184            30,
185        );
186        let available = [a.clone(), b.clone()];
187
188        // Match b even if maxTimeout differs on the accepted side.
189        let mut accepted = b;
190        accepted.max_timeout_seconds = 999;
191        let matched = find_matching_requirements(&available, &accepted).unwrap();
192        assert_eq!(matched.network.to_string(), "eip155:8453");
193        assert_eq!(matched.max_timeout_seconds, 30); // original available entry
194
195        // No match when scheme differs.
196        let mut miss = a;
197        miss.scheme = "nonexistent".into();
198        assert!(find_matching_requirements(&available, &miss).is_none());
199    }
200}