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