Skip to main content

fly402_actix/
lib.rs

1//! # 402fly Actix Web
2//!
3//! Actix Web framework integration for the 402fly payment protocol.
4//!
5//! This library provides middleware and extractors to protect Actix Web endpoints
6//! with payment requirements using the 402fly protocol.
7//!
8//! ## Example
9//!
10//! ```rust,no_run
11//! use actix_web::{web, App, HttpResponse, HttpServer};
12//! use fly402_actix::{PaymentExtractor, X402Config, X402State};
13//!
14//! async fn premium_data(auth: PaymentExtractor) -> HttpResponse {
15//!     HttpResponse::Ok().json(serde_json::json!({
16//!         "message": "Premium content!",
17//!         "payment_id": auth.authorization.payment_id
18//!     }))
19//! }
20//!
21//! #[actix_web::main]
22//! async fn main() -> std::io::Result<()> {
23//!     let config = X402Config {
24//!         payment_address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
25//!         token_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
26//!         network: "solana-devnet".to_string(),
27//!         rpc_url: None,
28//!         auto_verify: true,
29//!     };
30//!
31//!     HttpServer::new(move || {
32//!         App::new()
33//!             .app_data(web::Data::new(X402State { config: config.clone() }))
34//!             .route("/premium", web::get().to(premium_data))
35//!     })
36//!     .bind(("127.0.0.1", 8080))?
37//!     .run()
38//!     .await
39//! }
40//! ```
41
42use actix_web::{
43    dev::Payload, error::ResponseError, http::StatusCode, Error, FromRequest, HttpRequest,
44    HttpResponse,
45};
46use chrono::{Duration, Utc};
47use fly402_core::{PaymentAuthorization, PaymentRequest};
48use std::future::{ready, Ready};
49use uuid::Uuid;
50
51/// Global X402 configuration
52#[derive(Debug, Clone)]
53pub struct X402Config {
54    /// Wallet address to receive payments
55    pub payment_address: String,
56
57    /// SPL token mint address (USDC)
58    pub token_mint: String,
59
60    /// Solana network (e.g., "solana-devnet", "solana-mainnet")
61    pub network: String,
62
63    /// Optional custom RPC URL
64    pub rpc_url: Option<String>,
65
66    /// Whether to verify payments on-chain
67    pub auto_verify: bool,
68}
69
70/// Application state containing X402 configuration
71#[derive(Debug, Clone)]
72pub struct X402State {
73    pub config: X402Config,
74}
75
76/// Payment requirement configuration for a specific endpoint
77#[derive(Debug, Clone)]
78pub struct PaymentRequirement {
79    /// Amount required in USDC
80    pub amount: String,
81
82    /// Optional description
83    pub description: Option<String>,
84
85    /// Expiration time in seconds (default: 300)
86    pub expires_in: i64,
87}
88
89impl PaymentRequirement {
90    /// Create a new payment requirement
91    pub fn new(amount: &str) -> Self {
92        Self {
93            amount: amount.to_string(),
94            description: None,
95            expires_in: 300,
96        }
97    }
98
99    /// Set the description
100    pub fn with_description(mut self, description: &str) -> Self {
101        self.description = Some(description.to_string());
102        self
103    }
104
105    /// Set the expiration time
106    pub fn with_expires_in(mut self, seconds: i64) -> Self {
107        self.expires_in = seconds;
108        self
109    }
110}
111
112/// Extractor that enforces payment requirements
113pub struct PaymentExtractor {
114    pub authorization: PaymentAuthorization,
115}
116
117impl FromRequest for PaymentExtractor {
118    type Error = Error;
119    type Future = Ready<Result<Self, Self::Error>>;
120
121    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
122        // Get payment authorization header
123        let auth_header = match req.headers().get("X-Payment-Authorization") {
124            Some(h) => match h.to_str() {
125                Ok(s) => s,
126                Err(_) => {
127                    return ready(Err(PaymentError::InvalidHeader.into()));
128                }
129            },
130            None => {
131                return ready(Err(PaymentError::Required.into()));
132            }
133        };
134
135        // Parse authorization
136        match PaymentAuthorization::from_header_value(auth_header) {
137            Ok(auth) => {
138                // TODO: Verify payment if auto_verify is enabled
139                ready(Ok(PaymentExtractor { authorization: auth }))
140            }
141            Err(e) => ready(Err(PaymentError::InvalidAuthorization(e.to_string()).into())),
142        }
143    }
144}
145
146/// Error type for payment operations
147#[derive(Debug)]
148pub enum PaymentError {
149    Required,
150    InvalidHeader,
151    InvalidAuthorization(String),
152}
153
154impl std::fmt::Display for PaymentError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            PaymentError::Required => write!(f, "Payment required"),
158            PaymentError::InvalidHeader => write!(f, "Invalid authorization header"),
159            PaymentError::InvalidAuthorization(msg) => {
160                write!(f, "Invalid payment authorization: {}", msg)
161            }
162        }
163    }
164}
165
166impl ResponseError for PaymentError {
167    fn status_code(&self) -> StatusCode {
168        match self {
169            PaymentError::Required => StatusCode::PAYMENT_REQUIRED,
170            PaymentError::InvalidHeader | PaymentError::InvalidAuthorization(_) => {
171                StatusCode::BAD_REQUEST
172            }
173        }
174    }
175
176    fn error_response(&self) -> HttpResponse {
177        match self {
178            PaymentError::Required => {
179                HttpResponse::PaymentRequired().json(serde_json::json!({
180                    "error": "Payment required",
181                    "message": "This endpoint requires payment"
182                }))
183            }
184            _ => HttpResponse::BadRequest().json(serde_json::json!({
185                "error": self.to_string()
186            })),
187        }
188    }
189}
190
191/// Create a payment request for an endpoint
192pub fn create_payment_request(
193    config: &X402Config,
194    requirement: &PaymentRequirement,
195    resource: &str,
196) -> PaymentRequest {
197    let expires_at = Utc::now() + Duration::seconds(requirement.expires_in);
198    let payment_id = Uuid::new_v4().to_string();
199    let nonce = Uuid::new_v4().to_string();
200
201    let mut request = PaymentRequest::new(
202        requirement.amount.clone(),
203        config.token_mint.clone(),
204        config.payment_address.clone(),
205        config.network.clone(),
206        expires_at,
207        nonce,
208        payment_id,
209        resource.to_string(),
210    );
211
212    if let Some(desc) = &requirement.description {
213        request = request.with_description(desc.clone());
214    }
215
216    request
217}
218
219/// Helper function to create a 402 Payment Required response
220pub fn payment_required_response(payment_request: PaymentRequest) -> HttpResponse {
221    HttpResponse::PaymentRequired().json(payment_request)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_payment_requirement() {
230        let req = PaymentRequirement::new("0.10")
231            .with_description("Premium data access")
232            .with_expires_in(600);
233
234        assert_eq!(req.amount, "0.10");
235        assert_eq!(req.description, Some("Premium data access".to_string()));
236        assert_eq!(req.expires_in, 600);
237    }
238
239    #[test]
240    fn test_create_payment_request() {
241        let config = X402Config {
242            payment_address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
243            token_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
244            network: "solana-devnet".to_string(),
245            rpc_url: None,
246            auto_verify: false,
247        };
248
249        let requirement = PaymentRequirement::new("0.10");
250        let request = create_payment_request(&config, &requirement, "/api/premium");
251
252        assert_eq!(request.max_amount_required, "0.10");
253        assert_eq!(request.payment_address, config.payment_address);
254        assert_eq!(request.asset_address, config.token_mint);
255    }
256}