Skip to main content

defi_tracker_lifecycle/protocols/
mod.rs

1pub mod dca;
2pub mod kamino;
3pub mod limit_v1;
4pub mod limit_v2;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::Error;
9
10#[cfg(feature = "wasm")]
11pub const DCA_PROGRAM_ID: &str = "DCA265Vj8a9CEuX1eb1LWRnDT7uK6q1xMipnNyatn23M";
12#[cfg(feature = "wasm")]
13pub const LIMIT_V1_PROGRAM_ID: &str = "jupoNjAxXgZ4rjzxzPMP4oxduvQsQtZzyknqvzYNrNu";
14#[cfg(feature = "wasm")]
15pub const LIMIT_V2_PROGRAM_ID: &str = "j1o2qRpjcyUwEvwtcfhEQefh773ZgjxcVRry7LDqg5X";
16#[cfg(feature = "wasm")]
17pub const KAMINO_PROGRAM_ID: &str = "LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF";
18
19/// Supported DeFi protocols.
20#[derive(
21    Debug, Clone, Copy, PartialEq, Eq, Serialize, strum_macros::Display, strum_macros::AsRefStr,
22)]
23#[strum(serialize_all = "snake_case")]
24pub enum Protocol {
25    /// Jupiter Dollar-Cost Averaging.
26    Dca,
27    /// Jupiter Limit Order v1.
28    LimitV1,
29    /// Jupiter Limit Order v2.
30    LimitV2,
31    /// Kamino Limit Order.
32    Kamino,
33}
34
35impl Protocol {
36    /// Resolves a base58 program id string to its [`Protocol`], or `None` if unrecognised.
37    #[cfg(feature = "native")]
38    pub fn from_program_id(program_id: &str) -> Option<Self> {
39        let key: solana_pubkey::Pubkey = program_id.parse().ok()?;
40        match key {
41            carbon_jupiter_dca_decoder::PROGRAM_ID => Some(Self::Dca),
42            carbon_jupiter_limit_order_decoder::PROGRAM_ID => Some(Self::LimitV1),
43            carbon_jupiter_limit_order_2_decoder::PROGRAM_ID => Some(Self::LimitV2),
44            carbon_kamino_limit_order_decoder::PROGRAM_ID => Some(Self::Kamino),
45            _ => None,
46        }
47    }
48
49    #[cfg(all(feature = "wasm", not(feature = "native")))]
50    pub fn from_program_id(program_id: &str) -> Option<Self> {
51        match program_id {
52            DCA_PROGRAM_ID => Some(Self::Dca),
53            LIMIT_V1_PROGRAM_ID => Some(Self::LimitV1),
54            LIMIT_V2_PROGRAM_ID => Some(Self::LimitV2),
55            KAMINO_PROGRAM_ID => Some(Self::Kamino),
56            _ => None,
57        }
58    }
59
60    /// Returns the on-chain program id for every supported protocol.
61    #[cfg(feature = "native")]
62    pub fn all_program_ids() -> [solana_pubkey::Pubkey; 4] {
63        [
64            carbon_jupiter_dca_decoder::PROGRAM_ID,
65            carbon_jupiter_limit_order_decoder::PROGRAM_ID,
66            carbon_jupiter_limit_order_2_decoder::PROGRAM_ID,
67            carbon_kamino_limit_order_decoder::PROGRAM_ID,
68        ]
69    }
70
71    #[cfg(feature = "wasm")]
72    pub fn program_id_str(&self) -> &'static str {
73        match self {
74            Self::Dca => DCA_PROGRAM_ID,
75            Self::LimitV1 => LIMIT_V1_PROGRAM_ID,
76            Self::LimitV2 => LIMIT_V2_PROGRAM_ID,
77            Self::Kamino => KAMINO_PROGRAM_ID,
78        }
79    }
80}
81
82/// Canonical event classification shared across all protocols.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::Display, strum_macros::AsRefStr)]
84#[strum(serialize_all = "snake_case")]
85pub enum EventType {
86    /// Order was created on-chain.
87    Created,
88    /// A fill was initiated (e.g. flash-fill start).
89    FillInitiated,
90    /// A fill was completed (partial or full).
91    FillCompleted,
92    /// Order was explicitly cancelled.
93    Cancelled,
94    /// Order expired without completing.
95    Expired,
96    /// Order reached a terminal close (protocol-level).
97    Closed,
98    /// Protocol fee was collected.
99    FeeCollected,
100    /// Funds were withdrawn from the order.
101    Withdrawn,
102    /// Funds were deposited into the order.
103    Deposited,
104}
105
106/// A single account entry from a decoded instruction's account list.
107#[derive(Debug, Deserialize)]
108pub struct AccountInfo {
109    /// Base58-encoded account public key.
110    pub pubkey: String,
111    /// Whether this account signed the transaction.
112    #[serde(default)]
113    pub is_signer: bool,
114    /// Whether this account was marked writable.
115    #[serde(default)]
116    pub is_writable: bool,
117    /// Optional IDL-derived account name (e.g. `"dca"`, `"order"`).
118    pub name: Option<String>,
119}
120
121/// Shared stateless helpers used across all protocol adapters.
122pub struct ProtocolHelpers;
123
124impl ProtocolHelpers {
125    /// Looks up an [`EventType`] by name from a static mapping table.
126    pub fn lookup_event_type(
127        name: &str,
128        mapping: &[(&'static str, EventType)],
129    ) -> Option<EventType> {
130        mapping
131            .iter()
132            .find_map(|(candidate, event_type)| (*candidate == name).then_some(*event_type))
133    }
134
135    /// Deserializes a JSON array of accounts into [`AccountInfo`] structs.
136    pub fn parse_accounts(accounts_json: &serde_json::Value) -> Result<Vec<AccountInfo>, Error> {
137        serde_json::from_value(accounts_json.clone()).map_err(|e| Error::Protocol {
138            reason: format!("failed to parse accounts: {e}"),
139        })
140    }
141
142    /// Returns the pubkey of the first signer in the account list.
143    pub fn find_signer(accounts: &[AccountInfo]) -> Option<&str> {
144        accounts
145            .iter()
146            .find(|a| a.is_signer)
147            .map(|a| a.pubkey.as_str())
148    }
149
150    /// Finds an account by its IDL-derived name.
151    pub fn find_account_by_name<'a>(
152        accounts: &'a [AccountInfo],
153        name: &str,
154    ) -> Option<&'a AccountInfo> {
155        accounts.iter().find(|a| a.name.as_deref() == Some(name))
156    }
157
158    /// Returns `true` if the JSON object's keys contain any of the `known_names`.
159    pub fn contains_known_variant(fields: &serde_json::Value, known_names: &[&str]) -> bool {
160        fields
161            .as_object()
162            .is_some_and(|obj| obj.keys().any(|name| known_names.contains(&name.as_str())))
163    }
164
165    /// Converts `u64` to `i64`, returning an error if the value exceeds `i64::MAX`.
166    pub fn checked_u64_to_i64(value: u64, field: &str) -> Result<i64, Error> {
167        i64::try_from(value).map_err(|_| Error::Protocol {
168            reason: format!("{field} exceeds i64::MAX: {value}"),
169        })
170    }
171
172    /// Converts `u64` to `i64` for optional fields.
173    /// Returns `None` if the value exceeds `i64::MAX` (e.g. `u64::MAX` sentinel for "no limit").
174    pub fn optional_u64_to_i64(value: u64) -> Option<i64> {
175        i64::try_from(value).ok()
176    }
177
178    /// Converts `u16` to `i16`, returning an error if the value exceeds `i16::MAX`.
179    pub fn checked_u16_to_i16(value: u16, field: &str) -> Result<i16, Error> {
180        i16::try_from(value).map_err(|_| Error::Protocol {
181            reason: format!("{field} exceeds i16::MAX: {value}"),
182        })
183    }
184}
185
186#[cfg(test)]
187#[expect(clippy::unwrap_used, clippy::panic, reason = "test assertions")]
188mod tests {
189    use super::*;
190    use crate::lifecycle::adapters::{ProtocolAdapter, adapter_for};
191    use crate::types::{RawEvent, RawInstruction, ResolveContext};
192    use std::collections::HashSet;
193
194    #[cfg(feature = "native")]
195    #[test]
196    fn protocol_program_id_mapping_and_string_names_are_stable() {
197        let cases = [
198            (
199                &carbon_jupiter_dca_decoder::PROGRAM_ID,
200                Protocol::Dca,
201                "dca",
202            ),
203            (
204                &carbon_jupiter_limit_order_decoder::PROGRAM_ID,
205                Protocol::LimitV1,
206                "limit_v1",
207            ),
208            (
209                &carbon_jupiter_limit_order_2_decoder::PROGRAM_ID,
210                Protocol::LimitV2,
211                "limit_v2",
212            ),
213            (
214                &carbon_kamino_limit_order_decoder::PROGRAM_ID,
215                Protocol::Kamino,
216                "kamino",
217            ),
218        ];
219        for (program_id, expected_protocol, expected_name) in cases {
220            assert_eq!(
221                Protocol::from_program_id(&program_id.to_string()),
222                Some(expected_protocol)
223            );
224            assert_eq!(expected_protocol.as_ref(), expected_name);
225        }
226
227        assert_eq!(Protocol::from_program_id("unknown_program"), None);
228        assert_eq!(Protocol::from_program_id("not_even_base58!@#"), None);
229        assert_eq!(
230            Protocol::all_program_ids(),
231            [
232                carbon_jupiter_dca_decoder::PROGRAM_ID,
233                carbon_jupiter_limit_order_decoder::PROGRAM_ID,
234                carbon_jupiter_limit_order_2_decoder::PROGRAM_ID,
235                carbon_kamino_limit_order_decoder::PROGRAM_ID,
236            ]
237        );
238    }
239
240    #[cfg(all(feature = "native", feature = "wasm"))]
241    #[test]
242    fn hardcoded_program_ids_match_carbon_constants() {
243        assert_eq!(
244            carbon_jupiter_dca_decoder::PROGRAM_ID.to_string(),
245            DCA_PROGRAM_ID
246        );
247        assert_eq!(
248            carbon_jupiter_limit_order_decoder::PROGRAM_ID.to_string(),
249            LIMIT_V1_PROGRAM_ID
250        );
251        assert_eq!(
252            carbon_jupiter_limit_order_2_decoder::PROGRAM_ID.to_string(),
253            LIMIT_V2_PROGRAM_ID
254        );
255        assert_eq!(
256            carbon_kamino_limit_order_decoder::PROGRAM_ID.to_string(),
257            KAMINO_PROGRAM_ID
258        );
259    }
260
261    #[cfg(feature = "wasm")]
262    #[test]
263    fn protocol_program_id_str_roundtrips() {
264        for protocol in [
265            Protocol::Dca,
266            Protocol::LimitV1,
267            Protocol::LimitV2,
268            Protocol::Kamino,
269        ] {
270            assert_eq!(
271                Protocol::from_program_id(protocol.program_id_str()),
272                Some(protocol)
273            );
274        }
275    }
276
277    #[test]
278    fn event_type_strings_match_expected_labels() {
279        let cases = [
280            (EventType::Created, "created"),
281            (EventType::FillInitiated, "fill_initiated"),
282            (EventType::FillCompleted, "fill_completed"),
283            (EventType::Cancelled, "cancelled"),
284            (EventType::Expired, "expired"),
285            (EventType::Closed, "closed"),
286            (EventType::FeeCollected, "fee_collected"),
287            (EventType::Withdrawn, "withdrawn"),
288            (EventType::Deposited, "deposited"),
289        ];
290        for (event_type, expected_label) in cases {
291            assert_eq!(event_type.as_ref(), expected_label);
292        }
293    }
294
295    #[test]
296    fn parse_accounts_supports_defaults_and_find_helpers() {
297        let accounts_json = serde_json::json!([
298            {
299                "pubkey": "signer_pubkey",
300                "is_signer": true,
301                "is_writable": true,
302                "name": "order"
303            },
304            {
305                "pubkey": "readonly_pubkey"
306            }
307        ]);
308
309        let parsed = ProtocolHelpers::parse_accounts(&accounts_json).unwrap();
310        assert_eq!(parsed.len(), 2);
311        assert_eq!(parsed[0].pubkey, "signer_pubkey");
312        assert!(parsed[0].is_signer);
313        assert!(parsed[0].is_writable);
314        assert_eq!(parsed[0].name.as_deref(), Some("order"));
315
316        assert_eq!(parsed[1].pubkey, "readonly_pubkey");
317        assert!(!parsed[1].is_signer);
318        assert!(!parsed[1].is_writable);
319        assert!(parsed[1].name.is_none());
320
321        assert_eq!(ProtocolHelpers::find_signer(&parsed), Some("signer_pubkey"));
322        assert_eq!(
323            ProtocolHelpers::find_account_by_name(&parsed, "order").map(|a| a.pubkey.as_str()),
324            Some("signer_pubkey")
325        );
326        assert!(ProtocolHelpers::find_account_by_name(&parsed, "missing").is_none());
327    }
328
329    #[test]
330    fn parse_accounts_rejects_non_array() {
331        let err = ProtocolHelpers::parse_accounts(&serde_json::json!({"pubkey": "not-an-array"}))
332            .unwrap_err();
333        let Error::Protocol { reason } = err else {
334            panic!("expected protocol error");
335        };
336        assert!(reason.contains("failed to parse accounts"), "{reason}");
337    }
338
339    #[test]
340    fn parse_accounts_rejects_missing_pubkey() {
341        let err =
342            ProtocolHelpers::parse_accounts(&serde_json::json!([{"is_signer": true}])).unwrap_err();
343        let Error::Protocol { reason } = err else {
344            panic!("expected protocol error");
345        };
346        assert!(reason.contains("failed to parse accounts"), "{reason}");
347    }
348
349    #[test]
350    fn find_signer_returns_none_when_no_signer_present() {
351        let accounts = vec![AccountInfo {
352            pubkey: "p1".to_string(),
353            is_signer: false,
354            is_writable: false,
355            name: Some("order".to_string()),
356        }];
357
358        assert_eq!(ProtocolHelpers::find_signer(&accounts), None);
359    }
360
361    fn make_ix(name: &str) -> RawInstruction {
362        RawInstruction {
363            id: 1,
364            signature: "sig".to_string(),
365            instruction_index: 0,
366            instruction_path: None,
367            program_id: "p".to_string(),
368            inner_program_id: "p".to_string(),
369            instruction_name: name.to_string(),
370            accounts: None,
371            args: None,
372            slot: 1,
373        }
374    }
375
376    fn collect_instruction_event_types(
377        instruction_names: &[&str],
378        adapter: &dyn ProtocolAdapter,
379    ) -> HashSet<String> {
380        instruction_names
381            .iter()
382            .filter_map(|name| adapter.classify_instruction(&make_ix(name)))
383            .map(|et| et.as_ref().to_string())
384            .collect()
385    }
386
387    fn make_event(fields: serde_json::Value) -> RawEvent {
388        RawEvent {
389            id: 1,
390            signature: "sig".to_string(),
391            event_index: 0,
392            event_path: None,
393            program_id: "p".to_string(),
394            inner_program_id: "p".to_string(),
395            event_name: "test".to_string(),
396            fields: Some(fields),
397            slot: 1,
398        }
399    }
400
401    fn resolve_event_type(
402        json: serde_json::Value,
403        adapter: &dyn ProtocolAdapter,
404        ctx: &ResolveContext,
405    ) -> Option<String> {
406        adapter
407            .classify_and_resolve_event(&make_event(json), ctx)
408            .and_then(|r| r.ok())
409            .map(|(et, _, _)| et.as_ref().to_string())
410    }
411
412    #[test]
413    fn event_type_reachability_all_variants_covered() {
414        let mut all_event_types: HashSet<String> = HashSet::new();
415        let default_ctx = ResolveContext {
416            pre_fetched_order_pdas: None,
417        };
418
419        let dca = adapter_for(Protocol::Dca);
420        let dca_ix_names = [
421            "OpenDca",
422            "OpenDcaV2",
423            "InitiateFlashFill",
424            "InitiateDlmmFill",
425            "FulfillFlashFill",
426            "FulfillDlmmFill",
427            "CloseDca",
428            "EndAndClose",
429            "Transfer",
430            "Deposit",
431            "Withdraw",
432            "WithdrawFees",
433        ];
434        all_event_types.extend(collect_instruction_event_types(&dca_ix_names, dca));
435
436        let dca_event_payloads = [
437            serde_json::json!({"OpenedEvent": {"dca_key": "t"}}),
438            serde_json::json!({"FilledEvent": {"dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64}}),
439            serde_json::json!({"ClosedEvent": {"dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64}}),
440            serde_json::json!({"CollectedFeeEvent": {"dca_key": "t"}}),
441            serde_json::json!({"WithdrawEvent": {"dca_key": "t"}}),
442            serde_json::json!({"DepositEvent": {"dca_key": "t"}}),
443        ];
444        for json in &dca_event_payloads {
445            if let Some(et) = resolve_event_type(json.clone(), dca, &default_ctx) {
446                all_event_types.insert(et);
447            }
448        }
449
450        let v1 = adapter_for(Protocol::LimitV1);
451        let v1_ix_names = [
452            "InitializeOrder",
453            "PreFlashFillOrder",
454            "FillOrder",
455            "FlashFillOrder",
456            "CancelOrder",
457            "CancelExpiredOrder",
458            "WithdrawFee",
459            "InitFee",
460            "UpdateFee",
461        ];
462        all_event_types.extend(collect_instruction_event_types(&v1_ix_names, v1));
463
464        let v1_event_payloads = [
465            serde_json::json!({"CreateOrderEvent": {"order_key": "t"}}),
466            serde_json::json!({"CancelOrderEvent": {"order_key": "t"}}),
467            serde_json::json!({"TradeEvent": {"order_key": "t", "in_amount": 1_u64, "out_amount": 1_u64, "remaining_in_amount": 0_u64, "remaining_out_amount": 0_u64}}),
468        ];
469        for json in &v1_event_payloads {
470            if let Some(et) = resolve_event_type(json.clone(), v1, &default_ctx) {
471                all_event_types.insert(et);
472            }
473        }
474
475        let v2 = adapter_for(Protocol::LimitV2);
476        let v2_ix_names = [
477            "InitializeOrder",
478            "PreFlashFillOrder",
479            "FlashFillOrder",
480            "CancelOrder",
481            "UpdateFee",
482            "WithdrawFee",
483        ];
484        all_event_types.extend(collect_instruction_event_types(&v2_ix_names, v2));
485
486        let v2_event_payloads = [
487            serde_json::json!({"CreateOrderEvent": {"order_key": "t"}}),
488            serde_json::json!({"CancelOrderEvent": {"order_key": "t"}}),
489            serde_json::json!({"TradeEvent": {"order_key": "t", "making_amount": 1_u64, "taking_amount": 1_u64, "remaining_making_amount": 0_u64, "remaining_taking_amount": 0_u64}}),
490        ];
491        for json in &v2_event_payloads {
492            if let Some(et) = resolve_event_type(json.clone(), v2, &default_ctx) {
493                all_event_types.insert(et);
494            }
495        }
496
497        let kamino = adapter_for(Protocol::Kamino);
498        let kamino_ix_names = [
499            "CreateOrder",
500            "TakeOrder",
501            "FlashTakeOrderStart",
502            "FlashTakeOrderEnd",
503            "CloseOrderAndClaimTip",
504            "InitializeGlobalConfig",
505            "InitializeVault",
506            "UpdateGlobalConfig",
507            "UpdateGlobalConfigAdmin",
508            "WithdrawHostTip",
509            "LogUserSwapBalances",
510        ];
511        all_event_types.extend(collect_instruction_event_types(&kamino_ix_names, kamino));
512
513        let kamino_ctx = ResolveContext {
514            pre_fetched_order_pdas: Some(vec!["test_pda".to_string()]),
515        };
516        let kamino_event_payloads = [
517            serde_json::json!({"OrderDisplayEvent": {"status": 1_u8}}),
518            serde_json::json!({"UserSwapBalancesEvent": {}}),
519        ];
520        for json in &kamino_event_payloads {
521            if let Some(et) = resolve_event_type(json.clone(), kamino, &kamino_ctx) {
522                all_event_types.insert(et);
523            }
524        }
525
526        let expected: HashSet<String> = [
527            "created",
528            "fill_initiated",
529            "fill_completed",
530            "cancelled",
531            "expired",
532            "closed",
533            "fee_collected",
534            "withdrawn",
535            "deposited",
536        ]
537        .into_iter()
538        .map(String::from)
539        .collect();
540
541        assert_eq!(
542            all_event_types,
543            expected,
544            "missing EventTypes: {:?}, extra: {:?}",
545            expected.difference(&all_event_types).collect::<Vec<_>>(),
546            all_event_types.difference(&expected).collect::<Vec<_>>()
547        );
548    }
549}