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().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().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_config());
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_propagates_handler_error() {
253        let mut handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
254        handler.on_update(|_| async move {
255            Err(CryptoBotError::WebhookError {
256                kind: WebhookErrorKind::InvalidPayload,
257                message: "handler error".to_string(),
258            })
259        });
260
261        let json = json!({
262            "update_id": 1,
263            "update_type": "invoice_paid",
264            "request_date": Utc::now().to_rfc3339(),
265            "payload": {
266                "invoice_id": 528890,
267                "hash": "IVDoTcNBYEfk",
268                "currency_type": "crypto",
269                "asset": "TON",
270                "amount": "10.5",
271                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
272                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
273                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
274                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
275                "description": "Test invoice",
276                "status": "paid",
277                "created_at": "2025-02-08T12:11:01.341Z",
278                "allow_comments": true,
279                "allow_anonymous": true
280            }
281        })
282        .to_string();
283
284        let result = handler.handle_update(&json).await;
285        assert!(matches!(
286            result,
287            Err(CryptoBotError::WebhookError {
288                kind: WebhookErrorKind::InvalidPayload,
289                message
290            }) if message == "handler error"
291        ));
292    }
293
294    #[tokio::test]
295    async fn test_webhook_handler_invalid_request_date() {
296        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
297
298        let json = json!({
299            "update_id": 1,
300            "update_type": "invoice_paid",
301            "request_date": "invalid_date",
302            "payload": {
303                "invoice_id": 528890,
304                "hash": "IVDoTcNBYEfk",
305                "currency_type": "crypto",
306                "asset": "TON",
307                "amount": "10.5",
308                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
309                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
310                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
311                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
312                "description": "Test invoice",
313                "status": "paid",
314                "created_at": "2025-02-08T12:11:01.341Z",
315                "allow_comments": true,
316                "allow_anonymous": true
317            }
318        });
319
320        let result = handler.handle_update(&json.to_string()).await;
321
322        assert!(matches!(
323            result,
324            Err(CryptoBotError::WebhookError {
325                kind: WebhookErrorKind::InvalidPayload,
326                message,
327            }) if message == "Invalid request date"
328        ));
329    }
330
331    #[tokio::test]
332    async fn test_webhook_handler_with_disabled_expiration() {
333        let handler = WebhookHandler::with_config(
334            "test_token",
335            WebhookHandlerConfigBuilder::new().disable_expiration().build_config(),
336        );
337
338        let json = json!({
339            "update_id": 1,
340            "update_type": "invoice_paid",
341            "request_date": Utc::now().to_rfc3339(),
342            "payload": {
343                "invoice_id": 528890,
344                "hash": "IVDoTcNBYEfk",
345                "currency_type": "crypto",
346                "asset": "TON",
347                "amount": "10.5",
348                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
349                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
350                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
351                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
352                "description": "Test invoice",
353                "status": "paid",
354                "created_at": "2025-02-08T12:11:01.341Z",
355                "allow_comments": true,
356                "allow_anonymous": true
357            }
358        });
359
360        let result = handler.handle_update(&json.to_string()).await;
361        assert!(result.is_ok());
362    }
363
364    #[tokio::test]
365    async fn test_default_webhook_expiration() {
366        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
367
368        let date = (Utc::now() - chrono::Duration::minutes(3)).to_rfc3339();
369
370        let json = json!({
371            "update_id": 1,
372            "update_type": "invoice_paid",
373            "request_date": date,
374            "payload":  {
375                    "invoice_id": 528890,
376                    "hash": "IVDoTcNBYEfk",
377                    "currency_type": "crypto",
378                    "asset": "TON",
379                    "amount": "10.5",
380                    "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
381                    "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
382                    "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
383                    "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
384                    "description": "Test invoice",
385                    "status": "paid",
386                    "created_at": "2025-02-08T12:11:01.341Z",
387                    "allow_comments": true,
388                    "allow_anonymous": true
389            }
390        })
391        .to_string();
392
393        let result = handler.handle_update(&json).await;
394        assert!(result.is_ok());
395    }
396
397    #[tokio::test]
398    async fn test_custom_webhook_expiration() {
399        let handler = WebhookHandler::with_config(
400            "test_token",
401            WebhookHandlerConfigBuilder::new()
402                .expiration_time(Duration::from_secs(60))
403                .build_config(),
404        );
405
406        let old_date = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
407
408        let json = json!({
409            "update_id": 1,
410            "update_type": "invoice_paid",
411            "request_date": old_date,
412            "payload": {
413                    "invoice_id": 528890,
414                    "hash": "IVDoTcNBYEfk",
415                    "currency_type": "crypto",
416                    "asset": "TON",
417                    "amount": "10.5",
418                    "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
419                    "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
420                    "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
421                    "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
422                    "description": "Test invoice",
423                    "status": "paid",
424                    "created_at": "2025-02-08T12:11:01.341Z",
425                    "allow_comments": true,
426                    "allow_anonymous": true
427                }
428        })
429        .to_string();
430
431        let result = handler.handle_update(&json).await;
432        assert!(matches!(
433            result,
434            Err(CryptoBotError::WebhookError {
435                kind: WebhookErrorKind::Expired,
436                ..
437            })
438        ));
439    }
440
441    #[test]
442    fn test_webhook_signature_verification() {
443        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfigBuilder::new().build_config());
444        let body = json!({
445            "update_id": 1,
446            "update_type": "invoice_paid",
447            "request_date": "2024-01-01T12:00:00Z",
448            "payload": {
449                "invoice_id": 528890,
450                "hash": "IVDoTcNBYEfk",
451                "status": "paid",
452                // ... other invoice fields ...
453            }
454        })
455        .to_string();
456
457        // Generate a valid signature
458        let secret = Sha256::digest(b"test_token");
459        let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
460        mac.update(body.as_bytes());
461        let signature = hex::encode(mac.finalize().into_bytes());
462
463        assert!(handler.verify_signature(&body, &signature));
464        assert!(!handler.verify_signature(&body, "invalid_signature"));
465    }
466
467    #[test]
468    fn test_parse_webhook_update() {
469        let json = json!({
470            "update_id": 1,
471            "update_type": "invoice_paid",
472            "request_date": "2024-02-02T12:11:02Z",
473            "payload": {
474                "invoice_id": 528890,
475                "hash": "IVDoTcNBYEfk",
476                "currency_type": "crypto",
477                "asset": "TON",
478                "amount": "10.5",
479                "pay_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
480                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=IVDoTcNBYEfk",
481                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-IVDoTcNBYEfk",
482                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/IVDoTcNBYEfk",
483                "description": "Test invoice",
484                "status": "paid",
485                "created_at": "2025-02-08T12:11:01.341Z",
486                "allow_comments": true,
487                "allow_anonymous": true
488            }
489        });
490
491        let result = WebhookHandler::parse_update(&json.to_string());
492        assert!(result.is_ok());
493
494        let update = result.unwrap();
495        assert_eq!(update.update_id, 1);
496        assert_eq!(update.update_type, UpdateType::InvoicePaid);
497        assert_eq!(update.request_date, "2024-02-02T12:11:02Z");
498
499        match update.payload {
500            WebhookPayload::InvoicePaid(invoice) => {
501                assert_eq!(invoice.invoice_id, 528890);
502                assert_eq!(invoice.status, InvoiceStatus::Paid);
503            }
504        }
505    }
506
507    #[test]
508    fn test_parse_invalid_webhook_update() {
509        let invalid_json = r#"{"invalid": "json"}"#;
510
511        let result = WebhookHandler::parse_update(invalid_json);
512        assert!(matches!(
513            result,
514            Err(CryptoBotError::WebhookError {
515                kind: WebhookErrorKind::InvalidPayload,
516                ..
517            })
518        ));
519    }
520
521    #[tokio::test]
522    async fn test_handle_update_with_missing_handler_ok() {
523        let handler = WebhookHandler::with_config("test_token", WebhookHandlerConfig::default());
524
525        let json = json!({
526            "update_id": 1,
527            "update_type": "invoice_paid",
528            "request_date": Utc::now().to_rfc3339(),
529            "payload": {
530                "invoice_id": 1,
531                "hash": "hash",
532                "status": "paid",
533                "currency_type": "crypto",
534                "asset": "TON",
535                "amount": "1",
536                "bot_invoice_url": "https://t.me/CryptoTestnetBot?start=hash",
537                "mini_app_invoice_url": "https://t.me/CryptoTestnetBot/app?startapp=invoice-hash",
538                "web_app_invoice_url": "https://testnet-app.send.tg/invoices/hash",
539                "created_at": "2025-02-08T12:11:01.341Z",
540                "allow_comments": true,
541                "allow_anonymous": true
542            }
543        })
544        .to_string();
545
546        let result = handler.handle_update(&json).await;
547        assert!(result.is_ok());
548    }
549}