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