openzeppelin_relayer/services/notification/
mod.rs1use 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(¬ification)?;
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(¬ification)
90 .send()
91 .await?
92 }
93 None => {
94 self.client
95 .post(&self.webhook_url)
96 .json(¬ification)
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 assert!(result.is_ok());
260
261 let signature = result.unwrap();
263 assert!(STANDARD.decode(&signature).is_ok());
264
265 let second_result = service
267 .sign_payload(payload, &SecretString::new("test_secret"))
268 .unwrap();
269 assert_eq!(signature, second_result);
270 }
271}