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 serde_json::json;
137    use wiremock::matchers::{header_exists, method, path};
138    use wiremock::{Mock, MockServer, ResponseTemplate};
139
140    fn mock_transaction_response() -> TransactionResponse {
141        TransactionResponse::Evm(Box::new(EvmTransactionResponse {
142            id: "tx_123".to_string(),
143            hash: Some("0x123...".to_string()),
144            status: TransactionStatus::Pending,
145            status_reason: None,
146            created_at: "2024-03-20T10:00:00Z".to_string(),
147            sent_at: Some("2024-03-20T10:00:01Z".to_string()),
148            confirmed_at: None,
149            gas_price: Some(0u128),
150            gas_limit: Some(21000u64),
151            nonce: Some(1u64),
152            value: U256::from(0),
153            from: "0x123...".to_string(),
154            to: Some("0x456...".to_string()),
155            relayer_id: "relayer_123".to_string(),
156            data: None,
157            max_fee_per_gas: None,
158            max_priority_fee_per_gas: None,
159            signature: None,
160            speed: None,
161        }))
162    }
163
164    #[tokio::test]
165    async fn test_successful_notification_with_signature() {
166        let mock_server = MockServer::start().await;
167        Mock::given(method("POST"))
168            .and(path("/"))
169            .and(header_exists("X-Signature"))
170            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
171                "status": "success",
172                "message": null
173            })))
174            .mount(&mock_server)
175            .await;
176
177        let secret_key = SecretString::new("test_secret");
178        let service = WebhookNotificationService::new(
179            mock_server.uri().to_string(),
180            Some(secret_key.clone()),
181        );
182
183        let notification = WebhookNotification {
184            id: "123".to_string(),
185            event: "test_event".to_string(),
186            payload: WebhookPayload::Transaction(mock_transaction_response()),
187            timestamp: "2021-01-01T00:00:00Z".to_string(),
188        };
189
190        let result = service.send_notification(notification).await;
191        assert!(result.is_ok());
192    }
193
194    #[tokio::test]
195    async fn test_failed_notification_without_signature() {
196        let mock_server = MockServer::start().await;
197        Mock::given(method("POST"))
198            .and(path("/"))
199            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
200                "status": "success",
201                "message": null
202            })))
203            .mount(&mock_server)
204            .await;
205
206        let service = WebhookNotificationService::new(mock_server.uri().to_string(), None);
207
208        let notification = WebhookNotification {
209            id: "123".to_string(),
210            event: "test_event".to_string(),
211            payload: WebhookPayload::Transaction(mock_transaction_response()),
212            timestamp: "2021-01-01T00:00:00Z".to_string(),
213        };
214
215        let result = service.send_notification(notification).await;
216        assert!(result.is_ok());
217    }
218
219    #[tokio::test]
220    async fn test_failed_notification_with_http_error() {
221        let mock_server = MockServer::start().await;
222        Mock::given(method("POST"))
223            .and(path("/"))
224            .respond_with(ResponseTemplate::new(500).set_body_json(json!({
225                "status": "error",
226                "message": "Internal Server Error"
227            })))
228            .mount(&mock_server)
229            .await;
230
231        let secret_key = SecretString::new("test_secret");
232        let service = WebhookNotificationService::new(
233            mock_server.uri().to_string(),
234            Some(secret_key.clone()),
235        );
236
237        let notification = WebhookNotification {
238            id: "123".to_string(),
239            event: "test_event".to_string(),
240            payload: WebhookPayload::Transaction(mock_transaction_response()),
241            timestamp: "2021-01-01T00:00:00Z".to_string(),
242        };
243
244        let result = service.send_notification(notification).await;
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn test_sign_payload() {
250        let service = WebhookNotificationService::new(
251            "http://example.com".to_string(),
252            Some(SecretString::new("test_secret")),
253        );
254
255        let payload = r#"{"test": "data"}"#;
256        let result = service.sign_payload(payload, &SecretString::new("test_secret"));
257
258        // Verify the signature is generated successfully
259        assert!(result.is_ok());
260
261        // Verify it's a valid base64 string
262        let signature = result.unwrap();
263        assert!(STANDARD.decode(&signature).is_ok());
264
265        // Verify deterministic behavior (same input produces same output)
266        let second_result = service
267            .sign_payload(payload, &SecretString::new("test_secret"))
268            .unwrap();
269        assert_eq!(signature, second_result);
270    }
271}