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        }))
161    }
162
163    #[tokio::test]
164    async fn test_successful_notification_with_signature() {
165        let mut mock_server = mockito::Server::new_async().await;
166        let _mock = mock_server
167            .mock("POST", "/")
168            .match_header("X-Signature", mockito::Matcher::Any)
169            .with_status(200)
170            .with_header("content-type", "application/json")
171            .with_body(
172                serde_json::to_string(&json!({
173                    "status": "success",
174                    "message": null
175                }))
176                .unwrap(),
177            )
178            .create_async()
179            .await;
180
181        let secret_key = SecretString::new("test_secret");
182        let service = WebhookNotificationService::new(mock_server.url(), Some(secret_key));
183
184        let notification = WebhookNotification {
185            id: "123".to_string(),
186            event: "test_event".to_string(),
187            payload: WebhookPayload::Transaction(mock_transaction_response()),
188            timestamp: "2021-01-01T00:00:00Z".to_string(),
189        };
190
191        let result = service.send_notification(notification).await;
192        assert!(result.is_ok());
193    }
194
195    #[tokio::test]
196    async fn test_failed_notification_without_signature() {
197        let mut mock_server = mockito::Server::new_async().await;
198        let _mock = mock_server
199            .mock("POST", "/")
200            .with_status(200)
201            .with_header("content-type", "application/json")
202            .with_body(
203                serde_json::to_string(&json!({
204                    "status": "success",
205                    "message": null
206                }))
207                .unwrap(),
208            )
209            .create_async()
210            .await;
211
212        let service = WebhookNotificationService::new(mock_server.url(), None);
213
214        let notification = WebhookNotification {
215            id: "123".to_string(),
216            event: "test_event".to_string(),
217            payload: WebhookPayload::Transaction(mock_transaction_response()),
218            timestamp: "2021-01-01T00:00:00Z".to_string(),
219        };
220
221        let result = service.send_notification(notification).await;
222        assert!(result.is_ok());
223    }
224
225    #[tokio::test]
226    async fn test_failed_notification_with_http_error() {
227        let mut mock_server = mockito::Server::new_async().await;
228        let _mock = mock_server
229            .mock("POST", "/")
230            .with_status(500)
231            .with_header("content-type", "application/json")
232            .with_body(
233                serde_json::to_string(&json!({
234                    "status": "error",
235                    "message": "Internal Server Error"
236                }))
237                .unwrap(),
238            )
239            .create_async()
240            .await;
241
242        let secret_key = SecretString::new("test_secret");
243        let service = WebhookNotificationService::new(mock_server.url(), Some(secret_key));
244
245        let notification = WebhookNotification {
246            id: "123".to_string(),
247            event: "test_event".to_string(),
248            payload: WebhookPayload::Transaction(mock_transaction_response()),
249            timestamp: "2021-01-01T00:00:00Z".to_string(),
250        };
251
252        let result = service.send_notification(notification).await;
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn test_sign_payload() {
258        let service = WebhookNotificationService::new(
259            "http://example.com".to_string(),
260            Some(SecretString::new("test_secret")),
261        );
262
263        let payload = r#"{"test": "data"}"#;
264        let result = service.sign_payload(payload, &SecretString::new("test_secret"));
265
266        // Verify the signature is generated successfully
267        assert!(result.is_ok());
268
269        // Verify it's a valid base64 string
270        let signature = result.unwrap();
271        assert!(STANDARD.decode(&signature).is_ok());
272
273        // Verify deterministic behavior (same input produces same output)
274        let second_result = service
275            .sign_payload(payload, &SecretString::new("test_secret"))
276            .unwrap();
277        assert_eq!(signature, second_result);
278    }
279}