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#[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 Dca,
27 LimitV1,
29 LimitV2,
31 Kamino,
33}
34
35impl Protocol {
36 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, strum_macros::Display, strum_macros::AsRefStr)]
84#[strum(serialize_all = "snake_case")]
85pub enum EventType {
86 Created,
88 FillInitiated,
90 FillCompleted,
92 Cancelled,
94 Expired,
96 Closed,
98 FeeCollected,
100 Withdrawn,
102 Deposited,
104}
105
106#[derive(Debug, Deserialize)]
108pub struct AccountInfo {
109 pub pubkey: String,
111 #[serde(default)]
113 pub is_signer: bool,
114 #[serde(default)]
116 pub is_writable: bool,
117 pub name: Option<String>,
119}
120
121pub struct ProtocolHelpers;
123
124impl ProtocolHelpers {
125 pub fn parse_accounts(accounts_json: &serde_json::Value) -> Result<Vec<AccountInfo>, Error> {
127 serde_json::from_value(accounts_json.clone()).map_err(|e| Error::Protocol {
128 reason: format!("failed to parse accounts: {e}"),
129 })
130 }
131
132 pub fn find_signer(accounts: &[AccountInfo]) -> Option<&str> {
134 accounts
135 .iter()
136 .find(|a| a.is_signer)
137 .map(|a| a.pubkey.as_str())
138 }
139
140 pub fn find_account_by_name<'a>(
142 accounts: &'a [AccountInfo],
143 name: &str,
144 ) -> Option<&'a AccountInfo> {
145 accounts.iter().find(|a| a.name.as_deref() == Some(name))
146 }
147
148 pub fn contains_known_variant(fields: &serde_json::Value, known_names: &[&str]) -> bool {
150 fields
151 .as_object()
152 .is_some_and(|obj| obj.keys().any(|name| known_names.contains(&name.as_str())))
153 }
154
155 pub fn checked_u64_to_i64(value: u64, field: &str) -> Result<i64, Error> {
157 i64::try_from(value).map_err(|_| Error::Protocol {
158 reason: format!("{field} exceeds i64::MAX: {value}"),
159 })
160 }
161
162 pub fn optional_u64_to_i64(value: u64) -> Option<i64> {
165 i64::try_from(value).ok()
166 }
167
168 pub fn checked_u16_to_i16(value: u16, field: &str) -> Result<i16, Error> {
170 i16::try_from(value).map_err(|_| Error::Protocol {
171 reason: format!("{field} exceeds i16::MAX: {value}"),
172 })
173 }
174}
175
176#[cfg(test)]
177#[expect(clippy::unwrap_used, reason = "test assertions")]
178mod tests {
179 use super::*;
180 use crate::lifecycle::adapters::{ProtocolAdapter, adapter_for};
181 use crate::types::{RawEvent, RawInstruction, ResolveContext};
182 use std::collections::HashSet;
183
184 #[cfg(feature = "native")]
185 #[test]
186 fn protocol_program_id_mapping_and_string_names_are_stable() {
187 let cases = [
188 (
189 &carbon_jupiter_dca_decoder::PROGRAM_ID,
190 Protocol::Dca,
191 "dca",
192 ),
193 (
194 &carbon_jupiter_limit_order_decoder::PROGRAM_ID,
195 Protocol::LimitV1,
196 "limit_v1",
197 ),
198 (
199 &carbon_jupiter_limit_order_2_decoder::PROGRAM_ID,
200 Protocol::LimitV2,
201 "limit_v2",
202 ),
203 (
204 &carbon_kamino_limit_order_decoder::PROGRAM_ID,
205 Protocol::Kamino,
206 "kamino",
207 ),
208 ];
209 for (program_id, expected_protocol, expected_name) in cases {
210 assert_eq!(
211 Protocol::from_program_id(&program_id.to_string()),
212 Some(expected_protocol)
213 );
214 assert_eq!(expected_protocol.as_ref(), expected_name);
215 }
216
217 assert_eq!(Protocol::from_program_id("unknown_program"), None);
218 assert_eq!(Protocol::from_program_id("not_even_base58!@#"), None);
219 assert_eq!(
220 Protocol::all_program_ids(),
221 [
222 carbon_jupiter_dca_decoder::PROGRAM_ID,
223 carbon_jupiter_limit_order_decoder::PROGRAM_ID,
224 carbon_jupiter_limit_order_2_decoder::PROGRAM_ID,
225 carbon_kamino_limit_order_decoder::PROGRAM_ID,
226 ]
227 );
228 }
229
230 #[cfg(all(feature = "native", feature = "wasm"))]
231 #[test]
232 fn hardcoded_program_ids_match_carbon_constants() {
233 assert_eq!(
234 carbon_jupiter_dca_decoder::PROGRAM_ID.to_string(),
235 DCA_PROGRAM_ID
236 );
237 assert_eq!(
238 carbon_jupiter_limit_order_decoder::PROGRAM_ID.to_string(),
239 LIMIT_V1_PROGRAM_ID
240 );
241 assert_eq!(
242 carbon_jupiter_limit_order_2_decoder::PROGRAM_ID.to_string(),
243 LIMIT_V2_PROGRAM_ID
244 );
245 assert_eq!(
246 carbon_kamino_limit_order_decoder::PROGRAM_ID.to_string(),
247 KAMINO_PROGRAM_ID
248 );
249 }
250
251 #[cfg(feature = "wasm")]
252 #[test]
253 fn protocol_program_id_str_roundtrips() {
254 for protocol in [
255 Protocol::Dca,
256 Protocol::LimitV1,
257 Protocol::LimitV2,
258 Protocol::Kamino,
259 ] {
260 assert_eq!(
261 Protocol::from_program_id(protocol.program_id_str()),
262 Some(protocol)
263 );
264 }
265 }
266
267 #[test]
268 fn event_type_strings_match_expected_labels() {
269 let cases = [
270 (EventType::Created, "created"),
271 (EventType::FillInitiated, "fill_initiated"),
272 (EventType::FillCompleted, "fill_completed"),
273 (EventType::Cancelled, "cancelled"),
274 (EventType::Expired, "expired"),
275 (EventType::Closed, "closed"),
276 (EventType::FeeCollected, "fee_collected"),
277 (EventType::Withdrawn, "withdrawn"),
278 (EventType::Deposited, "deposited"),
279 ];
280 for (event_type, expected_label) in cases {
281 assert_eq!(event_type.as_ref(), expected_label);
282 }
283 }
284
285 #[test]
286 fn parse_accounts_supports_defaults_and_find_helpers() {
287 let accounts_json = serde_json::json!([
288 {
289 "pubkey": "signer_pubkey",
290 "is_signer": true,
291 "is_writable": true,
292 "name": "order"
293 },
294 {
295 "pubkey": "readonly_pubkey"
296 }
297 ]);
298
299 let parsed = ProtocolHelpers::parse_accounts(&accounts_json).unwrap();
300 assert_eq!(parsed.len(), 2);
301 assert_eq!(parsed[0].pubkey, "signer_pubkey");
302 assert!(parsed[0].is_signer);
303 assert!(parsed[0].is_writable);
304 assert_eq!(parsed[0].name.as_deref(), Some("order"));
305
306 assert_eq!(parsed[1].pubkey, "readonly_pubkey");
307 assert!(!parsed[1].is_signer);
308 assert!(!parsed[1].is_writable);
309 assert!(parsed[1].name.is_none());
310
311 assert_eq!(ProtocolHelpers::find_signer(&parsed), Some("signer_pubkey"));
312 assert_eq!(
313 ProtocolHelpers::find_account_by_name(&parsed, "order").map(|a| a.pubkey.as_str()),
314 Some("signer_pubkey")
315 );
316 assert!(ProtocolHelpers::find_account_by_name(&parsed, "missing").is_none());
317 }
318
319 #[test]
320 fn parse_accounts_rejects_non_array() {
321 let err = ProtocolHelpers::parse_accounts(&serde_json::json!({"pubkey": "not-an-array"}))
322 .unwrap_err();
323 let Error::Protocol { reason } = err else {
324 panic!("expected protocol error");
325 };
326 assert!(reason.contains("failed to parse accounts"), "{reason}");
327 }
328
329 #[test]
330 fn parse_accounts_rejects_missing_pubkey() {
331 let err =
332 ProtocolHelpers::parse_accounts(&serde_json::json!([{"is_signer": true}])).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 find_signer_returns_none_when_no_signer_present() {
341 let accounts = vec![AccountInfo {
342 pubkey: "p1".to_string(),
343 is_signer: false,
344 is_writable: false,
345 name: Some("order".to_string()),
346 }];
347
348 assert_eq!(ProtocolHelpers::find_signer(&accounts), None);
349 }
350
351 fn make_ix(name: &str) -> RawInstruction {
352 RawInstruction {
353 id: 1,
354 signature: "sig".to_string(),
355 instruction_index: 0,
356 instruction_path: None,
357 program_id: "p".to_string(),
358 inner_program_id: "p".to_string(),
359 instruction_name: name.to_string(),
360 accounts: None,
361 args: None,
362 slot: 1,
363 }
364 }
365
366 fn collect_instruction_event_types(
367 instruction_names: &[&str],
368 adapter: &dyn ProtocolAdapter,
369 ) -> HashSet<String> {
370 instruction_names
371 .iter()
372 .filter_map(|name| adapter.classify_instruction(&make_ix(name)))
373 .map(|et| et.as_ref().to_string())
374 .collect()
375 }
376
377 fn make_event(fields: serde_json::Value) -> RawEvent {
378 RawEvent {
379 id: 1,
380 signature: "sig".to_string(),
381 event_index: 0,
382 event_path: None,
383 program_id: "p".to_string(),
384 inner_program_id: "p".to_string(),
385 event_name: "test".to_string(),
386 fields: Some(fields),
387 slot: 1,
388 }
389 }
390
391 fn resolve_event_type(
392 json: serde_json::Value,
393 adapter: &dyn ProtocolAdapter,
394 ctx: &ResolveContext,
395 ) -> Option<String> {
396 adapter
397 .classify_and_resolve_event(&make_event(json), ctx)
398 .and_then(|r| r.ok())
399 .map(|(et, _, _)| et.as_ref().to_string())
400 }
401
402 #[test]
403 fn event_type_reachability_all_variants_covered() {
404 let mut all_event_types: HashSet<String> = HashSet::new();
405 let default_ctx = ResolveContext {
406 pre_fetched_order_pdas: None,
407 };
408
409 let dca = adapter_for(Protocol::Dca);
410 let dca_ix_names = [
411 "OpenDca",
412 "OpenDcaV2",
413 "InitiateFlashFill",
414 "InitiateDlmmFill",
415 "FulfillFlashFill",
416 "FulfillDlmmFill",
417 "CloseDca",
418 "EndAndClose",
419 "Transfer",
420 "Deposit",
421 "Withdraw",
422 "WithdrawFees",
423 ];
424 all_event_types.extend(collect_instruction_event_types(&dca_ix_names, dca));
425
426 let dca_event_payloads = [
427 serde_json::json!({"OpenedEvent": {"dca_key": "t"}}),
428 serde_json::json!({"FilledEvent": {"dca_key": "t", "in_amount": 1_u64, "out_amount": 1_u64}}),
429 serde_json::json!({"ClosedEvent": {"dca_key": "t", "user_closed": false, "unfilled_amount": 0_u64}}),
430 serde_json::json!({"CollectedFeeEvent": {"dca_key": "t"}}),
431 serde_json::json!({"WithdrawEvent": {"dca_key": "t"}}),
432 serde_json::json!({"DepositEvent": {"dca_key": "t"}}),
433 ];
434 for json in &dca_event_payloads {
435 if let Some(et) = resolve_event_type(json.clone(), dca, &default_ctx) {
436 all_event_types.insert(et);
437 }
438 }
439
440 let v1 = adapter_for(Protocol::LimitV1);
441 let v1_ix_names = [
442 "InitializeOrder",
443 "PreFlashFillOrder",
444 "FillOrder",
445 "FlashFillOrder",
446 "CancelOrder",
447 "CancelExpiredOrder",
448 "WithdrawFee",
449 "InitFee",
450 "UpdateFee",
451 ];
452 all_event_types.extend(collect_instruction_event_types(&v1_ix_names, v1));
453
454 let v1_event_payloads = [
455 serde_json::json!({"CreateOrderEvent": {"order_key": "t"}}),
456 serde_json::json!({"CancelOrderEvent": {"order_key": "t"}}),
457 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}}),
458 ];
459 for json in &v1_event_payloads {
460 if let Some(et) = resolve_event_type(json.clone(), v1, &default_ctx) {
461 all_event_types.insert(et);
462 }
463 }
464
465 let v2 = adapter_for(Protocol::LimitV2);
466 let v2_ix_names = [
467 "InitializeOrder",
468 "PreFlashFillOrder",
469 "FlashFillOrder",
470 "CancelOrder",
471 "UpdateFee",
472 "WithdrawFee",
473 ];
474 all_event_types.extend(collect_instruction_event_types(&v2_ix_names, v2));
475
476 let v2_event_payloads = [
477 serde_json::json!({"CreateOrderEvent": {"order_key": "t"}}),
478 serde_json::json!({"CancelOrderEvent": {"order_key": "t"}}),
479 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}}),
480 ];
481 for json in &v2_event_payloads {
482 if let Some(et) = resolve_event_type(json.clone(), v2, &default_ctx) {
483 all_event_types.insert(et);
484 }
485 }
486
487 let kamino = adapter_for(Protocol::Kamino);
488 let kamino_ix_names = [
489 "CreateOrder",
490 "TakeOrder",
491 "FlashTakeOrderStart",
492 "FlashTakeOrderEnd",
493 "CloseOrderAndClaimTip",
494 "InitializeGlobalConfig",
495 "InitializeVault",
496 "UpdateGlobalConfig",
497 "UpdateGlobalConfigAdmin",
498 "WithdrawHostTip",
499 "LogUserSwapBalances",
500 ];
501 all_event_types.extend(collect_instruction_event_types(&kamino_ix_names, kamino));
502
503 let kamino_ctx = ResolveContext {
504 pre_fetched_order_pdas: Some(vec!["test_pda".to_string()]),
505 };
506 let kamino_event_payloads = [
507 serde_json::json!({"OrderDisplayEvent": {"status": 1_u8}}),
508 serde_json::json!({"UserSwapBalancesEvent": {}}),
509 ];
510 for json in &kamino_event_payloads {
511 if let Some(et) = resolve_event_type(json.clone(), kamino, &kamino_ctx) {
512 all_event_types.insert(et);
513 }
514 }
515
516 let expected: HashSet<String> = [
517 "created",
518 "fill_initiated",
519 "fill_completed",
520 "cancelled",
521 "expired",
522 "closed",
523 "fee_collected",
524 "withdrawn",
525 "deposited",
526 ]
527 .into_iter()
528 .map(String::from)
529 .collect();
530
531 assert_eq!(
532 all_event_types,
533 expected,
534 "missing EventTypes: {:?}, extra: {:?}",
535 expected.difference(&all_event_types).collect::<Vec<_>>(),
536 all_event_types.difference(&expected).collect::<Vec<_>>()
537 );
538 }
539}