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(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 assert!(result.is_ok());
255
256 let signature = result.unwrap();
258 assert!(STANDARD.decode(&signature).is_ok());
259
260 let second_result = service
262 .sign_payload(payload, &SecretString::new("test_secret"))
263 .unwrap();
264 assert_eq!(signature, second_result);
265 }
266}