Skip to main content

quicknode_sdk/webhooks/
webhook.rs

1#[cfg(feature = "rust")]
2use bon::Builder;
3#[cfg(feature = "node")]
4use napi_derive::napi;
5#[cfg(feature = "python")]
6use pyo3::{pyclass, pymethods};
7#[cfg(feature = "python")]
8use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
9use serde::{Deserialize, Deserializer, Serialize};
10
11fn deserialize_as_optional_json_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
12where
13    D: Deserializer<'de>,
14{
15    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
16    match value {
17        None => Ok(None),
18        Some(v) => serde_json::to_string(&v)
19            .map(Some)
20            .map_err(serde::de::Error::custom),
21    }
22}
23
24// ── Enums ──────────────────────────────────────────────────────────────────
25
26/// Identifier of a predefined webhook filter template.
27#[cfg_attr(feature = "node", napi(string_enum))]
28#[cfg_attr(not(feature = "node"), derive(Clone))]
29#[derive(Debug, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum WebhookTemplateId {
32    EvmWalletFilter,
33    EvmContractEvents,
34    EvmAbiFilter,
35    SolanaWalletFilter,
36    BitcoinWalletFilter,
37    XrplWalletFilter,
38    HyperliquidWalletEventsFilter,
39    StellarWalletTransactionsSourceAccountFilter,
40}
41
42impl WebhookTemplateId {
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            WebhookTemplateId::EvmWalletFilter => "evmWalletFilter",
46            WebhookTemplateId::EvmContractEvents => "evmContractEvents",
47            WebhookTemplateId::EvmAbiFilter => "evmAbiFilter",
48            WebhookTemplateId::SolanaWalletFilter => "solanaWalletFilter",
49            WebhookTemplateId::BitcoinWalletFilter => "bitcoinWalletFilter",
50            WebhookTemplateId::XrplWalletFilter => "xrplWalletFilter",
51            WebhookTemplateId::HyperliquidWalletEventsFilter => "hyperliquidWalletEventsFilter",
52            WebhookTemplateId::StellarWalletTransactionsSourceAccountFilter => {
53                "stellarWalletTransactionsSourceAccountFilter"
54            }
55        }
56    }
57}
58
59/// Position a webhook begins (or resumes) delivering from when activated.
60#[cfg_attr(feature = "node", napi(string_enum))]
61#[cfg_attr(not(feature = "node"), derive(Clone))]
62#[derive(Debug, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum WebhookStartFrom {
65    /// Resume from the last-delivered block.
66    Last,
67    /// Start from the newest available block.
68    Latest,
69}
70
71// ── Template Arg Structs ───────────────────────────────────────────────────
72
73/// Template arguments for an EVM wallet filter: matches activity for a list of
74/// wallet addresses.
75#[cfg_attr(feature = "rust", derive(Builder))]
76#[cfg_attr(feature = "python", gen_stub_pyclass)]
77#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
78#[cfg_attr(feature = "node", napi(object))]
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct EvmWalletFilterTemplate {
81    /// Wallet addresses to match against.
82    pub wallets: Vec<String>,
83}
84
85#[cfg(feature = "python")]
86#[gen_stub_pymethods]
87#[pymethods]
88impl EvmWalletFilterTemplate {
89    #[new]
90    pub fn new(wallets: Vec<String>) -> Self {
91        Self { wallets }
92    }
93}
94
95/// Template arguments for filtering EVM contract events, optionally scoped to
96/// a specific set of event topic hashes.
97#[cfg_attr(feature = "rust", derive(Builder))]
98#[cfg_attr(feature = "python", gen_stub_pyclass)]
99#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
100#[cfg_attr(feature = "node", napi(object))]
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct EvmContractEventsTemplate {
104    /// Contract addresses to watch for events.
105    pub contracts: Vec<String>,
106    /// Optional list of event topic hashes to restrict the filter to specific events.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub event_hashes: Option<Vec<String>>,
109}
110
111#[cfg(feature = "python")]
112#[gen_stub_pymethods]
113#[pymethods]
114impl EvmContractEventsTemplate {
115    #[new]
116    #[pyo3(signature = (contracts, event_hashes=None))]
117    pub fn new(contracts: Vec<String>, event_hashes: Option<Vec<String>>) -> Self {
118        Self {
119            contracts,
120            event_hashes,
121        }
122    }
123}
124
125/// Template arguments for an EVM ABI filter: decodes and filters events for a
126/// set of contracts using a provided ABI.
127#[cfg_attr(feature = "rust", derive(Builder))]
128#[cfg_attr(feature = "python", gen_stub_pyclass)]
129#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
130#[cfg_attr(feature = "node", napi(object))]
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct EvmAbiFilterTemplate {
133    /// JSON-encoded contract ABI used to decode event data.
134    pub abi: String,
135    /// Contract addresses to watch for events.
136    pub contracts: Vec<String>,
137}
138
139#[cfg(feature = "python")]
140#[gen_stub_pymethods]
141#[pymethods]
142impl EvmAbiFilterTemplate {
143    #[new]
144    pub fn new(abi: String, contracts: Vec<String>) -> Self {
145        Self { abi, contracts }
146    }
147}
148
149/// Template arguments for a Solana wallet filter: matches activity for a list
150/// of Solana account addresses.
151#[cfg_attr(feature = "rust", derive(Builder))]
152#[cfg_attr(feature = "python", gen_stub_pyclass)]
153#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
154#[cfg_attr(feature = "node", napi(object))]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SolanaWalletFilterTemplate {
157    /// Solana account addresses to match against.
158    pub accounts: Vec<String>,
159}
160
161#[cfg(feature = "python")]
162#[gen_stub_pymethods]
163#[pymethods]
164impl SolanaWalletFilterTemplate {
165    #[new]
166    pub fn new(accounts: Vec<String>) -> Self {
167        Self { accounts }
168    }
169}
170
171/// Template arguments for a Bitcoin wallet filter.
172#[cfg_attr(feature = "rust", derive(Builder))]
173#[cfg_attr(feature = "python", gen_stub_pyclass)]
174#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
175#[cfg_attr(feature = "node", napi(object))]
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct BitcoinWalletFilterTemplate {
178    /// Bitcoin wallet addresses to match against.
179    pub wallets: Vec<String>,
180}
181
182#[cfg(feature = "python")]
183#[gen_stub_pymethods]
184#[pymethods]
185impl BitcoinWalletFilterTemplate {
186    #[new]
187    pub fn new(wallets: Vec<String>) -> Self {
188        Self { wallets }
189    }
190}
191
192/// Template arguments for an XRPL wallet filter.
193#[cfg_attr(feature = "rust", derive(Builder))]
194#[cfg_attr(feature = "python", gen_stub_pyclass)]
195#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
196#[cfg_attr(feature = "node", napi(object))]
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct XrplWalletFilterTemplate {
199    /// XRPL wallet addresses to match against.
200    pub wallets: Vec<String>,
201}
202
203#[cfg(feature = "python")]
204#[gen_stub_pymethods]
205#[pymethods]
206impl XrplWalletFilterTemplate {
207    #[new]
208    pub fn new(wallets: Vec<String>) -> Self {
209        Self { wallets }
210    }
211}
212
213/// Template arguments for a Hyperliquid wallet-events filter.
214#[cfg_attr(feature = "rust", derive(Builder))]
215#[cfg_attr(feature = "python", gen_stub_pyclass)]
216#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
217#[cfg_attr(feature = "node", napi(object))]
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct HyperliquidWalletEventsFilterTemplate {
220    /// Hyperliquid wallet addresses to match against.
221    pub wallets: Vec<String>,
222}
223
224#[cfg(feature = "python")]
225#[gen_stub_pymethods]
226#[pymethods]
227impl HyperliquidWalletEventsFilterTemplate {
228    #[new]
229    pub fn new(wallets: Vec<String>) -> Self {
230        Self { wallets }
231    }
232}
233
234/// Template arguments for a Stellar wallet-transactions filter, matching
235/// transactions where the given wallets are the source account.
236#[cfg_attr(feature = "rust", derive(Builder))]
237#[cfg_attr(feature = "python", gen_stub_pyclass)]
238#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
239#[cfg_attr(feature = "node", napi(object))]
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct StellarWalletTransactionsFilterTemplate {
242    /// Stellar wallet addresses to match against.
243    pub wallets: Vec<String>,
244}
245
246#[cfg(feature = "python")]
247#[gen_stub_pymethods]
248#[pymethods]
249impl StellarWalletTransactionsFilterTemplate {
250    #[new]
251    pub fn new(wallets: Vec<String>) -> Self {
252        Self { wallets }
253    }
254}
255
256// ── Template Args ──────────────────────────────────────────────────────────
257
258/// Template identifier paired with its arguments. Exactly one variant selects
259/// which filter is applied. Consumed by `create_webhook_from_template` and
260/// `update_webhook_template`.
261// Pure-Rust discriminated union; no #[pyclass] / #[napi(object)] because PyO3
262// and napi-rs cannot represent enum-with-data. Each language binding crate
263// wraps this type for its own FFI surface.
264// The serde tag/content pair matches the API wire format when flattened into
265// a request struct.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(tag = "templateId", content = "templateArgs", rename_all = "camelCase")]
268pub enum TemplateArgs {
269    /// EVM wallet filter: matches activity for a list of wallet addresses.
270    EvmWalletFilter(EvmWalletFilterTemplate),
271    /// EVM contract events filter, optionally scoped to specific event topic hashes.
272    EvmContractEvents(EvmContractEventsTemplate),
273    /// EVM ABI filter: decodes and filters events using a provided ABI.
274    EvmAbiFilter(EvmAbiFilterTemplate),
275    /// Solana wallet filter.
276    SolanaWalletFilter(SolanaWalletFilterTemplate),
277    /// Bitcoin wallet filter.
278    BitcoinWalletFilter(BitcoinWalletFilterTemplate),
279    /// XRPL wallet filter.
280    XrplWalletFilter(XrplWalletFilterTemplate),
281    /// Hyperliquid wallet-events filter.
282    HyperliquidWalletEventsFilter(HyperliquidWalletEventsFilterTemplate),
283    /// Stellar wallet-transactions filter (source-account match).
284    StellarWalletTransactionsSourceAccountFilter(StellarWalletTransactionsFilterTemplate),
285}
286
287impl TemplateArgs {
288    pub fn tag(&self) -> WebhookTemplateId {
289        match self {
290            Self::EvmWalletFilter(_) => WebhookTemplateId::EvmWalletFilter,
291            Self::EvmContractEvents(_) => WebhookTemplateId::EvmContractEvents,
292            Self::EvmAbiFilter(_) => WebhookTemplateId::EvmAbiFilter,
293            Self::SolanaWalletFilter(_) => WebhookTemplateId::SolanaWalletFilter,
294            Self::BitcoinWalletFilter(_) => WebhookTemplateId::BitcoinWalletFilter,
295            Self::XrplWalletFilter(_) => WebhookTemplateId::XrplWalletFilter,
296            Self::HyperliquidWalletEventsFilter(_) => {
297                WebhookTemplateId::HyperliquidWalletEventsFilter
298            }
299            Self::StellarWalletTransactionsSourceAccountFilter(_) => {
300                WebhookTemplateId::StellarWalletTransactionsSourceAccountFilter
301            }
302        }
303    }
304}
305
306// ── Webhook Destination Attributes ─────────────────────────────────────────
307
308/// Destination configuration for a webhook.
309#[cfg_attr(feature = "python", gen_stub_pyclass)]
310#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
311#[cfg_attr(feature = "node", napi(object))]
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct WebhookDestinationAttributes {
314    /// Target URL that receives webhook payloads.
315    pub url: String,
316    /// Optional token sent with each payload so the receiver can verify authenticity; generated automatically when omitted.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub security_token: Option<String>,
319    /// Optional payload compression (`gzip` or `none`).
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub compression: Option<String>,
322}
323
324#[cfg(feature = "python")]
325#[gen_stub_pymethods]
326#[pymethods]
327impl WebhookDestinationAttributes {
328    #[new]
329    #[pyo3(signature = (url, security_token=None, compression=None))]
330    pub fn new(url: String, security_token: Option<String>, compression: Option<String>) -> Self {
331        Self {
332            url,
333            security_token,
334            compression,
335        }
336    }
337}
338
339// ── Request Types ──────────────────────────────────────────────────────────
340
341/// Parameters for `list_webhooks`.
342#[cfg_attr(feature = "python", gen_stub_pyclass)]
343#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
344#[cfg_attr(feature = "node", napi(object))]
345#[cfg_attr(not(feature = "node"), derive(Clone))]
346#[derive(Debug, Default, Serialize, Deserialize)]
347pub struct GetWebhooksParams {
348    /// Maximum number of webhooks returned.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub limit: Option<i64>,
351    /// Starting index into the result set.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub offset: Option<i64>,
354}
355
356#[cfg(feature = "python")]
357#[gen_stub_pymethods]
358#[pymethods]
359impl GetWebhooksParams {
360    #[new]
361    #[pyo3(signature = (limit=None, offset=None))]
362    pub fn new(limit: Option<i64>, offset: Option<i64>) -> Self {
363        Self { limit, offset }
364    }
365}
366
367/// Parameters for `update_webhook`. All fields are optional; only set fields
368/// are modified.
369#[cfg_attr(feature = "python", gen_stub_pyclass)]
370#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
371#[cfg_attr(feature = "node", napi(object))]
372#[cfg_attr(not(feature = "node"), derive(Clone))]
373#[derive(Debug, Default, Serialize, Deserialize)]
374pub struct UpdateWebhookParams {
375    /// New human-readable name.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub name: Option<String>,
378    /// New notification email.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub notification_email: Option<String>,
381    /// New destination configuration.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub destination_attributes: Option<WebhookDestinationAttributes>,
384}
385
386#[cfg(feature = "python")]
387#[gen_stub_pymethods]
388#[pymethods]
389impl UpdateWebhookParams {
390    #[new]
391    #[pyo3(signature = (name=None, notification_email=None, destination_attributes=None))]
392    pub fn new(
393        name: Option<String>,
394        notification_email: Option<String>,
395        destination_attributes: Option<WebhookDestinationAttributes>,
396    ) -> Self {
397        Self {
398            name,
399            notification_email,
400            destination_attributes,
401        }
402    }
403}
404
405/// Parameters for `activate_webhook`.
406#[cfg_attr(feature = "node", napi(object))]
407#[cfg_attr(not(feature = "node"), derive(Clone))]
408#[derive(Debug, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct ActivateWebhookParams {
411    /// Position to begin (or resume) delivery from.
412    pub start_from: WebhookStartFrom,
413}
414
415/// Parameters for `create_webhook_from_template`.
416#[cfg_attr(feature = "rust", derive(Builder))]
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct CreateWebhookFromTemplateParams {
419    /// Human-readable label for the webhook.
420    pub name: String,
421    /// Blockchain network to watch (e.g. `ethereum-mainnet`).
422    pub network: String,
423    /// Optional email that receives alerts if the webhook terminates.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub notification_email: Option<String>,
426    /// Destination configuration for delivered payloads.
427    pub destination_attributes: WebhookDestinationAttributes,
428    /// Filter template identifier and its arguments.
429    // Flattening the enum's tag/content produces { templateId, templateArgs }.
430    #[serde(flatten)]
431    pub template_args: TemplateArgs,
432}
433
434/// Parameters for `update_webhook_template`.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct UpdateWebhookTemplateParams {
437    /// New human-readable name.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub name: Option<String>,
440    /// New notification email.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub notification_email: Option<String>,
443    /// New destination configuration.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub destination_attributes: Option<WebhookDestinationAttributes>,
446    /// New template identifier and arguments.
447    // Flattening the enum's tag/content produces { templateId, templateArgs }.
448    #[serde(flatten)]
449    pub template_args: TemplateArgs,
450}
451
452// ── Response Types ─────────────────────────────────────────────────────────
453
454/// A webhook's full configuration and current state.
455#[cfg_attr(feature = "python", gen_stub_pyclass)]
456#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
457#[cfg_attr(feature = "node", napi(object))]
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct Webhook {
460    /// Unique webhook identifier.
461    pub id: String,
462    /// Human-readable webhook name.
463    pub name: String,
464    /// Current operational state (e.g. `active`, `paused`).
465    pub status: String,
466    /// Blockchain network the webhook is watching.
467    pub network: String,
468    /// Timestamp when the webhook was created.
469    pub created_at: String,
470    /// Timestamp of the most recent modification.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub updated_at: Option<String>,
473    /// Template identifier used to create the webhook, if any.
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub template_id: Option<String>,
476    /// Email address notified of webhook terminations or failures.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub notification_email: Option<String>,
479    /// Destination-specific configuration as a JSON string.
480    #[serde(
481        default,
482        skip_serializing_if = "Option::is_none",
483        deserialize_with = "deserialize_as_optional_json_string"
484    )]
485    pub destination_attributes: Option<String>,
486}
487
488/// Pagination metadata returned alongside a paginated webhooks list.
489#[cfg_attr(feature = "python", gen_stub_pyclass)]
490#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
491#[cfg_attr(feature = "node", napi(object))]
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct WebhookPageInfo {
494    /// Page size used for this response.
495    pub limit: i64,
496    /// Starting index of this page within the full result set.
497    pub offset: i64,
498    /// Total number of webhooks matching the query across all pages.
499    pub total: i64,
500}
501
502/// Response from `list_webhooks`.
503#[cfg_attr(feature = "python", gen_stub_pyclass)]
504#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
505#[cfg_attr(feature = "node", napi(object))]
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct ListWebhooksResponse {
508    /// Webhooks on the current page.
509    pub data: Vec<Webhook>,
510    /// Pagination metadata for the response.
511    #[serde(rename = "pageInfo")]
512    pub page_info: WebhookPageInfo,
513}
514
515/// Response from `get_enabled_count` for webhooks.
516#[cfg_attr(feature = "python", gen_stub_pyclass)]
517#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
518#[cfg_attr(feature = "node", napi(object))]
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct WebhookEnabledCountResponse {
521    /// Total count of enabled webhooks on the account.
522    pub total: i64,
523}
524
525#[cfg(test)]
526#[allow(clippy::unwrap_used)]
527mod template_args_tests {
528    use super::*;
529
530    #[test]
531    fn evm_wallet_filter_roundtrip() {
532        let args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
533            wallets: vec!["0xabc".to_string()],
534        });
535        let json = serde_json::to_string(&args).unwrap();
536        assert!(json.contains(r#""templateId":"evmWalletFilter""#));
537        assert!(json.contains(r#""wallets":["0xabc"]"#));
538        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
539        assert!(matches!(parsed, TemplateArgs::EvmWalletFilter(_)));
540        assert!(matches!(parsed.tag(), WebhookTemplateId::EvmWalletFilter));
541    }
542
543    #[test]
544    fn evm_contract_events_roundtrip() {
545        let args = TemplateArgs::EvmContractEvents(EvmContractEventsTemplate {
546            contracts: vec!["0xdef".to_string()],
547            event_hashes: Some(vec!["0x1234".to_string()]),
548        });
549        let json = serde_json::to_string(&args).unwrap();
550        assert!(json.contains(r#""templateId":"evmContractEvents""#));
551        // API expects camelCase `eventHashes` — snake_case is silently rejected with a 500.
552        assert!(json.contains(r#""eventHashes":["0x1234"]"#));
553        assert!(!json.contains("event_hashes"));
554        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
555        assert!(matches!(parsed, TemplateArgs::EvmContractEvents(_)));
556    }
557
558    #[test]
559    fn evm_abi_filter_roundtrip() {
560        let args = TemplateArgs::EvmAbiFilter(EvmAbiFilterTemplate {
561            abi: "[]".to_string(),
562            contracts: vec!["0xdef".to_string()],
563        });
564        let json = serde_json::to_string(&args).unwrap();
565        assert!(json.contains(r#""templateId":"evmAbiFilter""#));
566        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
567        assert!(matches!(parsed, TemplateArgs::EvmAbiFilter(_)));
568    }
569
570    #[test]
571    fn solana_wallet_filter_roundtrip() {
572        let args = TemplateArgs::SolanaWalletFilter(SolanaWalletFilterTemplate {
573            accounts: vec!["acc".to_string()],
574        });
575        let json = serde_json::to_string(&args).unwrap();
576        assert!(json.contains(r#""templateId":"solanaWalletFilter""#));
577        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
578        assert!(matches!(parsed, TemplateArgs::SolanaWalletFilter(_)));
579    }
580
581    #[test]
582    fn bitcoin_wallet_filter_roundtrip() {
583        let args = TemplateArgs::BitcoinWalletFilter(BitcoinWalletFilterTemplate {
584            wallets: vec!["bc1".to_string()],
585        });
586        let json = serde_json::to_string(&args).unwrap();
587        assert!(json.contains(r#""templateId":"bitcoinWalletFilter""#));
588        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
589        assert!(matches!(parsed, TemplateArgs::BitcoinWalletFilter(_)));
590    }
591
592    #[test]
593    fn xrpl_wallet_filter_roundtrip() {
594        let args = TemplateArgs::XrplWalletFilter(XrplWalletFilterTemplate {
595            wallets: vec!["r1".to_string()],
596        });
597        let json = serde_json::to_string(&args).unwrap();
598        assert!(json.contains(r#""templateId":"xrplWalletFilter""#));
599        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
600        assert!(matches!(parsed, TemplateArgs::XrplWalletFilter(_)));
601    }
602
603    #[test]
604    fn hyperliquid_wallet_events_filter_roundtrip() {
605        let args =
606            TemplateArgs::HyperliquidWalletEventsFilter(HyperliquidWalletEventsFilterTemplate {
607                wallets: vec!["0xhl".to_string()],
608            });
609        let json = serde_json::to_string(&args).unwrap();
610        assert!(json.contains(r#""templateId":"hyperliquidWalletEventsFilter""#));
611        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
612        assert!(matches!(
613            parsed,
614            TemplateArgs::HyperliquidWalletEventsFilter(_)
615        ));
616    }
617
618    #[test]
619    fn stellar_wallet_transactions_filter_roundtrip() {
620        let args = TemplateArgs::StellarWalletTransactionsSourceAccountFilter(
621            StellarWalletTransactionsFilterTemplate {
622                wallets: vec!["G...".to_string()],
623            },
624        );
625        let json = serde_json::to_string(&args).unwrap();
626        assert!(json.contains(r#""templateId":"stellarWalletTransactionsSourceAccountFilter""#));
627        let parsed: TemplateArgs = serde_json::from_str(&json).unwrap();
628        assert!(matches!(
629            parsed,
630            TemplateArgs::StellarWalletTransactionsSourceAccountFilter(_)
631        ));
632    }
633
634    #[test]
635    fn create_params_flattens_template_args() {
636        let params = CreateWebhookFromTemplateParams {
637            name: "n".to_string(),
638            network: "ethereum-mainnet".to_string(),
639            notification_email: None,
640            destination_attributes: WebhookDestinationAttributes {
641                url: "https://x".to_string(),
642                security_token: None,
643                compression: None,
644            },
645            template_args: TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
646                wallets: vec!["0xabc".to_string()],
647            }),
648        };
649        let json = serde_json::to_value(&params).unwrap();
650        let obj = json.as_object().unwrap();
651        assert_eq!(
652            obj.get("templateId").and_then(|v| v.as_str()),
653            Some("evmWalletFilter")
654        );
655        assert!(obj.get("templateArgs").unwrap().is_object());
656        assert_eq!(obj["templateArgs"]["wallets"][0].as_str(), Some("0xabc"));
657    }
658}