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,
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            program_id: "p".to_string(),
415            inner_program_id: "p".to_string(),
416            event_name: "test".to_string(),
417            fields: Some(fields),
418            slot: 1,
419        }
420    }
421
422    fn resolve(
423        fields: serde_json::Value,
424    ) -> Option<Result<(EventType, CorrelationOutcome, EventPayload), crate::error::Error>> {
425        let ev = make_event(fields);
426        let ctx = ResolveContext {
427            pre_fetched_order_pdas: None,
428        };
429        DcaAdapter.classify_and_resolve_event(&ev, &ctx)
430    }
431
432    #[test]
433    fn classify_known_instructions_via_envelope() {
434        let cases = [
435            ("OpenDca", Some(EventType::Created)),
436            ("OpenDcaV2", Some(EventType::Created)),
437            ("InitiateFlashFill", Some(EventType::FillInitiated)),
438            ("InitiateDlmmFill", Some(EventType::FillInitiated)),
439            ("FulfillFlashFill", Some(EventType::FillCompleted)),
440            ("FulfillDlmmFill", Some(EventType::FillCompleted)),
441            ("CloseDca", Some(EventType::Closed)),
442            ("EndAndClose", Some(EventType::Closed)),
443            ("Transfer", None),
444            ("Deposit", None),
445            ("Withdraw", None),
446            ("WithdrawFees", None),
447            ("Unknown", None),
448        ];
449        for (name, expected) in cases {
450            let ix = RawInstruction {
451                id: 1,
452                signature: "sig".to_string(),
453                instruction_index: 0,
454                program_id: "p".to_string(),
455                inner_program_id: "p".to_string(),
456                instruction_name: name.to_string(),
457                accounts: None,
458                args: None,
459                slot: 1,
460            };
461            assert_eq!(
462                DcaAdapter.classify_instruction(&ix),
463                expected,
464                "mismatch for {name}"
465            );
466        }
467    }
468
469    #[test]
470    fn resolve_fill_event_from_envelope() {
471        let fields = serde_json::json!({
472            "FilledEvent": {
473                "dca_key": "3nsTjVJTwwGvXqDRgqNCZAQKwt4QMVhHHqvyseCNR3YX",
474                "in_amount": 21_041_666_667_u64,
475                "out_amount": 569_529_644_u64,
476                "fee": 570_099_u64,
477                "fee_mint": "A7b",
478                "input_mint": "So1",
479                "output_mint": "A7b",
480                "user_key": "31o"
481            }
482        });
483        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
484        assert_eq!(event_type, EventType::FillCompleted);
485        let CorrelationOutcome::Correlated(pdas) = correlation else {
486            panic!("expected Correlated");
487        };
488        assert_eq!(pdas, vec!["3nsTjVJTwwGvXqDRgqNCZAQKwt4QMVhHHqvyseCNR3YX"]);
489        let EventPayload::DcaFill {
490            in_amount,
491            out_amount,
492        } = payload
493        else {
494            panic!("expected DcaFill");
495        };
496        assert_eq!(in_amount, 21_041_666_667);
497        assert_eq!(out_amount, 569_529_644);
498    }
499
500    #[test]
501    fn resolve_closed_event_completed() {
502        let fields = serde_json::json!({
503            "ClosedEvent": {
504                "dca_key": "pda1",
505                "user_closed": false,
506                "unfilled_amount": 0_u64,
507                "created_at": 0, "in_amount_per_cycle": 0, "in_deposited": 0,
508                "input_mint": "x", "output_mint": "y", "total_in_withdrawn": 0,
509                "total_out_withdrawn": 0, "user_key": "z", "cycle_frequency": 60
510            }
511        });
512        let (event_type, _, payload) = resolve(fields).unwrap().unwrap();
513        assert_eq!(event_type, EventType::Closed);
514        assert_eq!(
515            payload,
516            EventPayload::DcaClosed {
517                status: TerminalStatus::Completed
518            }
519        );
520    }
521
522    #[test]
523    fn resolve_opened_event_correlates() {
524        let fields = serde_json::json!({
525            "OpenedEvent": {
526                "dca_key": "my_pda",
527                "created_at": 0, "cycle_frequency": 60, "in_amount_per_cycle": 100,
528                "in_deposited": 500, "input_mint": "a", "output_mint": "b", "user_key": "c"
529            }
530        });
531        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
532        assert_eq!(event_type, EventType::Created);
533        assert_eq!(
534            correlation,
535            CorrelationOutcome::Correlated(vec!["my_pda".to_string()])
536        );
537        assert_eq!(payload, EventPayload::None);
538    }
539
540    #[test]
541    fn unknown_event_returns_none() {
542        let fields = serde_json::json!({"UnknownEvent": {"some_field": 1}});
543        assert!(resolve(fields).is_none());
544    }
545
546    #[test]
547    fn malformed_known_event_returns_error() {
548        let fields = serde_json::json!({
549            "FilledEvent": {
550                "dca_key": "pda",
551                "in_amount": "bad",
552                "out_amount": 1_u64
553            }
554        });
555        let result = resolve(fields).unwrap();
556        assert!(result.is_err());
557    }
558
559    #[test]
560    fn resolve_fill_event_rejects_amount_overflow() {
561        let fields = serde_json::json!({
562            "FilledEvent": {
563                "dca_key": "pda",
564                "in_amount": (i64::MAX as u64) + 1,
565                "out_amount": 1_u64
566            }
567        });
568        let result = resolve(fields).unwrap();
569        assert!(result.is_err());
570    }
571
572    #[test]
573    fn parse_create_args_rejects_overflow_amounts() {
574        let args = serde_json::json!({
575            "in_amount": (i64::MAX as u64) + 1,
576            "in_amount_per_cycle": 1_u64,
577            "cycle_frequency": 60_i64,
578            "min_out_amount": 1_u64,
579            "max_out_amount": 1_u64
580        });
581        assert!(DcaAdapter::parse_create_args(&args).is_err());
582    }
583
584    #[test]
585    fn parse_create_args_accepts_valid_payload() {
586        let args = serde_json::json!({
587            "in_amount": 1_000_u64,
588            "in_amount_per_cycle": 100_u64,
589            "cycle_frequency": 60_i64,
590            "min_out_amount": 10_u64,
591            "max_out_amount": 500_u64,
592            "start_at": 1_700_000_000_i64
593        });
594        let parsed = DcaAdapter::parse_create_args(&args).unwrap();
595        assert_eq!(parsed.in_amount, 1_000);
596        assert_eq!(parsed.in_amount_per_cycle, 100);
597        assert_eq!(parsed.cycle_frequency, 60);
598        assert_eq!(parsed.min_out_amount, Some(10));
599        assert_eq!(parsed.max_out_amount, Some(500));
600        assert_eq!(parsed.start_at, Some(1_700_000_000));
601    }
602
603    #[test]
604    fn parse_create_args_rejects_malformed_payload() {
605        let args = serde_json::json!({
606            "in_amount": "bad",
607            "in_amount_per_cycle": 100_u64,
608            "cycle_frequency": 60_i64
609        });
610        assert!(DcaAdapter::parse_create_args(&args).is_err());
611    }
612
613    #[test]
614    fn extract_order_pda_prefers_named_account() {
615        let accounts = vec![
616            account("idx0", None),
617            account("idx1", None),
618            account("named_dca", Some("dca")),
619        ];
620        let extracted = DcaAdapter::extract_order_pda(&accounts, "CloseDca").unwrap();
621        assert_eq!(extracted, "named_dca");
622    }
623
624    #[test]
625    fn extract_order_pda_uses_instruction_fallback_indexes() {
626        let open_accounts = vec![account("open_idx0", None)];
627        assert_eq!(
628            DcaAdapter::extract_order_pda(&open_accounts, "OpenDca").unwrap(),
629            "open_idx0"
630        );
631
632        let close_accounts = vec![account("ignore0", None), account("close_idx1", None)];
633        assert_eq!(
634            DcaAdapter::extract_order_pda(&close_accounts, "CloseDca").unwrap(),
635            "close_idx1"
636        );
637    }
638
639    #[test]
640    fn extract_order_pda_rejects_unknown_instruction() {
641        let err = DcaAdapter::extract_order_pda(&[account("a", None)], "Unknown").unwrap_err();
642        let Error::Protocol { reason } = err else {
643            panic!("expected protocol error");
644        };
645        assert_eq!(reason, "unknown DCA instruction: Unknown");
646    }
647
648    #[test]
649    fn extract_order_pda_rejects_out_of_bounds_fallback() {
650        let err = DcaAdapter::extract_order_pda(&[account("only0", None)], "CloseDca").unwrap_err();
651        let Error::Protocol { reason } = err else {
652            panic!("expected protocol error");
653        };
654        assert_eq!(reason, "DCA account index 1 out of bounds for CloseDca");
655    }
656
657    #[test]
658    fn extract_create_mints_prefers_named_accounts() {
659        let accounts = vec![
660            account("fallback_input", None),
661            account("fallback_output", None),
662            account("named_input", Some("input_mint")),
663            account("named_output", Some("output_mint")),
664        ];
665        let extracted = DcaAdapter::extract_create_mints(&accounts, "OpenDca").unwrap();
666        assert_eq!(extracted.input_mint, "named_input");
667        assert_eq!(extracted.output_mint, "named_output");
668    }
669
670    #[test]
671    fn extract_create_mints_uses_fallback_indexes_for_create_variants() {
672        let open_accounts = vec![
673            account("0", None),
674            account("1", None),
675            account("open_input", None),
676            account("open_output", None),
677        ];
678        let open = DcaAdapter::extract_create_mints(&open_accounts, "OpenDca").unwrap();
679        assert_eq!(open.input_mint, "open_input");
680        assert_eq!(open.output_mint, "open_output");
681
682        let open_v2_accounts = vec![
683            account("0", None),
684            account("1", None),
685            account("2", None),
686            account("open_v2_input", None),
687            account("open_v2_output", None),
688        ];
689        let open_v2 = DcaAdapter::extract_create_mints(&open_v2_accounts, "OpenDcaV2").unwrap();
690        assert_eq!(open_v2.input_mint, "open_v2_input");
691        assert_eq!(open_v2.output_mint, "open_v2_output");
692    }
693
694    #[test]
695    fn extract_create_mints_rejects_non_create_instruction() {
696        let err = DcaAdapter::extract_create_mints(&[], "CloseDca")
697            .err()
698            .expect("expected error");
699        let Error::Protocol { reason } = err else {
700            panic!("expected protocol error");
701        };
702        assert_eq!(reason, "not a DCA create instruction: CloseDca");
703    }
704
705    #[test]
706    fn extract_create_mints_rejects_missing_fallback_input_index() {
707        let err = DcaAdapter::extract_create_mints(&[], "OpenDca")
708            .err()
709            .expect("expected error");
710        let Error::Protocol { reason } = err else {
711            panic!("expected protocol error");
712        };
713        assert_eq!(reason, "DCA input_mint index 2 out of bounds");
714    }
715
716    #[test]
717    fn extract_create_mints_rejects_missing_fallback_output_index() {
718        let accounts = vec![account("0", None), account("1", None), account("2", None)];
719        let err = DcaAdapter::extract_create_mints(&accounts, "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 output_mint index 3 out of bounds");
726    }
727
728    #[test]
729    fn resolve_deposit_event_from_envelope() {
730        let fields = serde_json::json!({
731            "DepositEvent": {
732                "dca_key": "deposit_pda_123",
733                "amount": 1_000_000_u64,
734                "user_key": "user123"
735            }
736        });
737        let (event_type, correlation, payload) = resolve(fields).unwrap().unwrap();
738        assert_eq!(event_type, EventType::Deposited);
739        assert_eq!(
740            correlation,
741            CorrelationOutcome::Correlated(vec!["deposit_pda_123".to_string()])
742        );
743        assert_eq!(payload, EventPayload::None);
744    }
745
746    #[cfg(feature = "wasm")]
747    #[test]
748    fn instruction_constants_match_classify() {
749        for (name, expected) in INSTRUCTION_EVENT_TYPES {
750            let ix = RawInstruction {
751                id: 1,
752                signature: "sig".to_string(),
753                instruction_index: 0,
754                program_id: "p".to_string(),
755                inner_program_id: "p".to_string(),
756                instruction_name: name.to_string(),
757                accounts: None,
758                args: None,
759                slot: 1,
760            };
761            assert_eq!(
762                DcaAdapter.classify_instruction(&ix).as_ref(),
763                Some(expected),
764                "INSTRUCTION_EVENT_TYPES mismatch for {name}"
765            );
766        }
767    }
768
769    #[cfg(feature = "wasm")]
770    #[test]
771    fn event_constants_match_resolve() {
772        for (name, expected) in EVENT_EVENT_TYPES {
773            let fields = match *name {
774                "FilledEvent" => {
775                    serde_json::json!({(*name): {"dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64}})
776                }
777                "ClosedEvent" => {
778                    serde_json::json!({(*name): {"dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64}})
779                }
780                _ => serde_json::json!({(*name): {"dca_key": "t"}}),
781            };
782            let result = resolve(fields);
783            let (event_type, _, _) = result.expect("should return Some").expect("should be Ok");
784            assert_eq!(
785                &event_type, expected,
786                "EVENT_EVENT_TYPES mismatch for {name}"
787            );
788        }
789    }
790
791    #[test]
792    fn mirror_enums_cover_all_carbon_variants() {
793        let instruction_variants = [
794            "OpenDca",
795            "OpenDcaV2",
796            "InitiateFlashFill",
797            "InitiateDlmmFill",
798            "FulfillFlashFill",
799            "FulfillDlmmFill",
800            "CloseDca",
801            "EndAndClose",
802            "Transfer",
803            "Deposit",
804            "Withdraw",
805            "WithdrawFees",
806        ];
807        for name in instruction_variants {
808            let json = serde_json::json!({ name: serde_json::Value::Null });
809            assert!(
810                serde_json::from_value::<DcaInstructionKind>(json).is_ok(),
811                "DcaInstructionKind missing variant: {name}"
812            );
813        }
814
815        let key_holder_variants = [
816            "OpenedEvent",
817            "CollectedFeeEvent",
818            "WithdrawEvent",
819            "DepositEvent",
820        ];
821        for name in key_holder_variants {
822            let json = serde_json::json!({ name: { "dca_key": "test" } });
823            assert!(
824                serde_json::from_value::<DcaEventEnvelope>(json).is_ok(),
825                "DcaEventEnvelope missing variant: {name}"
826            );
827        }
828
829        let filled = serde_json::json!({
830            "FilledEvent": { "dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64 }
831        });
832        assert!(serde_json::from_value::<DcaEventEnvelope>(filled).is_ok());
833
834        let closed = serde_json::json!({
835            "ClosedEvent": { "dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64 }
836        });
837        assert!(serde_json::from_value::<DcaEventEnvelope>(closed).is_ok());
838    }
839}