openzeppelin_relayer/services/notification/
mod.rs

1//! This module provides the `WebhookNotificationService` for sending notifications via webhooks.
2use crate::models::{SecretString, WebhookNotification, WebhookResponse};
3use async_trait::async_trait;
4use base64::{engine::general_purpose::STANDARD, Engine};
5use hmac::{Hmac, Mac};
6#[cfg(test)]
7use mockall::automock;
8use reqwest::Client;
9use sha2::Sha256;
10use thiserror::Error;
11
12type HmacSha256 = Hmac<Sha256>;
13
14#[derive(Debug, Clone)]
15pub struct WebhookNotificationService {
16    client: Client,
17    webhook_url: String,
18    secret_key: Option<SecretString>,
19}
20
21#[cfg_attr(test, automock)]
22#[async_trait]
23pub trait WebhookNotificationServiceTrait: Send + Sync {
24    async fn send_notification(
25        &self,
26        notification: WebhookNotification,
27    ) -> Result<WebhookResponse, WebhookNotificationError>;
28
29    fn sign_payload(
30        &self,
31        payload: &str,
32        secret_key: &SecretString,
33    ) -> Result<String, WebhookNotificationError>;
34}
35
36#[async_trait]
37impl WebhookNotificationServiceTrait for WebhookNotificationService {
38    async fn send_notification(
39        &self,
40        notification: WebhookNotification,
41    ) -> Result<WebhookResponse, WebhookNotificationError> {
42        self.send_notification(notification).await
43    }
44
45    fn sign_payload(
46        &self,
47        payload: &str,
48        secret_key: &SecretString,
49    ) -> Result<String, WebhookNotificationError> {
50        self.sign_payload(payload, secret_key)
51    }
52}
53
54impl WebhookNotificationService {
55    pub fn new(webhook_url: String, secret_key: Option<SecretString>) -> Self {
56        Self {
57            client: Client::new(),
58            webhook_url,
59            secret_key,
60        }
61    }
62
63    fn sign_payload(
64        &self,
65        payload: &str,
66        secret_key: &SecretString,
67    ) -> Result<String, WebhookNotificationError> {
68        let mut mac = HmacSha256::new_from_slice(secret_key.to_str().as_bytes())
69            .map_err(|e| WebhookNotificationError::SigningError(e.to_string()))?;
70        mac.update(payload.as_bytes());
71        let result = mac.finalize();
72        let code_bytes = result.into_bytes();
73        Ok(STANDARD.encode(code_bytes))
74    }
75
76    pub async fn send_notification(
77        &self,
78        notification: WebhookNotification,
79    ) -> Result<WebhookResponse, WebhookNotificationError> {
80        let payload = serde_json::to_string(&notification)?;
81
82        let response = match self.secret_key.as_ref() {
83            Some(key) => {
84                let signature = self.sign_payload(&payload, key)?;
85
86                self.client
87                    .post(&self.webhook_url)
88                    .header("X-Signature", signature)
89                    .json(&notification)
90                    .send()
91                    .await?
92            }
93            None => {
94                self.client
95                    .post(&self.webhook_url)
96                    .json(&notification)
97                    .send()
98                    .await?
99            }
100        };
101
102        if response.status().is_success() {
103            Ok(WebhookResponse {
104                status: "success".to_string(),
105                message: None,
106            })
107        } else {
108            let error_message: String = response.text().await?;
109            Err(WebhookNotificationError::WebhookError(error_message))
110        }
111    }
112}
113
114#[derive(Debug, Error)]
115#[allow(clippy::enum_variant_names)]
116pub enum WebhookNotificationError {
117    #[error("Request error: {0}")]
118    RequestError(#[from] reqwest::Error),
119    #[error("Response error: {0}")]
120    ResponseError(#[from] serde_json::Error),
121    #[error("Webhook error: {0}")]
122    WebhookError(String),
123    #[error("Signing error: {0}")]
124    SigningError(String),
125}
126
127#[cfg(test)]
128mod tests {
129    use crate::models::U256;
130    use crate::models::{
131        EvmTransactionResponse, SecretString, TransactionResponse, TransactionStatus,
132    };
133    use crate::models::{WebhookNotification, WebhookPayload};
134    use crate::services::notification::WebhookNotificationService;
135    use base64::{engine::general_purpose::STANDARD, Engine};
136    use mockito;
137    use serde_json::json;
138
139    fn mock_transaction_response() -> TransactionResponse {
140        TransactionResponse::Evm(Box::new(EvmTransactionResponse {
141            id: "tx_123".to_string(),
142            hash: Some("0x123...".to_string()),
143            status: TransactionStatus::Pending,
144            status_reason: None,
145            created_at: "2024-03-20T10:00:00Z".to_string(),
146            sent_at: Some("2024-03-20T10:00:01Z".to_string()),
147            confirmed_at: None,
148            gas_price: Some(0u128),
149            gas_limit: Some(21000u64),
150            nonce: Some(1u64),
151            value: U256::from(0),
152            from: "0x123...".to_string(),
153            to: Some("0x456...".to_string()),
154            relayer_id: "relayer_123".to_string(),
155            data: None,
156            max_fee_per_gas: None,
157            max_priority_fee_per_gas: None,
158            signature: None,
159            speed: None,
160            is_canceled: None,
161        }))
162    }
163
164    #[tokio::test]
165    async fn test_successful_notification_with_signature() {
166        let mut mock_server = mockito::Server::new_async().await;
167        let _mock = mock_server
168            .mock("POST", "/")
169            .match_header("X-Signature", mockito::Matcher::Any)
170            .with_status(200)
171            .with_header("content-type", "application/json")
172            .with_body(
173                serde_json::to_string(&json!({
174                    "status": "success",
175                    "message": null
176                }))
177                .unwrap(),
178            )
179            .create_async()
180            .await;
181
182        let secret_key = SecretString::new("test_secret");
183        let service = WebhookNotificationService::new(mock_server.url(), Some(secret_key));
184
185        let notification = WebhookNotification {
186            id: "123".to_string(),
187            event: "test_event".to_string(),
188            payload: WebhookPayload::Transaction(mock_transaction_response()),
189            timestamp: "2021-01-01T00:00:00Z".to_string(),
190        };
191
192        let result = service.send_notification(notification).await;
193        assert!(result.is_ok());
194    }
195
196    #[tokio::test]
197    async fn test_failed_notification_without_signature() {
198        let mut mock_server = mockito::Server::new_async().await;
199        let _mock = mock_server
200            .mock("POST", "/")
201            .with_status(200)
202            .with_header("content-type", "application/json")
203            .with_body(
204                serde_json::to_string(&json!({
205                    "status": "success",
206                    "message": null
207                }))
208                .unwrap(),
209            )
210            .create_async()
211            .await;
212
213        let service = WebhookNotificationService::new(mock_server.url(), None);
214
215        let notification = WebhookNotification {
216            id: "123".to_string(),
217            event: "test_event".to_string(),
218            payload: WebhookPayload::Transaction(mock_transaction_response()),
219            timestamp: "2021-01-01T00:00:00Z".to_string(),
220        };
221
222        let result = service.send_notification(notification).await;
223        assert!(result.is_ok());
224    }
225
226    #[tokio::test]
227    async fn test_failed_notification_with_http_error() {
228        let mut mock_server = mockito::Server::new_async().await;
229        let _mock = mock_server
230            .mock("POST", "/")
231            .with_status(500)
232            .with_header("content-type", "application/json")
233            .with_body(
234                serde_json::to_string(&json!({
235                    "status": "error",
236                    "message": "Internal Server Error"
237                }))
238                .unwrap(),
239            )
240            .create_async()
241            .await;
242
243        let secret_key = SecretString::new("test_secret");
244        let service = WebhookNotificationService::new(mock_server.url(), Some(secret_key));
245
246        let notification = WebhookNotification {
247            id: "123".to_string(),
248            event: "test_event".to_string(),
249            payload: WebhookPayload::Transaction(mock_transaction_response()),
250            timestamp: "2021-01-01T00:00:00Z".to_string(),
251        };
252
253        let result = service.send_notification(notification).await;
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn test_sign_payload() {
259        let service = WebhookNotificationService::new(
260            "http://example.com".to_string(),
261            Some(SecretString::new("test_secret")),
262        );
263
264        let payload = r#"{"test": "data"}"#;
265        let result = service.sign_payload(payload, &SecretString::new("test_secret"));
266
267        // Verify the signature is generated successfully
268        assert!(result.is_ok());
269
270        // Verify it's a valid base64 string
271        let signature = result.unwrap();
272        assert!(STANDARD.decode(&signature).is_ok());
273
274        // Verify deterministic behavior (same input produces same output)
275        let second_result = service
276            .sign_payload(payload, &SecretString::new("test_secret"))
277            .unwrap();
278        assert_eq!(signature, second_result);
279    }
280}