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