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, Copy, 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 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 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 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 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 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 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 pub fn optional_u64_to_i64(value: u64) -> Option<i64> {
175 i64::try_from(value).ok()
176 }
177
178 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}