Skip to main content

defi_tracker_lifecycle/protocols/
dca.rs

1use crate::error::Error;
2use crate::lifecycle::adapters::{
3    CorrelationOutcome, EventPayload, ProtocolAdapter, dca_closed_terminal_status,
4};
5use crate::protocols::{AccountInfo, EventType, Protocol, ProtocolHelpers};
6use crate::types::{RawEvent, RawInstruction, ResolveContext};
7use strum::VariantNames;
8
9/// Serde-tagged envelope for Jupiter DCA event variants.
10///
11/// Variant names mirror the Carbon decoder crate exactly.
12#[derive(serde::Deserialize, strum_macros::VariantNames)]
13pub enum DcaEventEnvelope {
14    OpenedEvent(DcaKeyHolder),
15    FilledEvent(FilledEventFields),
16    ClosedEvent(ClosedEventFields),
17    CollectedFeeEvent(DcaKeyHolder),
18    WithdrawEvent(DcaKeyHolder),
19    DepositEvent(DcaKeyHolder),
20}
21
22/// Serde-tagged envelope for Jupiter DCA instruction variants.
23///
24/// Inner `Value` is unused at runtime — serde consumes it during deserialization.
25#[derive(serde::Deserialize)]
26pub enum DcaInstructionKind {
27    OpenDca(serde_json::Value),
28    OpenDcaV2(serde_json::Value),
29    InitiateFlashFill(serde_json::Value),
30    InitiateDlmmFill(serde_json::Value),
31    FulfillFlashFill(serde_json::Value),
32    FulfillDlmmFill(serde_json::Value),
33    CloseDca(serde_json::Value),
34    EndAndClose(serde_json::Value),
35    Transfer(serde_json::Value),
36    Deposit(serde_json::Value),
37    Withdraw(serde_json::Value),
38    WithdrawFees(serde_json::Value),
39}
40
41#[cfg(feature = "wasm")]
42pub const INSTRUCTION_EVENT_TYPES: &[(&str, EventType)] = &[
43    ("OpenDca", EventType::Created),
44    ("OpenDcaV2", EventType::Created),
45    ("InitiateFlashFill", EventType::FillInitiated),
46    ("InitiateDlmmFill", EventType::FillInitiated),
47    ("FulfillFlashFill", EventType::FillCompleted),
48    ("FulfillDlmmFill", EventType::FillCompleted),
49    ("CloseDca", EventType::Closed),
50    ("EndAndClose", EventType::Closed),
51];
52
53#[cfg(feature = "wasm")]
54pub const EVENT_EVENT_TYPES: &[(&str, EventType)] = &[
55    ("OpenedEvent", EventType::Created),
56    ("FilledEvent", EventType::FillCompleted),
57    ("ClosedEvent", EventType::Closed),
58    ("CollectedFeeEvent", EventType::FeeCollected),
59    ("WithdrawEvent", EventType::Withdrawn),
60    ("DepositEvent", EventType::Deposited),
61];
62
63#[cfg(feature = "wasm")]
64pub const CLOSED_VARIANTS: &[&str] = &["Completed", "Cancelled", "Expired"];
65
66/// Jupiter DCA protocol adapter (zero-sized, stored as a static).
67#[derive(Debug)]
68pub struct DcaAdapter;
69
70/// Serde intermediate for `FilledEvent` payload fields.
71#[derive(serde::Deserialize)]
72pub struct FilledEventFields {
73    dca_key: String,
74    in_amount: u64,
75    out_amount: u64,
76}
77
78/// Serde intermediate for `ClosedEvent` payload fields.
79#[derive(serde::Deserialize)]
80pub struct ClosedEventFields {
81    dca_key: String,
82    user_closed: bool,
83    unfilled_amount: u64,
84}
85
86/// Serde intermediate for events that only carry a `dca_key`.
87#[derive(serde::Deserialize)]
88pub struct DcaKeyHolder {
89    dca_key: String,
90}
91
92/// Extracted DCA close event with checked-cast amounts.
93pub struct DcaClosedEvent {
94    pub order_pda: String,
95    pub user_closed: bool,
96    pub unfilled_amount: i64,
97}
98
99/// Extracted DCA fill event with checked-cast amounts.
100pub struct DcaFillEvent {
101    pub order_pda: String,
102    pub in_amount: i64,
103    pub out_amount: i64,
104}
105
106/// Parsed arguments from an `OpenDca`/`OpenDcaV2` instruction.
107pub struct DcaCreateArgs {
108    pub in_amount: i64,
109    pub in_amount_per_cycle: i64,
110    pub cycle_frequency: i64,
111    pub min_out_amount: Option<i64>,
112    pub max_out_amount: Option<i64>,
113    pub start_at: Option<i64>,
114}
115
116/// Input and output mint addresses extracted from a DCA create instruction.
117pub struct DcaCreateMints {
118    pub input_mint: String,
119    pub output_mint: String,
120}
121
122#[derive(serde::Deserialize)]
123struct OpenDcaFields {
124    in_amount: u64,
125    in_amount_per_cycle: u64,
126    cycle_frequency: i64,
127    min_out_amount: Option<u64>,
128    max_out_amount: Option<u64>,
129    start_at: Option<i64>,
130}
131
132impl ProtocolAdapter for DcaAdapter {
133    fn protocol(&self) -> Protocol {
134        Protocol::Dca
135    }
136
137    fn classify_instruction(&self, ix: &RawInstruction) -> Option<EventType> {
138        let wrapper = serde_json::json!({ &ix.instruction_name: ix.args });
139        let kind: DcaInstructionKind = serde_json::from_value(wrapper).ok()?;
140        match kind {
141            DcaInstructionKind::OpenDca(_) | DcaInstructionKind::OpenDcaV2(_) => {
142                Some(EventType::Created)
143            }
144            DcaInstructionKind::InitiateFlashFill(_) | DcaInstructionKind::InitiateDlmmFill(_) => {
145                Some(EventType::FillInitiated)
146            }
147            DcaInstructionKind::FulfillFlashFill(_) | DcaInstructionKind::FulfillDlmmFill(_) => {
148                Some(EventType::FillCompleted)
149            }
150            DcaInstructionKind::CloseDca(_) | DcaInstructionKind::EndAndClose(_) => {
151                Some(EventType::Closed)
152            }
153            DcaInstructionKind::Transfer(_)
154            | DcaInstructionKind::Deposit(_)
155            | DcaInstructionKind::Withdraw(_)
156            | DcaInstructionKind::WithdrawFees(_) => None,
157        }
158    }
159
160    fn classify_and_resolve_event(
161        &self,
162        ev: &RawEvent,
163        _ctx: &ResolveContext,
164    ) -> Option<Result<(EventType, CorrelationOutcome, EventPayload), Error>> {
165        let fields = ev.fields.as_ref()?;
166        let envelope: DcaEventEnvelope = match serde_json::from_value(fields.clone()) {
167            Ok(e) => e,
168            Err(err) => {
169                if !ProtocolHelpers::contains_known_variant(fields, DcaEventEnvelope::VARIANTS) {
170                    return None;
171                }
172                return Some(Err(Error::Protocol {
173                    reason: format!("failed to parse DCA event payload: {err}"),
174                }));
175            }
176        };
177
178        Some(Self::resolve_event(envelope))
179    }
180}
181
182impl DcaAdapter {
183    fn resolve_event(
184        envelope: DcaEventEnvelope,
185    ) -> Result<(EventType, CorrelationOutcome, EventPayload), Error> {
186        match envelope {
187            DcaEventEnvelope::FilledEvent(FilledEventFields {
188                dca_key,
189                in_amount,
190                out_amount,
191            }) => Ok((
192                EventType::FillCompleted,
193                CorrelationOutcome::Correlated(vec![dca_key]),
194                EventPayload::DcaFill {
195                    in_amount: ProtocolHelpers::checked_u64_to_i64(in_amount, "in_amount")?,
196                    out_amount: ProtocolHelpers::checked_u64_to_i64(out_amount, "out_amount")?,
197                },
198            )),
199            DcaEventEnvelope::ClosedEvent(ClosedEventFields {
200                dca_key,
201                user_closed,
202                unfilled_amount,
203            }) => {
204                let closed = DcaClosedEvent {
205                    order_pda: dca_key,
206                    user_closed,
207                    unfilled_amount: ProtocolHelpers::checked_u64_to_i64(
208                        unfilled_amount,
209                        "unfilled_amount",
210                    )?,
211                };
212                let status = dca_closed_terminal_status(&closed);
213                Ok((
214                    EventType::Closed,
215                    CorrelationOutcome::Correlated(vec![closed.order_pda]),
216                    EventPayload::DcaClosed { status },
217                ))
218            }
219            DcaEventEnvelope::OpenedEvent(DcaKeyHolder { dca_key }) => Ok((
220                EventType::Created,
221                CorrelationOutcome::Correlated(vec![dca_key]),
222                EventPayload::None,
223            )),
224            DcaEventEnvelope::CollectedFeeEvent(DcaKeyHolder { dca_key }) => Ok((
225                EventType::FeeCollected,
226                CorrelationOutcome::Correlated(vec![dca_key]),
227                EventPayload::None,
228            )),
229            DcaEventEnvelope::WithdrawEvent(DcaKeyHolder { dca_key }) => Ok((
230                EventType::Withdrawn,
231                CorrelationOutcome::Correlated(vec![dca_key]),
232                EventPayload::None,
233            )),
234            DcaEventEnvelope::DepositEvent(DcaKeyHolder { dca_key }) => Ok((
235                EventType::Deposited,
236                CorrelationOutcome::Correlated(vec![dca_key]),
237                EventPayload::None,
238            )),
239        }
240    }
241
242    /// Extracts the order PDA from instruction accounts.
243    ///
244    /// Prefers the named `"dca"` account; falls back to positional index per instruction variant.
245    pub fn extract_order_pda(
246        accounts: &[AccountInfo],
247        instruction_name: &str,
248    ) -> Result<String, Error> {
249        if let Some(acc) = ProtocolHelpers::find_account_by_name(accounts, "dca") {
250            return Ok(acc.pubkey.clone());
251        }
252
253        let wrapper = serde_json::json!({ instruction_name: serde_json::Value::Null });
254        let kind: DcaInstructionKind =
255            serde_json::from_value(wrapper).map_err(|_| Error::Protocol {
256                reason: format!("unknown DCA instruction: {instruction_name}"),
257            })?;
258
259        let idx = match kind {
260            DcaInstructionKind::OpenDca(_) | DcaInstructionKind::OpenDcaV2(_) => 0,
261            DcaInstructionKind::InitiateFlashFill(_)
262            | DcaInstructionKind::FulfillFlashFill(_)
263            | DcaInstructionKind::InitiateDlmmFill(_)
264            | DcaInstructionKind::FulfillDlmmFill(_) => 1,
265            DcaInstructionKind::CloseDca(_) | DcaInstructionKind::EndAndClose(_) => 1,
266            DcaInstructionKind::Transfer(_)
267            | DcaInstructionKind::Deposit(_)
268            | DcaInstructionKind::Withdraw(_)
269            | DcaInstructionKind::WithdrawFees(_) => {
270                return Err(Error::Protocol {
271                    reason: format!("DCA instruction {instruction_name} has no order PDA"),
272                });
273            }
274        };
275
276        accounts
277            .get(idx)
278            .map(|a| a.pubkey.clone())
279            .ok_or_else(|| Error::Protocol {
280                reason: format!("DCA account index {idx} out of bounds for {instruction_name}"),
281            })
282    }
283
284    /// Extracts input/output mint addresses from a DCA create instruction's accounts.
285    ///
286    /// Prefers named accounts; falls back to positional indexes that differ between `OpenDca` and `OpenDcaV2`.
287    pub fn extract_create_mints(
288        accounts: &[AccountInfo],
289        instruction_name: &str,
290    ) -> Result<DcaCreateMints, Error> {
291        let input_mint =
292            ProtocolHelpers::find_account_by_name(accounts, "input_mint").map(|a| a.pubkey.clone());
293        let output_mint = ProtocolHelpers::find_account_by_name(accounts, "output_mint")
294            .map(|a| a.pubkey.clone());
295
296        if let (Some(input_mint), Some(output_mint)) = (input_mint, output_mint) {
297            return Ok(DcaCreateMints {
298                input_mint,
299                output_mint,
300            });
301        }
302
303        let wrapper = serde_json::json!({ instruction_name: serde_json::Value::Null });
304        let kind: DcaInstructionKind =
305            serde_json::from_value(wrapper).map_err(|_| Error::Protocol {
306                reason: format!("unknown DCA instruction: {instruction_name}"),
307            })?;
308
309        let (input_idx, output_idx) = match kind {
310            DcaInstructionKind::OpenDca(_) => (2, 3),
311            DcaInstructionKind::OpenDcaV2(_) => (3, 4),
312            _ => {
313                return Err(Error::Protocol {
314                    reason: format!("not a DCA create instruction: {instruction_name}"),
315                });
316            }
317        };
318
319        let input_mint = accounts
320            .get(input_idx)
321            .map(|a| a.pubkey.clone())
322            .ok_or_else(|| Error::Protocol {
323                reason: format!("DCA input_mint index {input_idx} out of bounds"),
324            })?;
325        let output_mint = accounts
326            .get(output_idx)
327            .map(|a| a.pubkey.clone())
328            .ok_or_else(|| Error::Protocol {
329                reason: format!("DCA output_mint index {output_idx} out of bounds"),
330            })?;
331
332        Ok(DcaCreateMints {
333            input_mint,
334            output_mint,
335        })
336    }
337
338    /// Parses `OpenDca`/`OpenDcaV2` instruction args into checked [`DcaCreateArgs`].
339    pub fn parse_create_args(args: &serde_json::Value) -> Result<DcaCreateArgs, Error> {
340        let OpenDcaFields {
341            in_amount,
342            in_amount_per_cycle,
343            cycle_frequency,
344            min_out_amount,
345            max_out_amount,
346            start_at,
347        } = serde_json::from_value(args.clone()).map_err(|e| Error::Protocol {
348            reason: format!("failed to parse DCA create args: {e}"),
349        })?;
350
351        Ok(DcaCreateArgs {
352            in_amount: ProtocolHelpers::checked_u64_to_i64(in_amount, "in_amount")?,
353            in_amount_per_cycle: ProtocolHelpers::checked_u64_to_i64(
354                in_amount_per_cycle,
355                "in_amount_per_cycle",
356            )?,
357            cycle_frequency,
358            min_out_amount: min_out_amount.and_then(ProtocolHelpers::optional_u64_to_i64),
359            max_out_amount: max_out_amount.and_then(ProtocolHelpers::optional_u64_to_i64),
360            start_at: start_at.filter(|&ts| ts > 0),
361        })
362    }
363
364    #[cfg(all(test, feature = "native"))]
365    pub fn classify_decoded(
366        decoded: &carbon_jupiter_dca_decoder::instructions::JupiterDcaInstruction,
367    ) -> Option<EventType> {
368        use carbon_jupiter_dca_decoder::instructions::JupiterDcaInstruction;
369        match decoded {
370            JupiterDcaInstruction::OpenDca(_) | JupiterDcaInstruction::OpenDcaV2(_) => {
371                Some(EventType::Created)
372            }
373            JupiterDcaInstruction::InitiateFlashFill(_)
374            | JupiterDcaInstruction::InitiateDlmmFill(_) => Some(EventType::FillInitiated),
375            JupiterDcaInstruction::FulfillFlashFill(_)
376            | JupiterDcaInstruction::FulfillDlmmFill(_) => Some(EventType::FillCompleted),
377            JupiterDcaInstruction::CloseDca(_) | JupiterDcaInstruction::EndAndClose(_) => {
378                Some(EventType::Closed)
379            }
380            JupiterDcaInstruction::OpenedEvent(_) => Some(EventType::Created),
381            JupiterDcaInstruction::FilledEvent(_) => Some(EventType::FillCompleted),
382            JupiterDcaInstruction::ClosedEvent(_) => Some(EventType::Closed),
383            JupiterDcaInstruction::CollectedFeeEvent(_) => Some(EventType::FeeCollected),
384            JupiterDcaInstruction::WithdrawEvent(_) => Some(EventType::Withdrawn),
385            JupiterDcaInstruction::DepositEvent(_) => Some(EventType::Deposited),
386            JupiterDcaInstruction::Transfer(_)
387            | JupiterDcaInstruction::Deposit(_)
388            | JupiterDcaInstruction::Withdraw(_)
389            | JupiterDcaInstruction::WithdrawFees(_) => None,
390        }
391    }
392}
393
394#[cfg(test)]
395#[expect(clippy::unwrap_used, reason = "test assertions")]
396mod tests {
397    use super::*;
398    use crate::lifecycle::TerminalStatus;
399
400    fn account(pubkey: &str, name: Option<&str>) -> AccountInfo {
401        AccountInfo {
402            pubkey: pubkey.to_string(),
403            is_signer: false,
404            is_writable: false,
405            name: name.map(str::to_string),
406        }
407    }
408
409    fn make_event(fields: serde_json::Value) -> RawEvent {
410        RawEvent {
411            id: 1,
412            signature: "sig".to_string(),
413            event_index: 0,
414            event_path: None,
415            program_id: "p".to_string(),
416            inner_program_id: "p".to_string(),
417            event_name: "test".to_string(),
418            fields: Some(fields),
419            slot: 1,
420        }
421    }
422
423    fn resolve(
424        fields: serde_json::Value,
425    ) -> Option<Result<(EventType, CorrelationOutcome, EventPayload), crate::error::Error>> {
426        let ev = make_event(fields);
427        let ctx = ResolveContext {
428            pre_fetched_order_pdas: None,
429        };
430        DcaAdapter.classify_and_resolve_event(&ev, &ctx)
431    }
432
433    #[test]
434    fn classify_known_instructions_via_envelope() {
435        let cases = [
436            ("OpenDca", Some(EventType::Created)),
437            ("OpenDcaV2", Some(EventType::Created)),
438            ("InitiateFlashFill", Some(EventType::FillInitiated)),
439            ("InitiateDlmmFill", Some(EventType::FillInitiated)),
440            ("FulfillFlashFill", Some(EventType::FillCompleted)),
441            ("FulfillDlmmFill", Some(EventType::FillCompleted)),
442            ("CloseDca", Some(EventType::Closed)),
443            ("EndAndClose", Some(EventType::Closed)),
444            ("Transfer", None),
445            ("Deposit", None),
446            ("Withdraw", None),
447            ("WithdrawFees", None),
448            ("Unknown", None),
449        ];
450        for (name, expected) in cases {
451            let ix = RawInstruction {
452                id: 1,
453                signature: "sig".to_string(),
454                instruction_index: 0,
455                instruction_path: None,
456                program_id: "p".to_string(),
457                inner_program_id: "p".to_string(),
458                instruction_name: name.to_string(),
459                accounts: None,
460                args: None,
461                slot: 1,
462            };
463            assert_eq!(
464                DcaAdapter.classify_instruction(&ix),
465                expected,
466                "mismatch for {name}"
467            );
468        }
469    }
470
471    #[test]
472    fn resolve_fill_event_from_envelope() {
473        let fields = serde_json::json!({
474            "FilledEvent": {
475                "dca_key": "3nsTjVJTwwGvXqDRgqNCZAQKwt4QMVhHHqvyseCNR3YX",
476                "in_amount": 21_041_666_667_u64,
477                "out_amount": 569_529_644_u64,
478                "fee": 570_099_u64,
479                "fee_mint": "A7b",
480                "input_mint": "So1",
481                "output_mint": "A7b",
482                "user_key": "31o"
483            }
484        });
485        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
486        assert_eq!(event_type, EventType::FillCompleted);
487        let CorrelationOutcome::Correlated(pdas) = correlation else {
488            panic!("expected Correlated");
489        };
490        assert_eq!(pdas, vec!["3nsTjVJTwwGvXqDRgqNCZAQKwt4QMVhHHqvyseCNR3YX"]);
491        let EventPayload::DcaFill {
492            in_amount,
493            out_amount,
494        } = payload
495        else {
496            panic!("expected DcaFill");
497        };
498        assert_eq!(in_amount, 21_041_666_667);
499        assert_eq!(out_amount, 569_529_644);
500    }
501
502    #[test]
503    fn resolve_closed_event_completed() {
504        let fields = serde_json::json!({
505            "ClosedEvent": {
506                "dca_key": "pda1",
507                "user_closed": false,
508                "unfilled_amount": 0_u64,
509                "created_at": 0, "in_amount_per_cycle": 0, "in_deposited": 0,
510                "input_mint": "x", "output_mint": "y", "total_in_withdrawn": 0,
511                "total_out_withdrawn": 0, "user_key": "z", "cycle_frequency": 60
512            }
513        });
514        let (event_type, _, payload) = resolve(fields).unwrap().unwrap();
515        assert_eq!(event_type, EventType::Closed);
516        assert_eq!(
517            payload,
518            EventPayload::DcaClosed {
519                status: TerminalStatus::Completed
520            }
521        );
522    }
523
524    #[test]
525    fn resolve_opened_event_correlates() {
526        let fields = serde_json::json!({
527            "OpenedEvent": {
528                "dca_key": "my_pda",
529                "created_at": 0, "cycle_frequency": 60, "in_amount_per_cycle": 100,
530                "in_deposited": 500, "input_mint": "a", "output_mint": "b", "user_key": "c"
531            }
532        });
533        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
534        assert_eq!(event_type, EventType::Created);
535        assert_eq!(
536            correlation,
537            CorrelationOutcome::Correlated(vec!["my_pda".to_string()])
538        );
539        assert_eq!(payload, EventPayload::None);
540    }
541
542    #[test]
543    fn unknown_event_returns_none() {
544        let fields = serde_json::json!({"UnknownEvent": {"some_field": 1}});
545        assert!(resolve(fields).is_none());
546    }
547
548    #[test]
549    fn malformed_known_event_returns_error() {
550        let fields = serde_json::json!({
551            "FilledEvent": {
552                "dca_key": "pda",
553                "in_amount": "bad",
554                "out_amount": 1_u64
555            }
556        });
557        let result = resolve(fields).unwrap();
558        assert!(result.is_err());
559    }
560
561    #[test]
562    fn resolve_fill_event_rejects_amount_overflow() {
563        let fields = serde_json::json!({
564            "FilledEvent": {
565                "dca_key": "pda",
566                "in_amount": (i64::MAX as u64) + 1,
567                "out_amount": 1_u64
568            }
569        });
570        let result = resolve(fields).unwrap();
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn parse_create_args_rejects_overflow_amounts() {
576        let args = serde_json::json!({
577            "in_amount": (i64::MAX as u64) + 1,
578            "in_amount_per_cycle": 1_u64,
579            "cycle_frequency": 60_i64,
580            "min_out_amount": 1_u64,
581            "max_out_amount": 1_u64
582        });
583        assert!(DcaAdapter::parse_create_args(&args).is_err());
584    }
585
586    #[test]
587    fn parse_create_args_accepts_valid_payload() {
588        let args = serde_json::json!({
589            "in_amount": 1_000_u64,
590            "in_amount_per_cycle": 100_u64,
591            "cycle_frequency": 60_i64,
592            "min_out_amount": 10_u64,
593            "max_out_amount": 500_u64,
594            "start_at": 1_700_000_000_i64
595        });
596        let parsed = DcaAdapter::parse_create_args(&args).unwrap();
597        assert_eq!(parsed.in_amount, 1_000);
598        assert_eq!(parsed.in_amount_per_cycle, 100);
599        assert_eq!(parsed.cycle_frequency, 60);
600        assert_eq!(parsed.min_out_amount, Some(10));
601        assert_eq!(parsed.max_out_amount, Some(500));
602        assert_eq!(parsed.start_at, Some(1_700_000_000));
603    }
604
605    #[test]
606    fn parse_create_args_treats_zero_start_at_as_none() {
607        let args = serde_json::json!({
608            "in_amount": 1_000_u64,
609            "in_amount_per_cycle": 100_u64,
610            "cycle_frequency": 60_i64,
611            "start_at": 0_i64
612        });
613        let parsed = DcaAdapter::parse_create_args(&args).unwrap();
614        assert!(parsed.start_at.is_none(), "start_at: 0 should become None");
615    }
616
617    #[test]
618    fn parse_create_args_treats_negative_start_at_as_none() {
619        let args = serde_json::json!({
620            "in_amount": 1_000_u64,
621            "in_amount_per_cycle": 100_u64,
622            "cycle_frequency": 60_i64,
623            "start_at": -1_i64
624        });
625        let parsed = DcaAdapter::parse_create_args(&args).unwrap();
626        assert!(
627            parsed.start_at.is_none(),
628            "negative start_at should become None"
629        );
630    }
631
632    #[test]
633    fn parse_create_args_rejects_malformed_payload() {
634        let args = serde_json::json!({
635            "in_amount": "bad",
636            "in_amount_per_cycle": 100_u64,
637            "cycle_frequency": 60_i64
638        });
639        assert!(DcaAdapter::parse_create_args(&args).is_err());
640    }
641
642    #[test]
643    fn extract_order_pda_prefers_named_account() {
644        let accounts = vec![
645            account("idx0", None),
646            account("idx1", None),
647            account("named_dca", Some("dca")),
648        ];
649        let extracted = DcaAdapter::extract_order_pda(&accounts, "CloseDca").unwrap();
650        assert_eq!(extracted, "named_dca");
651    }
652
653    #[test]
654    fn extract_order_pda_uses_instruction_fallback_indexes() {
655        let open_accounts = vec![account("open_idx0", None)];
656        assert_eq!(
657            DcaAdapter::extract_order_pda(&open_accounts, "OpenDca").unwrap(),
658            "open_idx0"
659        );
660
661        let close_accounts = vec![account("ignore0", None), account("close_idx1", None)];
662        assert_eq!(
663            DcaAdapter::extract_order_pda(&close_accounts, "CloseDca").unwrap(),
664            "close_idx1"
665        );
666    }
667
668    #[test]
669    fn extract_order_pda_rejects_unknown_instruction() {
670        let err = DcaAdapter::extract_order_pda(&[account("a", None)], "Unknown").unwrap_err();
671        let Error::Protocol { reason } = err else {
672            panic!("expected protocol error");
673        };
674        assert_eq!(reason, "unknown DCA instruction: Unknown");
675    }
676
677    #[test]
678    fn extract_order_pda_rejects_out_of_bounds_fallback() {
679        let err = DcaAdapter::extract_order_pda(&[account("only0", None)], "CloseDca").unwrap_err();
680        let Error::Protocol { reason } = err else {
681            panic!("expected protocol error");
682        };
683        assert_eq!(reason, "DCA account index 1 out of bounds for CloseDca");
684    }
685
686    #[test]
687    fn extract_create_mints_prefers_named_accounts() {
688        let accounts = vec![
689            account("fallback_input", None),
690            account("fallback_output", None),
691            account("named_input", Some("input_mint")),
692            account("named_output", Some("output_mint")),
693        ];
694        let extracted = DcaAdapter::extract_create_mints(&accounts, "OpenDca").unwrap();
695        assert_eq!(extracted.input_mint, "named_input");
696        assert_eq!(extracted.output_mint, "named_output");
697    }
698
699    #[test]
700    fn extract_create_mints_uses_fallback_indexes_for_create_variants() {
701        let open_accounts = vec![
702            account("0", None),
703            account("1", None),
704            account("open_input", None),
705            account("open_output", None),
706        ];
707        let open = DcaAdapter::extract_create_mints(&open_accounts, "OpenDca").unwrap();
708        assert_eq!(open.input_mint, "open_input");
709        assert_eq!(open.output_mint, "open_output");
710
711        let open_v2_accounts = vec![
712            account("0", None),
713            account("1", None),
714            account("2", None),
715            account("open_v2_input", None),
716            account("open_v2_output", None),
717        ];
718        let open_v2 = DcaAdapter::extract_create_mints(&open_v2_accounts, "OpenDcaV2").unwrap();
719        assert_eq!(open_v2.input_mint, "open_v2_input");
720        assert_eq!(open_v2.output_mint, "open_v2_output");
721    }
722
723    #[test]
724    fn extract_create_mints_rejects_non_create_instruction() {
725        let err = DcaAdapter::extract_create_mints(&[], "CloseDca")
726            .err()
727            .expect("expected error");
728        let Error::Protocol { reason } = err else {
729            panic!("expected protocol error");
730        };
731        assert_eq!(reason, "not a DCA create instruction: CloseDca");
732    }
733
734    #[test]
735    fn extract_create_mints_rejects_missing_fallback_input_index() {
736        let err = DcaAdapter::extract_create_mints(&[], "OpenDca")
737            .err()
738            .expect("expected error");
739        let Error::Protocol { reason } = err else {
740            panic!("expected protocol error");
741        };
742        assert_eq!(reason, "DCA input_mint index 2 out of bounds");
743    }
744
745    #[test]
746    fn extract_create_mints_rejects_missing_fallback_output_index() {
747        let accounts = vec![account("0", None), account("1", None), account("2", None)];
748        let err = DcaAdapter::extract_create_mints(&accounts, "OpenDca")
749            .err()
750            .expect("expected error");
751        let Error::Protocol { reason } = err else {
752            panic!("expected protocol error");
753        };
754        assert_eq!(reason, "DCA output_mint index 3 out of bounds");
755    }
756
757    #[test]
758    fn resolve_deposit_event_from_envelope() {
759        let fields = serde_json::json!({
760            "DepositEvent": {
761                "dca_key": "deposit_pda_123",
762                "amount": 1_000_000_u64,
763                "user_key": "user123"
764            }
765        });
766        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
767        assert_eq!(event_type, EventType::Deposited);
768        assert_eq!(
769            correlation,
770            CorrelationOutcome::Correlated(vec!["deposit_pda_123".to_string()])
771        );
772        assert_eq!(payload, EventPayload::None);
773    }
774
775    #[cfg(feature = "wasm")]
776    #[test]
777    fn instruction_constants_match_classify() {
778        for (name, expected) in INSTRUCTION_EVENT_TYPES {
779            let ix = RawInstruction {
780                id: 1,
781                signature: "sig".to_string(),
782                instruction_index: 0,
783                instruction_path: None,
784                program_id: "p".to_string(),
785                inner_program_id: "p".to_string(),
786                instruction_name: name.to_string(),
787                accounts: None,
788                args: None,
789                slot: 1,
790            };
791            assert_eq!(
792                DcaAdapter.classify_instruction(&ix).as_ref(),
793                Some(expected),
794                "INSTRUCTION_EVENT_TYPES mismatch for {name}"
795            );
796        }
797    }
798
799    #[cfg(feature = "wasm")]
800    #[test]
801    fn event_constants_match_resolve() {
802        for (name, expected) in EVENT_EVENT_TYPES {
803            let fields = match *name {
804                "FilledEvent" => {
805                    serde_json::json!({(*name): {"dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64}})
806                }
807                "ClosedEvent" => {
808                    serde_json::json!({(*name): {"dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64}})
809                }
810                _ => serde_json::json!({(*name): {"dca_key": "t"}}),
811            };
812            let result = resolve(fields);
813            let (event_type, _, _) = result.expect("should return Some").expect("should be Ok");
814            assert_eq!(
815                &event_type, expected,
816                "EVENT_EVENT_TYPES mismatch for {name}"
817            );
818        }
819    }
820
821    #[test]
822    fn mirror_enums_cover_all_carbon_variants() {
823        let instruction_variants = [
824            "OpenDca",
825            "OpenDcaV2",
826            "InitiateFlashFill",
827            "InitiateDlmmFill",
828            "FulfillFlashFill",
829            "FulfillDlmmFill",
830            "CloseDca",
831            "EndAndClose",
832            "Transfer",
833            "Deposit",
834            "Withdraw",
835            "WithdrawFees",
836        ];
837        for name in instruction_variants {
838            let json = serde_json::json!({ name: serde_json::Value::Null });
839            assert!(
840                serde_json::from_value::<DcaInstructionKind>(json).is_ok(),
841                "DcaInstructionKind missing variant: {name}"
842            );
843        }
844
845        let key_holder_variants = [
846            "OpenedEvent",
847            "CollectedFeeEvent",
848            "WithdrawEvent",
849            "DepositEvent",
850        ];
851        for name in key_holder_variants {
852            let json = serde_json::json!({ name: { "dca_key": "test" } });
853            assert!(
854                serde_json::from_value::<DcaEventEnvelope>(json).is_ok(),
855                "DcaEventEnvelope missing variant: {name}"
856            );
857        }
858
859        let filled = serde_json::json!({
860            "FilledEvent": { "dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64 }
861        });
862        assert!(serde_json::from_value::<DcaEventEnvelope>(filled).is_ok());
863
864        let closed = serde_json::json!({
865            "ClosedEvent": { "dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64 }
866        });
867        assert!(serde_json::from_value::<DcaEventEnvelope>(closed).is_ok());
868    }
869}