openzeppelin_relayer/models/notification/
webhook_notification.rs

1use crate::{
2    domain::SwapResult,
3    jobs::NotificationSend,
4    models::{
5        RelayerRepoModel, RelayerResponse, SignAndSendTransactionResult, SignTransactionResult,
6        TransactionRepoModel, TransactionResponse, TransferTransactionResult,
7    },
8};
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
14pub struct WebhookNotification {
15    pub id: String,
16    pub event: String,
17    pub payload: WebhookPayload,
18    pub timestamp: String,
19}
20
21impl WebhookNotification {
22    pub fn new(event: String, payload: WebhookPayload) -> Self {
23        Self {
24            id: Uuid::new_v4().to_string(),
25            event,
26            payload,
27            timestamp: Utc::now().to_rfc3339(),
28        }
29    }
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
33pub struct TransactionFailurePayload {
34    pub transaction: TransactionResponse,
35    pub failure_reason: String,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
39pub struct RelayerDisabledPayload {
40    pub relayer: RelayerResponse,
41    pub disable_reason: String,
42}
43
44#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
45pub struct RelayerEnabledPayload {
46    pub relayer: RelayerResponse,
47    pub retry_count: u32,
48}
49#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
50pub struct SolanaDexPayload {
51    pub swap_results: Vec<SwapResult>,
52}
53
54#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
55#[serde(rename_all = "lowercase")]
56#[serde(tag = "payload_type")]
57pub enum WebhookPayload {
58    Transaction(TransactionResponse),
59    #[serde(rename = "transaction_failure")]
60    TransactionFailure(TransactionFailurePayload),
61    #[serde(rename = "relayer_disabled")]
62    RelayerDisabled(Box<RelayerDisabledPayload>),
63    #[serde(rename = "relayer_enabled")]
64    RelayerEnabled(Box<RelayerEnabledPayload>),
65    #[serde(rename = "solana_rpc")]
66    SolanaRpc(SolanaWebhookRpcPayload),
67    #[serde(rename = "solana_dex")]
68    SolanaDex(SolanaDexPayload),
69}
70
71#[derive(Debug, Serialize, Deserialize, Clone)]
72pub struct WebhookResponse {
73    pub status: String,
74    pub message: Option<String>,
75}
76
77pub fn produce_transaction_update_notification_payload(
78    notification_id: &str,
79    transaction: &TransactionRepoModel,
80) -> NotificationSend {
81    let tx_payload: TransactionResponse = transaction.clone().into();
82    NotificationSend::new(
83        notification_id.to_string(),
84        WebhookNotification::new(
85            "transaction_update".to_string(),
86            WebhookPayload::Transaction(tx_payload),
87        ),
88    )
89}
90
91pub fn produce_relayer_disabled_payload(
92    notification_id: &str,
93    relayer: &RelayerRepoModel,
94    reason: &str,
95) -> NotificationSend {
96    let relayer_response: RelayerResponse = relayer.clone().into();
97    let payload = RelayerDisabledPayload {
98        relayer: relayer_response,
99        disable_reason: reason.to_string(),
100    };
101    NotificationSend::new(
102        notification_id.to_string(),
103        WebhookNotification::new(
104            "relayer_state_update".to_string(),
105            WebhookPayload::RelayerDisabled(Box::new(payload)),
106        ),
107    )
108}
109
110pub fn produce_relayer_enabled_payload(
111    notification_id: &str,
112    relayer: &RelayerRepoModel,
113    retry_count: u32,
114) -> NotificationSend {
115    let relayer_response: RelayerResponse = relayer.clone().into();
116    let payload = RelayerEnabledPayload {
117        relayer: relayer_response,
118        retry_count,
119    };
120    NotificationSend::new(
121        notification_id.to_string(),
122        WebhookNotification::new(
123            "relayer_state_update".to_string(),
124            WebhookPayload::RelayerEnabled(Box::new(payload)),
125        ),
126    )
127}
128
129#[allow(clippy::enum_variant_names)]
130#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
131#[serde(untagged)]
132pub enum SolanaWebhookRpcPayload {
133    SignAndSendTransaction(SignAndSendTransactionResult),
134    SignTransaction(SignTransactionResult),
135    TransferTransaction(TransferTransactionResult),
136}
137
138/// Produces a notification payload for a Solana RPC webhook event
139pub fn produce_solana_rpc_webhook_payload(
140    notification_id: &str,
141    event: String,
142    payload: SolanaWebhookRpcPayload,
143) -> NotificationSend {
144    NotificationSend::new(
145        notification_id.to_string(),
146        WebhookNotification::new(event, WebhookPayload::SolanaRpc(payload)),
147    )
148}
149
150pub fn produce_solana_dex_webhook_payload(
151    notification_id: &str,
152    event: String,
153    payload: SolanaDexPayload,
154) -> NotificationSend {
155    NotificationSend::new(
156        notification_id.to_string(),
157        WebhookNotification::new(event, WebhookPayload::SolanaDex(payload)),
158    )
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::utils::mocks::mockutils::{create_mock_relayer, create_mock_transaction};
165
166    #[test]
167    fn test_webhook_notification_new() {
168        let payload = WebhookPayload::Transaction(create_mock_transaction().into());
169        let notification = WebhookNotification::new("test_event".to_string(), payload.clone());
170
171        // Verify structure
172        assert!(!notification.id.is_empty(), "Should have an ID");
173        assert_eq!(notification.event, "test_event");
174        assert_eq!(notification.payload, payload);
175        assert!(
176            !notification.timestamp.is_empty(),
177            "Should have a timestamp"
178        );
179
180        // Verify ID is a valid UUID
181        assert!(
182            Uuid::parse_str(&notification.id).is_ok(),
183            "ID should be a valid UUID"
184        );
185
186        // Verify timestamp is valid RFC3339
187        assert!(
188            chrono::DateTime::parse_from_rfc3339(&notification.timestamp).is_ok(),
189            "Timestamp should be valid RFC3339"
190        );
191    }
192
193    #[test]
194    fn test_webhook_notification_unique_ids() {
195        let payload = WebhookPayload::Transaction(create_mock_transaction().into());
196        let notification1 = WebhookNotification::new("event".to_string(), payload.clone());
197        let notification2 = WebhookNotification::new("event".to_string(), payload);
198
199        // Each notification should have a unique ID
200        assert_ne!(
201            notification1.id, notification2.id,
202            "Each notification should have a unique UUID"
203        );
204    }
205
206    #[test]
207    fn test_produce_transaction_update_notification_payload() {
208        let transaction = create_mock_transaction();
209        let notification_id = "test-notification-id";
210
211        let result = produce_transaction_update_notification_payload(notification_id, &transaction);
212
213        // Verify notification_id
214        assert_eq!(result.notification_id, notification_id);
215
216        // Verify webhook structure
217        assert_eq!(result.notification.event, "transaction_update");
218
219        // Verify payload type
220        match result.notification.payload {
221            WebhookPayload::Transaction(TransactionResponse::Evm(tx)) => {
222                assert_eq!(tx.id, transaction.id);
223                assert_eq!(tx.relayer_id, transaction.relayer_id);
224            }
225            _ => panic!("Expected Transaction(Evm) payload"),
226        }
227    }
228
229    #[test]
230    fn test_produce_relayer_disabled_payload() {
231        let relayer = create_mock_relayer("test-relayer".to_string(), false);
232        let notification_id = "test-notification-id";
233        let reason = "RPC endpoint validation failed";
234
235        let result = produce_relayer_disabled_payload(notification_id, &relayer, reason);
236
237        // Verify notification_id
238        assert_eq!(result.notification_id, notification_id);
239
240        // Verify webhook structure
241        assert_eq!(result.notification.event, "relayer_state_update");
242
243        // Verify payload type and content
244        match result.notification.payload {
245            WebhookPayload::RelayerDisabled(payload) => {
246                assert_eq!(payload.relayer.id, relayer.id);
247                assert_eq!(payload.disable_reason, reason);
248            }
249            _ => panic!("Expected RelayerDisabled payload"),
250        }
251    }
252
253    #[test]
254    fn test_produce_relayer_disabled_payload_with_sensitive_info() {
255        let relayer = create_mock_relayer("test-relayer".to_string(), false);
256        let notification_id = "test-notification-id";
257        // This should be a safe description (from safe_description())
258        let reason = "RPC endpoint validation failed";
259
260        let result = produce_relayer_disabled_payload(notification_id, &relayer, reason);
261
262        match result.notification.payload {
263            WebhookPayload::RelayerDisabled(payload) => {
264                // Verify it doesn't contain sensitive details
265                assert!(!payload.disable_reason.contains("http://"));
266                assert!(!payload.disable_reason.contains("https://"));
267                assert_eq!(payload.disable_reason, reason);
268            }
269            _ => panic!("Expected RelayerDisabled payload"),
270        }
271    }
272
273    #[test]
274    fn test_produce_relayer_enabled_payload() {
275        let relayer = create_mock_relayer("test-relayer".to_string(), true);
276        let notification_id = "test-notification-id";
277        let retry_count = 5;
278
279        let result = produce_relayer_enabled_payload(notification_id, &relayer, retry_count);
280
281        // Verify notification_id
282        assert_eq!(result.notification_id, notification_id);
283
284        // Verify webhook structure
285        assert_eq!(result.notification.event, "relayer_state_update");
286
287        // Verify payload type and content
288        match result.notification.payload {
289            WebhookPayload::RelayerEnabled(payload) => {
290                assert_eq!(payload.relayer.id, relayer.id);
291                assert_eq!(payload.retry_count, retry_count);
292            }
293            _ => panic!("Expected RelayerEnabled payload"),
294        }
295    }
296
297    #[test]
298    fn test_produce_relayer_enabled_payload_with_zero_retries() {
299        let relayer = create_mock_relayer("test-relayer".to_string(), true);
300        let notification_id = "test-notification-id";
301
302        let result = produce_relayer_enabled_payload(notification_id, &relayer, 0);
303
304        match result.notification.payload {
305            WebhookPayload::RelayerEnabled(payload) => {
306                assert_eq!(payload.retry_count, 0);
307            }
308            _ => panic!("Expected RelayerEnabled payload"),
309        }
310    }
311
312    #[test]
313    fn test_produce_solana_rpc_webhook_payload() {
314        use crate::models::EncodedSerializedTransaction;
315
316        let notification_id = "test-notification-id";
317        let event = "solana_sign_transaction".to_string();
318        let solana_payload = SolanaWebhookRpcPayload::SignTransaction(SignTransactionResult {
319            transaction: EncodedSerializedTransaction::new("test-transaction".to_string()),
320            signature: "test-signature".to_string(),
321        });
322
323        let result =
324            produce_solana_rpc_webhook_payload(notification_id, event.clone(), solana_payload);
325
326        // Verify notification_id
327        assert_eq!(result.notification_id, notification_id);
328
329        // Verify webhook structure
330        assert_eq!(result.notification.event, event);
331
332        // Verify payload type
333        match result.notification.payload {
334            WebhookPayload::SolanaRpc(SolanaWebhookRpcPayload::SignTransaction(sig)) => {
335                assert_eq!(sig.signature, "test-signature");
336            }
337            _ => panic!("Expected SolanaRpc SignTransaction payload"),
338        }
339    }
340
341    #[test]
342    fn test_produce_solana_dex_webhook_payload() {
343        let notification_id = "test-notification-id";
344        let event = "solana_swap_completed".to_string();
345        let swap_results = vec![];
346        let dex_payload = SolanaDexPayload { swap_results };
347
348        let result =
349            produce_solana_dex_webhook_payload(notification_id, event.clone(), dex_payload.clone());
350
351        // Verify notification_id
352        assert_eq!(result.notification_id, notification_id);
353
354        // Verify webhook structure
355        assert_eq!(result.notification.event, event);
356
357        // Verify payload type
358        match result.notification.payload {
359            WebhookPayload::SolanaDex(payload) => {
360                assert_eq!(payload.swap_results.len(), 0);
361            }
362            _ => panic!("Expected SolanaDex payload"),
363        }
364    }
365
366    #[test]
367    fn test_webhook_payload_serialization_transaction() {
368        let transaction = create_mock_transaction();
369        let payload = WebhookPayload::Transaction(transaction.into());
370
371        let serialized = serde_json::to_value(&payload).unwrap();
372
373        // Verify it has the correct tag
374        assert_eq!(serialized["payload_type"], "transaction");
375    }
376
377    #[test]
378    fn test_webhook_payload_serialization_relayer_disabled() {
379        let relayer = create_mock_relayer("test".to_string(), false);
380        let payload = WebhookPayload::RelayerDisabled(Box::new(RelayerDisabledPayload {
381            relayer: relayer.into(),
382            disable_reason: "Test reason".to_string(),
383        }));
384
385        let serialized = serde_json::to_value(&payload).unwrap();
386
387        // Verify it has the correct tag
388        assert_eq!(serialized["payload_type"], "relayer_disabled");
389        assert!(serialized["disable_reason"].is_string());
390    }
391
392    #[test]
393    fn test_webhook_payload_serialization_relayer_enabled() {
394        let relayer = create_mock_relayer("test".to_string(), false);
395        let payload = WebhookPayload::RelayerEnabled(Box::new(RelayerEnabledPayload {
396            relayer: relayer.into(),
397            retry_count: 3,
398        }));
399
400        let serialized = serde_json::to_value(&payload).unwrap();
401
402        // Verify it has the correct tag
403        assert_eq!(serialized["payload_type"], "relayer_enabled");
404        assert_eq!(serialized["retry_count"], 3);
405    }
406
407    #[test]
408    fn test_notification_send_structure() {
409        let transaction = create_mock_transaction();
410        let notification_id = "test-notification-id";
411
412        let notification_send =
413            produce_transaction_update_notification_payload(notification_id, &transaction);
414
415        // Verify the NotificationSend can be serialized (for job queue)
416        let serialized = serde_json::to_string(&notification_send);
417        assert!(
418            serialized.is_ok(),
419            "NotificationSend should be serializable"
420        );
421
422        // Verify it can be deserialized back
423        let deserialized: Result<NotificationSend, _> = serde_json::from_str(&serialized.unwrap());
424        assert!(
425            deserialized.is_ok(),
426            "NotificationSend should be deserializable"
427        );
428    }
429
430    #[test]
431    fn test_relayer_disabled_and_enabled_use_same_event() {
432        let relayer = create_mock_relayer("test".to_string(), false);
433
434        let disabled_notification =
435            produce_relayer_disabled_payload("notif-id", &relayer, "reason");
436        let enabled_notification = produce_relayer_enabled_payload("notif-id", &relayer, 1);
437
438        // Both should use the same event type for consistency
439        assert_eq!(
440            disabled_notification.notification.event,
441            enabled_notification.notification.event
442        );
443        assert_eq!(
444            disabled_notification.notification.event,
445            "relayer_state_update"
446        );
447    }
448}