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