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 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 assert!(result.is_ok());
268
269 let signature = result.unwrap();
271 assert!(STANDARD.decode(&signature).is_ok());
272
273 let second_result = service
275 .sign_payload(payload, &SecretString::new("test_secret"))
276 .unwrap();
277 assert_eq!(signature, second_result);
278 }
279}