crypto_pay_api/webhook/
handler.rs

1use chrono::{DateTime, Utc};
2use hmac::{Hmac, Mac};
3use sha2::{Digest, Sha256};
4use std::future::Future;
5use std::pin::Pin;
6
7use crate::{
8    error::{CryptoBotError, WebhookErrorKind},
9    models::{WebhookResponse, WebhookUpdate},
10};
11
12use super::WebhookHandlerConfig;
13
14pub type WebhookHandlerFn =
15    Box<dyn Fn(WebhookUpdate) -> Pin<Box<dyn Future<Output = Result<(), CryptoBotError>> + Send>> + Send + Sync>;
16
17pub struct WebhookHandler {
18    pub(crate) api_token: String,
19    pub(crate) config: WebhookHandlerConfig,
20    pub(crate) update_handler: Option<WebhookHandlerFn>,
21}
22
23impl WebhookHandler {
24    pub(crate) fn with_config(api_token: impl Into<String>, config: WebhookHandlerConfig) -> Self {
25        Self {
26            api_token: api_token.into(),
27            config,
28            update_handler: None,
29        }
30    }
31
32    pub fn parse_update(json: &str) -> Result<WebhookUpdate, CryptoBotError> {
33        serde_json::from_str(json).map_err(|e| CryptoBotError::WebhookError {
34            kind: WebhookErrorKind::InvalidPayload,
35            message: e.to_string(),
36        })
37    }
38
39    /// Verifies the signature of a webhook request
40    ///
41    /// The signature is created by the Crypto Bot API using HMAC-SHA-256
42    /// with the API token as the key and the request body as the message.
43    ///
44    /// # Arguments
45    /// * `body` - The raw request body
46    /// * `signature` - The signature from the 'crypto-pay-api-signature' header
47    ///
48    /// # Returns
49    /// * `true` if the signature is valid
50    /// * `false` if the signature is invalid or malformed
51    ///
52    /// # Example
53    /// ```
54    /// use crypto_pay_api::prelude::*;
55    ///
56    /// #[tokio::main]
57    /// async fn main() -> Result<(), CryptoBotError> {
58    ///     let client = CryptoBot::builder().api_token("your_api_token").build().unwrap();
59    ///     let handler = client.webhook_handler(WebhookHandlerConfigBuilder::new().build());
60    ///     let body = r#"{"update_id": 1, "update_type": "invoice_paid"}"#;
61    ///     let signature = "1234567890abcdef"; // The actual signature from the request header
62    ///
63    ///     if handler.verify_signature(body, signature) {
64    ///         println!("Signature is valid");
65    ///     } else {
66    ///         println!("Invalid signature");
67    ///     }
68    ///
69    ///     Ok(())
70    /// }
71    /// ```
72    pub fn verify_signature(&self, body: &str, signature: &str) -> bool {
73        let secret = Sha256::digest(self.api_token.as_bytes());
74        let mut mac = Hmac::<Sha256>::new_from_slice(&secret).expect("HMAC can take key of any size");
75
76        mac.update(body.as_bytes());
77
78        if let Ok(hex_signature) = hex::decode(signature) {
79            mac.verify_slice(&hex_signature).is_ok()
80        } else {
81            false
82        }
83    }
84
85    /// Handles a webhook update from Crypto Bot API
86    ///
87    /// This method:
88    /// 1. Parses the webhook update from JSON
89    /// 2. Validates the request date
90    /// 3. Checks if the request has expired
91    /// 4. Calls the registered update handler if one exists
92    ///
93    /// # Arguments
94    /// * `body` - The raw webhook request body as JSON string
95    ///
96    /// # Returns
97    /// * `Ok(WebhookResponse)` - If the update was handled successfully
98    /// * `Err(CryptoBotError)` - If any validation fails or the handler returns an error
99    ///
100    /// # Errors
101    /// * `WebhookErrorKind::InvalidPayload` - If the JSON is invalid or missing required fields
102    /// * `WebhookErrorKind::Expired` - If the request is older than the expiration time
103    pub async fn handle_update(&self, body: &str) -> Result<WebhookResponse, CryptoBotError> {
104        let update: WebhookUpdate = Self::parse_update(body)?;
105
106        if let Some(expiration_time) = self.config.expiration_time {
107            let request_date =
108                DateTime::parse_from_rfc3339(&update.request_date).map_err(|_| CryptoBotError::WebhookError {
109                    kind: WebhookErrorKind::InvalidPayload, // TODO: test this
110                    message: "Invalid request date".to_string(),
111                })?;
112
113            let age = Utc::now().signed_duration_since(request_date.with_timezone(&Utc));
114
115            let webhook_expiration_time = expiration_time.as_secs();
116
117            let webhook_expiration = chrono::Duration::seconds(webhook_expiration_time as i64);
118
119            if age > webhook_expiration {
120                return Err(CryptoBotError::WebhookError {
121                    kind: WebhookErrorKind::Expired,
122                    message: "Webhook request too old".to_string(),
123                });
124            }
125        }
126
127        if let Some(handler) = &self.update_handler {
128            handler(update).await?;
129        }
130
131        Ok(WebhookResponse::ok())
132    }
133
134    /// Registers a handler function for webhook updates
135    ///
136    /// The handler function will be called for each webhook update received through
137    /// `handle_update`. The function should process the update and return a Result
138    /// indicating success or failure.
139    ///
140    /// # Arguments
141    /// * `handler` - An async function that takes a `WebhookUpdate` and returns a `Result<(), CryptoBotError>`
142    ///
143    /// # Type Parameters
144    /// * `F` - The handler function type
145    /// * `Fut` - The future type returned by the handler
146    ///
147    /// # Requirements
148    /// The handler function must:
149    /// * Be `Send` + `Sync` + 'static
150    /// * Return a Future that is `Send` + 'static
151    /// * The Future must resolve to `Result<(), CryptoBotError>`
152    ///
153    /// # Example
154    /// ```
155    /// use crypto_pay_api::prelude::*;
156    ///
157    /// #[tokio::main]
158    /// async fn main() {
159    ///     let client = CryptoBot::builder().api_token("YOUR_API_TOKEN").build().unwrap();
160    ///     let mut handler = client.webhook_handler(WebhookHandlerConfigBuilder::new().build());
161    ///
162    ///     handler.on_update(|update| async move {
163    ///         match (update.update_type, update.payload) {
164    ///             (UpdateType::InvoicePaid, WebhookPayload::InvoicePaid(invoice)) => {
165    ///                 println!("Payment received!");
166    ///                 println!("Amount: {} {}", invoice.amount, invoice.asset.unwrap());
167    ///                 
168    ///                 // Process the payment...
169    ///             }
170    ///         }
171    ///         Ok(())
172    ///     });
173    ///
174    ///     // Now ready to handle webhook updates
175    /// }
176    /// ```
177    pub fn on_update<F, Fut>(&mut self, handler: F)
178    where
179        F: Fn(WebhookUpdate) -> Fut + Send + Sync + 'static,
180        Fut: Future<Output = Result<(), CryptoBotError>> + Send + 'static,
181    {
182        self.update_handler = Some(Box::new(move |update| Box::pin(handler(update))));
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::{
190        models::{InvoiceStatus, UpdateType, WebhookPayload},
191        webhook::WebhookHandlerConfigBuilder,
192    };
193    use chrono::Utc;
194    use serde_json::json;
195
196    use std::{sync::Arc, time::Duration};
197    use tokio::sync::Mutex;
198
199    #[tokio::test]
200    async fn test_webhook_handler() {
201        let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
202
203        let received = Arc::new(Mutex::new(None));
204        let received_clone = received.clone();
205
206        handler.on_update(move |update| {
207            let received = received_clone.clone();
208            async move {
209                let mut guard = received.lock().await;
210                *guard = Some(update);
211                Ok(())
212            }
213        });
214
215        let json = json!({
216            "update_id": 1,
217            "update_type": "invoice_paid",
218            "request_date": Utc::now().to_rfc3339(),
219            "payload": {
220                "invoice_id": 528890,
221                "hash": "IVDoTcNBYEfk",
222                "currency_type": "crypto",
223                "asset": "TON",
224                "amount": "10.5",
225                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
226                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
227                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
228                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
229                "description": "Test invoice",
230                "status": "paid",
231                "created_at": "2025-02-08T12:11:01.341Z",
232                "allow_comments": true,
233                "allow_anonymous": true
234            }
235        })
236        .to_string();
237
238        let result = handler.handle_update(&json).await;
239        assert!(result.is_ok());
240
241        let update = received.lock().await.take().expect("Should have received update");
242        assert_eq!(update.update_type, UpdateType::InvoicePaid);
243        match update.payload {
244            WebhookPayload::InvoicePaid(invoice) => {
245                assert_eq!(invoice.invoice_id, 528890);
246                assert_eq!(invoice.status, InvoiceStatus::Paid);
247            }
248        }
249    }
250
251    #[tokio::test]
252    async fn test_webhook_handler_invalid_request_date() {
253        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
254
255        let json = json!({
256            "update_id": 1,
257            "update_type": "invoice_paid",
258            "request_date": "invalid_date",
259            "payload": {
260                "invoice_id": 528890,
261                "hash": "IVDoTcNBYEfk",
262                "currency_type": "crypto",
263                "asset": "TON",
264                "amount": "10.5",
265                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
266                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
267                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
268                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
269                "description": "Test invoice",
270                "status": "paid",
271                "created_at": "2025-02-08T12:11:01.341Z",
272                "allow_comments": true,
273                "allow_anonymous": true
274            }
275        });
276
277        let result = handler.handle_update(&json.to_string()).await;
278
279        assert!(matches!(
280            result,
281            Err(CryptoBotError::WebhookError {
282                kind: WebhookErrorKind::InvalidPayload,
283                message,
284            }) if message == "Invalid request date"
285        ));
286    }
287
288    #[tokio::test]
289    async fn test_webhook_handler_with_disabled_expiration() {
290        let handler = WebhookHandler::with_config(
291            "test_token",
292            WebhookHandlerConfigBuilder::new().disable_expiration().build(),
293        );
294
295        let json = json!({
296            "update_id": 1,
297            "update_type": "invoice_paid",
298            "request_date": Utc::now().to_rfc3339(),
299            "payload": {
300                "invoice_id": 528890,
301                "hash": "IVDoTcNBYEfk",
302                "currency_type": "crypto",
303                "asset": "TON",
304                "amount": "10.5",
305                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
306                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
307                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
308                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
309                "description": "Test invoice",
310                "status": "paid",
311                "created_at": "2025-02-08T12:11:01.341Z",
312                "allow_comments": true,
313                "allow_anonymous": true
314            }
315        });
316
317        let result = handler.handle_update(&json.to_string()).await;
318        assert!(result.is_ok());
319    }
320
321    #[tokio::test]
322    async fn test_default_webhook_expiration() {
323        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build());
324
325        let date = (Utc::now() - chrono::Duration::minutes(3)).to_rfc3339();
326
327        let json = json!({
328            "update_id": 1,
329            "update_type": "invoice_paid",
330            "request_date": date,
331            "payload":  {
332                    "invoice_id": 528890,
333                    "hash": "IVDoTcNBYEfk",
334                    "currency_type": "crypto",
335                    "asset": "TON",
336                    "amount": "10.5",
337                    "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
338                    "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
339                    "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
340                    "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
341                    "description": "Test invoice",
342                    "status": "paid",
343                    "created_at": "2025-02-08T12:11:01.341Z",
344                    "allow_comments": true,
345                    "allow_anonymous": true
346            }
347        })
348        .to_string();
349
350        let result = handler.handle_update(&json).await;
351        assert!(result.is_ok());
352    }
353
354    #[tokio::test]
355    async fn test_custom_webhook_expiration() {
356        let handler = WebhookHandler::with_config(
357            "test_token",
358            WebhookHandlerConfigBuilder::new()
359                .expiration_time(Duration::from_secs(60))
360                .build(),
361        );
362
363        let old_date = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
364
365        let json = json!({
366            "update_id": 1,
367            "update_type": "invoice_paid",
368            "request_date": old_date,
369            "payload": {
370                    "invoice_id": 528890,
371                    "hash": "IVDoTcNBYEfk",
372                    "currency_type": "crypto",
373                    "asset": "TON",
374                    "amount": "10.5",
375                    "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
376                    "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
377                    "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
378                    "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
379                    "description": "Test invoice",
380                    "status": "paid",
381                    "created_at": "2025-02-08T12:11:01.341Z",
382                    "allow_comments": true,
383                    "allow_anonymous": true
384                }
385        })
386        .to_string();
387
388        let result = handler.handle_update(&json).await;
389        assert!(matches!(
390            result,
391            Err(CryptoBotError::WebhookError {
392                kind: WebhookErrorKind::Expired,
393                ..
394            })
395        ));
396    }
397
398    #[test]
399    fn test_webhook_signature_verification() {
400        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfig::default());
401        let body = json!({
402            "update_id": 1,
403            "update_type": "invoice_paid",
404            "request_date": "2024-01-01T12:00:00Z",
405            "payload": {
406                "invoice_id": 528890,
407                "hash": "IVDoTcNBYEfk",
408                "status": "paid",
409                // ... other invoice fields ...
410            }
411        })
412        .to_string();
413
414        // Generate a valid signature
415        let secret = Sha256::digest(b"test_token");
416        let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
417        mac.update(body.as_bytes());
418        let signature = hex::encode(mac.finalize().into_bytes());
419
420        assert!(handler.verify_signature(&body, &signature));
421        assert!(!handler.verify_signature(&body, "invalid_signature"));
422    }
423
424    #[test]
425    fn test_parse_webhook_update() {
426        let json = json!({
427            "update_id": 1,
428            "update_type": "invoice_paid",
429            "request_date": "2024-02-02T12:11:02Z",
430            "payload": {
431                "invoice_id": 528890,
432                "hash": "IVDoTcNBYEfk",
433                "currency_type": "crypto",
434                "asset": "TON",
435                "amount": "10.5",
436                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
437                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
438                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
439                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
440                "description": "Test invoice",
441                "status": "paid",
442                "created_at": "2025-02-08T12:11:01.341Z",
443                "allow_comments": true,
444                "allow_anonymous": true
445            }
446        });
447
448        let result = WebhookHandler::parse_update(&json.to_string());
449        assert!(result.is_ok());
450
451        let update = result.unwrap();
452        assert_eq!(update.update_id, 1);
453        assert_eq!(update.update_type, UpdateType::InvoicePaid);
454        assert_eq!(update.request_date, "2024-02-02T12:11:02Z");
455
456        match update.payload {
457            WebhookPayload::InvoicePaid(invoice) => {
458                assert_eq!(invoice.invoice_id, 528890);
459                assert_eq!(invoice.status, InvoiceStatus::Paid);
460            }
461        }
462    }
463
464    #[test]
465    fn test_parse_invalid_webhook_update() {
466        let invalid_json = r#"{"invalid": "json"}"#;
467
468        let result = WebhookHandler::parse_update(invalid_json);
469        assert!(matches!(
470            result,
471            Err(CryptoBotError::WebhookError {
472                kind: WebhookErrorKind::InvalidPayload,
473                ..
474            })
475        ));
476    }
477}