openzeppelin_relayer/models/notification/
webhook_notification.rs

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