x402_kit/seller/
toolkit.rs

1use std::fmt::Display;
2
3use http::{HeaderMap, StatusCode};
4
5use crate::{
6    concepts::Facilitator,
7    transport::{
8        Base64EncodedHeader, FacilitatorPaymentRequest, FacilitatorPaymentRequestPayload,
9        FacilitatorSettleFailed, FacilitatorSettleResponse, FacilitatorSettleSuccess,
10        FacilitatorSupportedResponse, FacilitatorVerifyInvalid, FacilitatorVerifyResponse,
11        FacilitatorVerifyValid, PaymentPayload, PaymentRequirements, PaymentRequirementsResponse,
12        PaymentResponse,
13    },
14    types::X402Version,
15};
16
17/// Structured error response for payment processing.
18#[derive(Debug, Clone)]
19pub struct ErrorResponse {
20    pub status: StatusCode,
21    pub error: String,
22    pub accepts: Vec<PaymentRequirements>,
23}
24
25impl ErrorResponse {
26    pub fn into_payment_requirements_response(self) -> PaymentRequirementsResponse {
27        PaymentRequirementsResponse {
28            x402_version: X402Version::V1,
29            error: self.error,
30            accepts: self.accepts,
31        }
32    }
33
34    pub fn payment_required(accepts: &[PaymentRequirements]) -> Self {
35        ErrorResponse {
36            status: StatusCode::PAYMENT_REQUIRED,
37            error: "X-PAYMENT header is required".to_string(),
38            accepts: accepts.to_owned(),
39        }
40    }
41
42    pub fn invalid_payment(error: impl Display, accepts: &[PaymentRequirements]) -> Self {
43        ErrorResponse {
44            status: StatusCode::BAD_REQUEST,
45            error: error.to_string(),
46            accepts: accepts.to_owned(),
47        }
48    }
49
50    pub fn payment_failed(error: impl Display, accepts: &[PaymentRequirements]) -> Self {
51        ErrorResponse {
52            status: StatusCode::PAYMENT_REQUIRED,
53            error: error.to_string(),
54            accepts: accepts.to_owned(),
55        }
56    }
57
58    pub fn server_error(error: impl Display, accepts: &[PaymentRequirements]) -> Self {
59        ErrorResponse {
60            status: StatusCode::INTERNAL_SERVER_ERROR,
61            error: error.to_string(),
62            accepts: accepts.to_owned(),
63        }
64    }
65}
66
67/// Extracts the payment payload from the raw X-Payment-Header.
68pub fn extract_payment_payload(
69    headers: &HeaderMap,
70    payment_requirements: &[PaymentRequirements],
71) -> Result<Base64EncodedHeader, ErrorResponse> {
72    Ok(Base64EncodedHeader(
73        headers
74            .get("X-Payment")
75            .ok_or(ErrorResponse::payment_required(payment_requirements))?
76            .to_str()
77            .map_err(|err| {
78                ErrorResponse::invalid_payment(
79                    format!("Failed to parse X-Payment header: {}", err),
80                    payment_requirements,
81                )
82            })?
83            .to_string(),
84    ))
85}
86
87/// Updates the payment requirements with supported kinds from the facilitator.
88pub async fn update_supported_kinds<F: Facilitator>(
89    facilitator: &F,
90    payment_requirements: Vec<PaymentRequirements>,
91) -> Result<Vec<PaymentRequirements>, ErrorResponse> {
92    let supported = facilitator
93        .supported()
94        .await
95        .map_err(|err| ErrorResponse::server_error(err, &payment_requirements))?;
96
97    Ok(filter_supported_kinds(&supported, payment_requirements))
98}
99
100/// Filters the payment requirements based on the supported kinds from the facilitator.
101///
102/// Returns only the payment requirements that are supported by the facilitator with updated extra fields.
103pub fn filter_supported_kinds(
104    supported: &FacilitatorSupportedResponse,
105    payment_requirements: Vec<PaymentRequirements>,
106) -> Vec<PaymentRequirements> {
107    payment_requirements
108        .into_iter()
109        .filter_map(|mut pr| {
110            supported
111                .kinds
112                .iter()
113                .find(|kind| kind.scheme == pr.scheme && kind.network == pr.network)
114                .map(|s| {
115                    // Update extra field if present
116                    if s.extra.is_some() {
117                        pr.extra = s.extra.clone();
118                    }
119                    pr
120                })
121        })
122        .collect()
123}
124
125/// Selects the appropriate payment requirements based on the provided payment payload.
126pub fn select_payment_with_payload(
127    payment_requirements: &[PaymentRequirements],
128    x_payment_header: &Base64EncodedHeader,
129) -> Result<PaymentRequirements, ErrorResponse> {
130    let payment_payload = PaymentPayload::try_from(x_payment_header.clone())
131        .map_err(|err| ErrorResponse::invalid_payment(err, payment_requirements))?;
132
133    payment_requirements
134        .iter()
135        .find(|pr| pr.network == payment_payload.network && pr.scheme == payment_payload.scheme)
136        .cloned()
137        .ok_or(ErrorResponse::invalid_payment(
138            "Payment payload does not match any accepted payment requirements",
139            payment_requirements,
140        ))
141}
142
143/// Verifies the payment using the facilitator.
144pub async fn verify_payment<F: Facilitator>(
145    facilitator: &F,
146    x_payment_header: &Base64EncodedHeader,
147    selected: &PaymentRequirements,
148    payment_requirements: &[PaymentRequirements],
149) -> Result<FacilitatorVerifyValid, ErrorResponse> {
150    let payment_payload = x_payment_header
151        .clone()
152        .try_into()
153        .map_err(|err| ErrorResponse::invalid_payment(err, payment_requirements))?;
154
155    #[cfg(feature = "tracing")]
156    tracing::debug!(
157        "Verifying payment for scheme={}, network={}",
158        selected.scheme,
159        selected.network,
160    );
161
162    let request = FacilitatorPaymentRequest {
163        payload: FacilitatorPaymentRequestPayload {
164            payment_payload,
165            payment_requirements: selected.clone(),
166        },
167        x_payment_header: x_payment_header.clone(),
168    };
169
170    let verify_response = facilitator
171        .verify(request)
172        .await
173        .map_err(|err| ErrorResponse::server_error(err, payment_requirements))?;
174
175    match verify_response {
176        FacilitatorVerifyResponse::Valid(valid) => Ok(valid),
177        FacilitatorVerifyResponse::Invalid(FacilitatorVerifyInvalid {
178            invalid_reason,
179            payer,
180        }) => Err(ErrorResponse::invalid_payment(
181            format!(
182                "Invalid payment: reason='{invalid_reason}', payer={}",
183                payer.unwrap_or("[Unknown]".to_string())
184            ),
185            payment_requirements,
186        )),
187    }
188}
189
190/// Settles the payment using the facilitator.
191pub async fn settle_payment<F: Facilitator>(
192    facilitator: &F,
193    x_payment_header: &Base64EncodedHeader,
194    selected: &PaymentRequirements,
195    payment_requirements: &[PaymentRequirements],
196) -> Result<FacilitatorSettleSuccess, ErrorResponse> {
197    let payment_payload = x_payment_header
198        .clone()
199        .try_into()
200        .map_err(|err| ErrorResponse::invalid_payment(err, payment_requirements))?;
201
202    let settle_response: FacilitatorSettleResponse = facilitator
203        .settle(FacilitatorPaymentRequest {
204            payload: FacilitatorPaymentRequestPayload {
205                payment_payload,
206                payment_requirements: selected.clone(),
207            },
208            x_payment_header: x_payment_header.clone(),
209        })
210        .await
211        .map_err(|err| ErrorResponse::server_error(err, payment_requirements))?;
212
213    match settle_response {
214        FacilitatorSettleResponse::Success(success) => Ok(success),
215        FacilitatorSettleResponse::Failed(FacilitatorSettleFailed {
216            error_reason,
217            payer,
218        }) => Err(ErrorResponse::payment_failed(
219            format!(
220                "Payment settlement failed: reason='{}', payer={}",
221                error_reason,
222                payer.unwrap_or("[Unknown]".to_string())
223            ),
224            payment_requirements,
225        )),
226    }
227}
228
229/// Entrypoint for processing a payment.
230pub async fn process_payment<F: Facilitator>(
231    facilitator: &F,
232    headers: &HeaderMap,
233    payment_requirements: Vec<PaymentRequirements>,
234) -> Result<PaymentResponse, ErrorResponse> {
235    let x_payment_header = extract_payment_payload(headers, &payment_requirements)?;
236    let selected = select_payment_with_payload(&payment_requirements, &x_payment_header)?;
237
238    // Allow unused variables when tracing is disabled
239    #[allow(unused_variables)]
240    let valid = verify_payment(
241        facilitator,
242        &x_payment_header,
243        &selected,
244        &payment_requirements,
245    )
246    .await?;
247
248    #[cfg(feature = "tracing")]
249    tracing::debug!("Payment verified for payer: {}", valid.payer);
250
251    let settle_response = settle_payment(
252        facilitator,
253        &x_payment_header,
254        &selected,
255        &payment_requirements,
256    )
257    .await?;
258
259    #[cfg(feature = "tracing")]
260    tracing::debug!(
261        "Payment settled: payer='{}', network='{}', transaction='{}'",
262        settle_response.payer,
263        settle_response.network,
264        settle_response.transaction
265    );
266
267    Ok(PaymentResponse {
268        success: true,
269        transaction: settle_response.transaction,
270        network: settle_response.network,
271        payer: settle_response.payer,
272    })
273}
274
275/// Entrypoint for processing a payment, without verification.
276pub async fn process_payment_no_verify<F: Facilitator>(
277    facilitator: &F,
278    headers: &HeaderMap,
279    payment_requirements: Vec<PaymentRequirements>,
280) -> Result<PaymentResponse, ErrorResponse> {
281    let updated_requirements = update_supported_kinds(facilitator, payment_requirements).await?;
282    let x_payment_header = extract_payment_payload(headers, &updated_requirements)?;
283    let selected = select_payment_with_payload(&updated_requirements, &x_payment_header)?;
284
285    let settle_response = settle_payment(
286        facilitator,
287        &x_payment_header,
288        &selected,
289        &updated_requirements,
290    )
291    .await?;
292
293    Ok(PaymentResponse {
294        success: true,
295        transaction: settle_response.transaction,
296        network: settle_response.network,
297        payer: settle_response.payer,
298    })
299}
300
301/// Entrypoint for processing a payment, without settlement.
302pub async fn process_payment_no_settle<F: Facilitator>(
303    facilitator: &F,
304    headers: &HeaderMap,
305    payment_requirements: Vec<PaymentRequirements>,
306) -> Result<FacilitatorVerifyValid, ErrorResponse> {
307    let updated_requirements = update_supported_kinds(facilitator, payment_requirements).await?;
308    let x_payment_header = extract_payment_payload(headers, &updated_requirements)?;
309    let selected = select_payment_with_payload(&updated_requirements, &x_payment_header)?;
310
311    let verify_response = verify_payment(
312        facilitator,
313        &x_payment_header,
314        &selected,
315        &updated_requirements,
316    )
317    .await?;
318
319    Ok(verify_response)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_filter_supported_kinds() {
328        let supported = serde_json::from_str(
329            "{
330  \"kinds\": [
331    {
332      \"x402Version\": 1,
333      \"scheme\": \"exact\",
334      \"network\": \"base-sepolia\"
335    },
336    {
337      \"x402Version\": 1,
338      \"scheme\": \"exact\",
339      \"network\": \"base\"
340    },
341    {
342      \"x402Version\": 1,
343      \"scheme\": \"exact\",
344      \"network\": \"my-mock-network\",
345      \"extra\": {
346        \"mockField\": \"mockValue\"
347      }
348    },
349    {
350      \"x402Version\": 1,
351      \"scheme\": \"exact\",
352      \"network\": \"solana-devnet\",
353      \"extra\": {
354        \"feePayer\": \"2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4\"
355      }
356    },
357    {
358      \"x402Version\": 1,
359      \"scheme\": \"exact\",
360      \"network\": \"solana\",
361      \"extra\": {
362        \"feePayer\": \"2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4\"
363      }
364    }
365  ]
366}",
367        )
368        .unwrap();
369
370        // EVM chains: "supported" doesn't include "extra" field, but payment requirements do
371        let payment_requirements = serde_json::from_str(
372            "[
373    {
374      \"scheme\": \"exact\",
375      \"network\": \"base\",
376      \"maxAmountRequired\": \"100\",
377      \"resource\": \"https://devnet.aimo.network/api/v1/chat/completions\",
378      \"description\": \"LLM Generation endpoint\",
379      \"mimeType\": \"application/json\",
380      \"payTo\": \"0xD14cE79C13CE71a853eF3E8BD75969d4BDEE39c1\",
381      \"maxTimeoutSeconds\": 60,
382      \"asset\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",
383      \"outputSchema\": {
384        \"input\": {
385          \"discoverable\": true,
386          \"type\": \"http\",
387          \"method\": \"post\"
388        }
389      },
390      \"extra\": {
391        \"name\": \"USD Coin\",
392        \"version\": \"2\"
393      }
394    }
395  ]",
396        )
397        .unwrap();
398
399        // Expect the final filtered item to include the "extra" field from payment requirements
400        let filtered = filter_supported_kinds(&supported, payment_requirements);
401        assert_eq!(filtered.len(), 1);
402        assert_eq!(
403            filtered[0].extra,
404            Some(serde_json::json!({"name": "USD Coin", "version": "2"}))
405        );
406
407        // Solana chains: "supported" includes "extra" field, which should override payment requirements
408        let payment_requirements_solana = serde_json::from_str(
409            "[
410    {
411      \"scheme\": \"exact\",
412      \"network\": \"solana\",
413      \"maxAmountRequired\": \"100\",
414      \"resource\": \"https://devnet.aimo.network/api/v1/chat/completions\",
415      \"description\": \"LLM Generation endpoint\",
416      \"mimeType\": \"application/json\",
417      \"payTo\": \"Ge3jkza5KRfXvaq3GELNLh6V1pjjdEKNpEdGXJgjjKUR\",
418      \"maxTimeoutSeconds\": 60,
419      \"asset\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",
420      \"outputSchema\": {
421        \"input\": {
422          \"discoverable\": true,
423          \"type\": \"http\",
424          \"method\": \"post\"
425        }
426      }
427    }
428  ]",
429        )
430        .unwrap();
431
432        // Expect the final filtered item to include the "extra" field from supported kinds
433        let filtered = filter_supported_kinds(&supported, payment_requirements_solana);
434        assert_eq!(filtered.len(), 1);
435        assert_eq!(
436            filtered[0].extra,
437            Some(serde_json::json!({"feePayer": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"}))
438        );
439
440        // Mock network: "supported" includes "extra" field, which should override payment requirements
441        let payment_requirements_mock = serde_json::from_str(
442            "[
443    {
444      \"scheme\": \"exact\",
445      \"network\": \"my-mock-network\",
446      \"maxAmountRequired\": \"100\",
447      \"resource\": \"https://devnet.aimo.network/api/v1/chat/completions\",
448      \"description\": \"LLM Generation endpoint\",
449      \"mimeType\": \"application/json\",
450      \"payTo\": \"0xD14cE79C13CE71a853eF3E8BD75969d4BDEE39c1\",
451      \"maxTimeoutSeconds\": 60,
452      \"asset\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",
453      \"outputSchema\": {
454        \"input\": {
455          \"discoverable\": true,
456          \"type\": \"http\",
457          \"method\": \"post\"
458        }
459      },
460      \"extra\": {
461        \"name\": \"USD Coin\",
462        \"version\": \"2\"
463      }
464    }
465  ]",
466        )
467        .unwrap();
468
469        // Expect the final filtered item to include the "extra" field from supported kinds
470        let filtered = filter_supported_kinds(&supported, payment_requirements_mock);
471        assert_eq!(filtered.len(), 1);
472        assert_eq!(
473            filtered[0].extra,
474            Some(serde_json::json!({"mockField": "mockValue"}))
475        );
476    }
477}