// file: kb_lib/src/dex/raydium_stable_swap.rs //! Raydium Stable Swap decoder. //! //! Raydium Stable Swap uses a legacy one-byte instruction discriminator layout. //! The decoder below follows the Pinax/Substreams layout and keeps Anchor-like //! eight-byte discriminants as upstream discovery evidence only. /// Raydium Stable Swap `initialize` one-byte discriminator. const RAYDIUM_STABLE_SWAP_INITIALIZE_DISCRIMINATOR_HEX: &str = "00"; /// Raydium Stable Swap `init_model_data` one-byte discriminator. const RAYDIUM_STABLE_SWAP_INIT_MODEL_DATA_DISCRIMINATOR_HEX: &str = "01"; /// Raydium Stable Swap `update_model_data` one-byte discriminator. const RAYDIUM_STABLE_SWAP_UPDATE_MODEL_DATA_DISCRIMINATOR_HEX: &str = "02"; /// Raydium Stable Swap `deposit` one-byte discriminator. const RAYDIUM_STABLE_SWAP_DEPOSIT_DISCRIMINATOR_HEX: &str = "03"; /// Raydium Stable Swap `withdraw` one-byte discriminator. const RAYDIUM_STABLE_SWAP_WITHDRAW_DISCRIMINATOR_HEX: &str = "04"; /// Raydium Stable Swap `monitor_step` one-byte discriminator. const RAYDIUM_STABLE_SWAP_MONITOR_STEP_DISCRIMINATOR_HEX: &str = "05"; /// Raydium Stable Swap `set_params` one-byte discriminator. const RAYDIUM_STABLE_SWAP_SET_PARAMS_DISCRIMINATOR_HEX: &str = "06"; /// Raydium Stable Swap `withdraw_pnl` one-byte discriminator. const RAYDIUM_STABLE_SWAP_WITHDRAW_PNL_DISCRIMINATOR_HEX: &str = "07"; /// Raydium Stable Swap `withdraw_srm` one-byte discriminator. const RAYDIUM_STABLE_SWAP_WITHDRAW_SRM_DISCRIMINATOR_HEX: &str = "08"; /// Raydium Stable Swap `swap_base_in` one-byte discriminator. const RAYDIUM_STABLE_SWAP_SWAP_BASE_IN_DISCRIMINATOR_HEX: &str = "09"; /// Raydium Stable Swap `pre_initialize` one-byte discriminator. const RAYDIUM_STABLE_SWAP_PRE_INITIALIZE_DISCRIMINATOR_HEX: &str = "0a"; /// Raydium Stable Swap `swap_base_out` one-byte discriminator. const RAYDIUM_STABLE_SWAP_SWAP_BASE_OUT_DISCRIMINATOR_HEX: &str = "0b"; /// Raydium Stable Swap `simulate_info` one-byte discriminator. const RAYDIUM_STABLE_SWAP_SIMULATE_INFO_DISCRIMINATOR_HEX: &str = "0c"; /// Raydium Stable Swap `admin_cancel_orders` one-byte discriminator observed locally. const RAYDIUM_STABLE_SWAP_ADMIN_CANCEL_ORDERS_DISCRIMINATOR_HEX: &str = "0d"; /// Raydium Stable Swap `SwapEvent` discriminator reused by Raydium CPMM logs. const RAYDIUM_STABLE_SWAP_SWAP_EVENT_DISCRIMINATOR_HEX: &str = "40c6cde8260871e2"; /// Raydium Stable Swap decoded event. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum RaydiumStableSwapDecodedEvent { /// Known Stable Swap instruction decoded from the local one-byte layout. Instruction(std::boxed::Box), /// Program-data swap event retained as decoded-only audit evidence. SwapEvent(std::boxed::Box), } /// Decoded Raydium Stable Swap instruction. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RaydiumStableSwapInstructionDecoded { /// Parent transaction id. pub transaction_id: i64, /// Parent instruction id. pub instruction_id: i64, /// Transaction signature. pub signature: std::string::String, /// Program id. pub program_id: std::string::String, /// Local decoded event kind. pub event_kind: std::string::String, /// Upstream/local instruction name. pub instruction_name: std::string::String, /// One-byte instruction discriminator in hexadecimal form. pub discriminator_hex: std::string::String, /// Optional pool/AMM account. pub pool_account: std::option::Option, /// Optional market account. pub market_account: std::option::Option, /// Optional LP mint account. pub lp_mint: std::option::Option, /// Optional normalized base mint. pub token_a_mint: std::option::Option, /// Optional normalized quote mint. pub token_b_mint: std::option::Option, /// Optional normalized base vault. pub base_vault: std::option::Option, /// Optional normalized quote vault. pub quote_vault: std::option::Option, /// Decoded and enrichment-ready payload. pub payload_json: serde_json::Value, } /// Decoded Raydium Stable Swap program-data swap event. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RaydiumStableSwapSwapEventDecoded { /// Event discriminator in hexadecimal form. pub event_discriminator_hex: std::string::String, /// Raydium stable-swap event-local dex flag. pub dex: u8, /// Raw input amount emitted by the event. pub amount_in_raw: std::string::String, /// Raw output amount emitted by the event. pub amount_out_raw: std::string::String, /// Whether the event can materialize as a trade. pub trade_candidate: bool, /// Whether the event can materialize as a candle. pub candle_candidate: bool, /// Reason why trade materialization is skipped. pub skip_trade_reason: std::string::String, /// Reason why candle materialization is skipped. pub skip_candle_reason: std::string::String, } /// Raydium Stable Swap decoder. #[derive(Debug, Clone, Default)] pub struct RaydiumStableSwapDecoder; impl RaydiumStableSwapDecoder { /// Creates a new Raydium Stable Swap decoder. pub fn new() -> Self { return Self; } /// Decodes all Raydium Stable Swap instructions in one projected transaction. pub fn decode_transaction( &self, transaction: &crate::ChainTransactionDto, instructions: &[crate::ChainInstructionDto], ) -> Result, crate::Error> { let transaction_id = match transaction.id { Some(transaction_id) => transaction_id, None => { return Err(crate::Error::InvalidState(format!( "chain transaction '{}' has no internal id", transaction.signature ))); }, }; let transaction_json_value = parse_json_value(transaction.transaction_json.as_str()); let meta_json_value = parse_optional_json_value(transaction.meta_json.as_deref()); let account_keys = extract_transaction_account_keys( transaction_json_value.as_ref(), meta_json_value.as_ref(), ); let token_mints_by_account = extract_token_mints_by_account( transaction_json_value.as_ref(), meta_json_value.as_ref(), account_keys.as_slice(), ); let token_balance_records = extract_token_balance_records( transaction_json_value.as_ref(), meta_json_value.as_ref(), account_keys.as_slice(), ); let mut decoded_events = std::vec::Vec::new(); for instruction in instructions { let program_id = match instruction.program_id.as_ref() { Some(program_id) => program_id, None => continue, }; if program_id.as_str() != crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID { continue; } let instruction_id = match instruction.id { Some(instruction_id) => instruction_id, None => continue, }; let decoded = decode_raydium_stable_swap_instruction_with_context( transaction_id, instruction_id, transaction.signature.as_str(), program_id.as_str(), instruction.accounts_json.as_str(), instruction.data_json.as_deref(), &token_mints_by_account, token_balance_records.as_slice(), ); for event in decoded { decoded_events.push(event); } } return Ok(decoded_events); } } impl RaydiumStableSwapDecodedEvent { /// Returns the local decoded event kind. pub fn event_kind(&self) -> &str { match self { Self::Instruction(event) => return event.event_kind.as_str(), Self::SwapEvent(_) => return "raydium_stable_swap.swap_event", } } /// Returns the optional pool account. pub fn pool_account(&self) -> std::option::Option<&str> { match self { Self::Instruction(event) => return event.pool_account.as_deref(), Self::SwapEvent(_) => return None, } } /// Returns the optional market account. pub fn market_account(&self) -> std::option::Option<&str> { match self { Self::Instruction(event) => return event.market_account.as_deref(), Self::SwapEvent(_) => return None, } } /// Returns the optional normalized base mint. pub fn base_mint(&self) -> std::option::Option<&str> { match self { Self::Instruction(event) => return event.token_a_mint.as_deref(), Self::SwapEvent(_) => return None, } } /// Returns the optional normalized quote mint. pub fn quote_mint(&self) -> std::option::Option<&str> { match self { Self::Instruction(event) => return event.token_b_mint.as_deref(), Self::SwapEvent(_) => return None, } } /// Returns the optional LP mint. pub fn lp_mint(&self) -> std::option::Option<&str> { match self { Self::Instruction(event) => return event.lp_mint.as_deref(), Self::SwapEvent(_) => return None, } } /// Serializes the decoded payload as JSON text. pub fn to_payload_json(&self) -> std::option::Option { match self { Self::Instruction(event) => { let result = serde_json::to_string(&event.payload_json); match result { Ok(payload) => return Some(payload), Err(_) => return None, } }, Self::SwapEvent(event) => { let result = serde_json::to_string(event); match result { Ok(payload) => return Some(payload), Err(_) => return None, } }, } } /// Returns the parent instruction id when available. pub fn instruction_id(&self) -> std::option::Option { match self { Self::Instruction(event) => return Some(event.instruction_id), Self::SwapEvent(_) => return None, } } } /// Classifies one Raydium Stable Swap instruction data payload. pub fn classify_raydium_stable_swap_instruction_data( data_json: &str, ) -> std::option::Option<&'static str> { let data = match decode_data_json_base58(data_json) { Some(data) => data, None => return None, }; let discriminator = match data.first() { Some(discriminator) => *discriminator, None => return None, }; return stable_instruction_name(discriminator); } /// Decodes Raydium Stable Swap `Program data:` event payloads. pub fn decode_raydium_stable_swap_program_data_event( data_base64: &str, ) -> std::option::Option { use base64::Engine as _; let decoded_result = base64::engine::general_purpose::STANDARD.decode(data_base64.as_bytes()); let data = match decoded_result { Ok(data) => data, Err(_) => return None, }; if data.len() < 25 { return None; } let discriminator_hex = bytes_to_hex(&data[0..8]); if discriminator_hex != RAYDIUM_STABLE_SWAP_SWAP_EVENT_DISCRIMINATOR_HEX { return None; } let dex = data[8]; let amount_in = match read_u64_le(data.as_slice(), 9) { Some(amount_in) => amount_in, None => return None, }; let amount_out = match read_u64_le(data.as_slice(), 17) { Some(amount_out) => amount_out, None => return None, }; return Some(crate::RaydiumStableSwapDecodedEvent::SwapEvent(std::boxed::Box::new( RaydiumStableSwapSwapEventDecoded { event_discriminator_hex: discriminator_hex, dex, amount_in_raw: amount_in.to_string(), amount_out_raw: amount_out.to_string(), trade_candidate: false, candle_candidate: false, skip_trade_reason: "raydium_stable_swap_swap_event_decoded_only".to_string(), skip_candle_reason: "raydium_stable_swap_swap_event_decoded_only".to_string(), }, ))); } #[allow(clippy::too_many_arguments)] fn decode_raydium_stable_swap_instruction_with_context( transaction_id: i64, instruction_id: i64, signature: &str, program_id: &str, accounts_json: &str, data_json: std::option::Option<&str>, token_mints_by_account: &std::collections::BTreeMap, token_balance_records: &[TokenBalanceRecord], ) -> std::vec::Vec { let data_json = match data_json { Some(data_json) => data_json, None => return std::vec::Vec::new(), }; let accounts = match parse_accounts_json(accounts_json) { Some(accounts) => accounts, None => return std::vec::Vec::new(), }; let data = match decode_data_json_base58(data_json) { Some(data) => data, None => return std::vec::Vec::new(), }; let discriminator = match data.first() { Some(discriminator) => *discriminator, None => return std::vec::Vec::new(), }; let instruction_name = match stable_instruction_name(discriminator) { Some(instruction_name) => instruction_name, None => return std::vec::Vec::new(), }; let decoded = build_stable_instruction_event( transaction_id, instruction_id, signature, program_id, instruction_name, discriminator, accounts.as_slice(), data.as_slice(), token_mints_by_account, token_balance_records, ); let decoded = match decoded { Some(decoded) => decoded, None => return std::vec::Vec::new(), }; return vec![crate::RaydiumStableSwapDecodedEvent::Instruction(std::boxed::Box::new(decoded))]; } #[allow(clippy::too_many_arguments)] fn build_stable_instruction_event( transaction_id: i64, instruction_id: i64, signature: &str, program_id: &str, instruction_name: &str, discriminator: u8, accounts: &[std::string::String], data: &[u8], token_mints_by_account: &std::collections::BTreeMap, token_balance_records: &[TokenBalanceRecord], ) -> std::option::Option { let event_kind = format!("raydium_stable_swap.{}", instruction_name); let discriminator_hex = match stable_instruction_discriminator_hex(discriminator) { Some(discriminator_hex) => discriminator_hex.to_string(), None => format!("{:02x}", discriminator), }; let mut payload = serde_json::Map::new(); payload.insert( "instructionName".to_string(), serde_json::Value::String(instruction_name.to_string()), ); payload.insert("eventKind".to_string(), serde_json::Value::String(event_kind.clone())); payload.insert( "discriminatorHex".to_string(), serde_json::Value::String(discriminator_hex.clone()), ); payload.insert("programId".to_string(), serde_json::Value::String(program_id.to_string())); payload.insert( "stableLayout".to_string(), serde_json::Value::String("pinax_one_byte".to_string()), ); let pool_account: std::option::Option; let market_account: std::option::Option; let lp_mint: std::option::Option; let token_a_mint: std::option::Option; let token_b_mint: std::option::Option; let base_vault: std::option::Option; let quote_vault: std::option::Option; match instruction_name { "initialize" => { pool_account = account_at(accounts, 3); lp_mint = account_at(accounts, 6); let coin_mint = account_at(accounts, 7); let pc_mint = account_at(accounts, 8); let coin_vault = account_at(accounts, 9); let pc_vault = account_at(accounts, 10); market_account = account_at(accounts, 14); let normalized = normalize_pair_with_vaults( coin_mint.clone(), pc_mint.clone(), coin_vault.clone(), pc_vault.clone(), ); token_a_mint = normalized.base_mint; token_b_mint = normalized.quote_mint; base_vault = normalized.base_vault; quote_vault = normalized.quote_vault; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); insert_optional_string(&mut payload, "coinMint", coin_mint); insert_optional_string(&mut payload, "pcMint", pc_mint); insert_optional_string(&mut payload, "baseVault", base_vault.clone()); insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); insert_optional_u8(&mut payload, "nonce", data.get(1).copied()); insert_optional_u64(&mut payload, "openTimeRaw", read_u64_le(data, 2)); }, "pre_initialize" => { pool_account = None; lp_mint = account_at(accounts, 5); let coin_mint = account_at(accounts, 6); let pc_mint = account_at(accounts, 7); let coin_vault = account_at(accounts, 8); let pc_vault = account_at(accounts, 9); market_account = account_at(accounts, 10); let normalized = normalize_pair_with_vaults( coin_mint.clone(), pc_mint.clone(), coin_vault.clone(), pc_vault.clone(), ); token_a_mint = normalized.base_mint; token_b_mint = normalized.quote_mint; base_vault = normalized.base_vault; quote_vault = normalized.quote_vault; insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); insert_optional_string(&mut payload, "coinMint", coin_mint); insert_optional_string(&mut payload, "pcMint", pc_mint); insert_optional_string(&mut payload, "baseVault", base_vault.clone()); insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); insert_optional_u8(&mut payload, "nonce", data.get(1).copied()); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_pre_initialize_partial_pool_context".to_string()), ); }, "deposit" => { pool_account = account_at(accounts, 1); lp_mint = account_at(accounts, 5); let coin_vault = account_at(accounts, 6); let pc_vault = account_at(accounts, 7); market_account = account_at(accounts, 9); let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); let normalized = normalize_pair_with_vaults( coin_mint.clone(), pc_mint.clone(), coin_vault.clone(), pc_vault.clone(), ); token_a_mint = normalized.base_mint; token_b_mint = normalized.quote_mint; base_vault = normalized.base_vault; quote_vault = normalized.quote_vault; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); insert_optional_string(&mut payload, "baseVault", base_vault.clone()); insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); insert_optional_u64_string(&mut payload, "maxCoinAmountRaw", read_u64_le(data, 1)); insert_optional_u64_string(&mut payload, "maxPcAmountRaw", read_u64_le(data, 9)); insert_optional_u64_string(&mut payload, "baseSideRaw", read_u64_le(data, 17)); }, "withdraw" => { pool_account = account_at(accounts, 1); lp_mint = account_at(accounts, 5); let coin_vault = account_at(accounts, 6); let pc_vault = account_at(accounts, 7); market_account = account_at(accounts, 10); let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); let normalized = normalize_pair_with_vaults( coin_mint.clone(), pc_mint.clone(), coin_vault.clone(), pc_vault.clone(), ); token_a_mint = normalized.base_mint; token_b_mint = normalized.quote_mint; base_vault = normalized.base_vault; quote_vault = normalized.quote_vault; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "lpMint", lp_mint.clone()); insert_optional_string(&mut payload, "baseVault", base_vault.clone()); insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); insert_optional_u64_string(&mut payload, "lpAmountRaw", read_u64_le(data, 1)); }, "init_model_data" | "update_model_data" => { pool_account = account_at(accounts, 0); market_account = None; lp_mint = None; token_a_mint = None; token_b_mint = None; base_vault = None; quote_vault = None; insert_optional_string(&mut payload, "modelDataAccount", pool_account.clone()); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), ); payload.insert( "skipLiquidityReason".to_string(), serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), ); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_model_data_instruction_decoded_only".to_string()), ); }, "monitor_step" | "admin_cancel_orders" => { pool_account = account_at(accounts, 1); market_account = account_at(accounts, 5); lp_mint = None; token_a_mint = None; token_b_mint = None; base_vault = None; quote_vault = None; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), ); payload.insert( "skipLiquidityReason".to_string(), serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), ); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_orderbook_instruction_decoded_only".to_string()), ); }, "set_params" => { pool_account = account_at(accounts, 1); market_account = None; lp_mint = None; token_a_mint = None; token_b_mint = None; base_vault = None; quote_vault = None; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), ); payload.insert( "skipLiquidityReason".to_string(), serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), ); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_admin_instruction_decoded_only".to_string()), ); }, "withdraw_pnl" | "withdraw_srm" => { pool_account = account_at(accounts, 1); market_account = account_at(accounts, 4); lp_mint = None; token_a_mint = None; token_b_mint = None; base_vault = None; quote_vault = None; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), ); payload.insert( "skipLiquidityReason".to_string(), serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), ); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_fee_instruction_decoded_only".to_string()), ); }, "simulate_info" => { pool_account = account_at(accounts, 1); market_account = None; lp_mint = None; token_a_mint = None; token_b_mint = None; base_vault = None; quote_vault = None; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), ); payload.insert( "skipLiquidityReason".to_string(), serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), ); payload.insert( "skipLifecycleReason".to_string(), serde_json::Value::String("stable_simulate_info_decoded_only".to_string()), ); }, "swap_base_in" | "swap_base_out" => { pool_account = account_at(accounts, 1); lp_mint = None; let coin_vault = account_at(accounts, 4); let pc_vault = account_at(accounts, 5); market_account = account_at(accounts, 8); let input_token_account = account_at(accounts, 15); let output_token_account = account_at(accounts, 16); let coin_mint = mint_for_account(coin_vault.as_deref(), token_mints_by_account); let pc_mint = mint_for_account(pc_vault.as_deref(), token_mints_by_account); let input_mint = mint_for_account(input_token_account.as_deref(), token_mints_by_account); let output_mint = mint_for_account(output_token_account.as_deref(), token_mints_by_account); let normalized = normalize_pair_with_vaults( coin_mint.clone(), pc_mint.clone(), coin_vault.clone(), pc_vault.clone(), ); token_a_mint = normalized.base_mint; token_b_mint = normalized.quote_mint; base_vault = normalized.base_vault; quote_vault = normalized.quote_vault; insert_optional_string(&mut payload, "poolAccount", pool_account.clone()); insert_optional_string(&mut payload, "baseVault", base_vault.clone()); insert_optional_string(&mut payload, "quoteVault", quote_vault.clone()); insert_optional_string(&mut payload, "marketAccount", market_account.clone()); insert_optional_string(&mut payload, "inputTokenAccount", input_token_account); insert_optional_string(&mut payload, "outputTokenAccount", output_token_account); insert_optional_string(&mut payload, "inputMint", input_mint.clone()); insert_optional_string(&mut payload, "outputMint", output_mint.clone()); let amount_in_or_max = read_u64_le(data, 1); let amount_out_or_min = read_u64_le(data, 9); if instruction_name == "swap_base_in" { insert_optional_u64_string(&mut payload, "amountInRaw", amount_in_or_max); insert_optional_u64_string(&mut payload, "minimumAmountOutRaw", amount_out_or_min); } else { insert_optional_u64_string(&mut payload, "maxAmountInRaw", amount_in_or_max); insert_optional_u64_string(&mut payload, "amountOutRaw", amount_out_or_min); } let instruction_amounts = map_swap_amounts( instruction_name, amount_in_or_max, amount_out_or_min, input_mint.as_deref(), output_mint.as_deref(), token_a_mint.as_deref(), token_b_mint.as_deref(), ); if let Some(instruction_amounts) = instruction_amounts { insert_optional_string( &mut payload, "instructionTradeSide", Some(instruction_amounts.trade_side.clone()), ); insert_optional_string( &mut payload, "instructionBoundBaseAmountRaw", Some(instruction_amounts.base_amount_raw), ); insert_optional_string( &mut payload, "instructionBoundQuoteAmountRaw", Some(instruction_amounts.quote_amount_raw), ); } let exact_amounts = resolve_stable_swap_vault_delta_amounts( base_vault.as_deref(), quote_vault.as_deref(), token_balance_records, ); match exact_amounts { Some(exact_amounts) => { insert_optional_string( &mut payload, "tradeSide", Some(exact_amounts.trade_side), ); insert_optional_string( &mut payload, "baseAmountRaw", Some(exact_amounts.base_amount_raw), ); insert_optional_string( &mut payload, "quoteAmountRaw", Some(exact_amounts.quote_amount_raw), ); payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(true)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(true)); payload.insert( "amountSource".to_string(), serde_json::Value::String("stable_swap_vault_balance_delta".to_string()), ); payload.insert("skipTradeReason".to_string(), serde_json::Value::Null); payload.insert("skipCandleReason".to_string(), serde_json::Value::Null); }, None => { payload.insert("tradeCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert("candleCandidate".to_string(), serde_json::Value::Bool(false)); payload.insert( "amountSource".to_string(), serde_json::Value::String( "stable_swap_instruction_bounds_only".to_string(), ), ); payload.insert( "skipTradeReason".to_string(), serde_json::Value::String( "stable_swap_exact_amounts_unresolved".to_string(), ), ); payload.insert( "skipCandleReason".to_string(), serde_json::Value::String( "stable_swap_exact_amounts_unresolved".to_string(), ), ); }, } }, _ => return None, } insert_optional_string(&mut payload, "tokenAMint", token_a_mint.clone()); insert_optional_string(&mut payload, "tokenBMint", token_b_mint.clone()); return Some(RaydiumStableSwapInstructionDecoded { transaction_id, instruction_id, signature: signature.to_string(), program_id: program_id.to_string(), event_kind, instruction_name: instruction_name.to_string(), discriminator_hex, pool_account, market_account, lp_mint, token_a_mint, token_b_mint, base_vault, quote_vault, payload_json: serde_json::Value::Object(payload), }); } fn stable_instruction_name(discriminator: u8) -> std::option::Option<&'static str> { match discriminator { 0 => return Some("initialize"), 1 => return Some("init_model_data"), 2 => return Some("update_model_data"), 3 => return Some("deposit"), 4 => return Some("withdraw"), 5 => return Some("monitor_step"), 6 => return Some("set_params"), 7 => return Some("withdraw_pnl"), 8 => return Some("withdraw_srm"), 9 => return Some("swap_base_in"), 10 => return Some("pre_initialize"), 11 => return Some("swap_base_out"), 12 => return Some("simulate_info"), 13 => return Some("admin_cancel_orders"), _ => return None, } } fn stable_instruction_discriminator_hex(discriminator: u8) -> std::option::Option<&'static str> { match discriminator { 0 => return Some(RAYDIUM_STABLE_SWAP_INITIALIZE_DISCRIMINATOR_HEX), 1 => return Some(RAYDIUM_STABLE_SWAP_INIT_MODEL_DATA_DISCRIMINATOR_HEX), 2 => return Some(RAYDIUM_STABLE_SWAP_UPDATE_MODEL_DATA_DISCRIMINATOR_HEX), 3 => return Some(RAYDIUM_STABLE_SWAP_DEPOSIT_DISCRIMINATOR_HEX), 4 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_DISCRIMINATOR_HEX), 5 => return Some(RAYDIUM_STABLE_SWAP_MONITOR_STEP_DISCRIMINATOR_HEX), 6 => return Some(RAYDIUM_STABLE_SWAP_SET_PARAMS_DISCRIMINATOR_HEX), 7 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_PNL_DISCRIMINATOR_HEX), 8 => return Some(RAYDIUM_STABLE_SWAP_WITHDRAW_SRM_DISCRIMINATOR_HEX), 9 => return Some(RAYDIUM_STABLE_SWAP_SWAP_BASE_IN_DISCRIMINATOR_HEX), 10 => return Some(RAYDIUM_STABLE_SWAP_PRE_INITIALIZE_DISCRIMINATOR_HEX), 11 => return Some(RAYDIUM_STABLE_SWAP_SWAP_BASE_OUT_DISCRIMINATOR_HEX), 12 => return Some(RAYDIUM_STABLE_SWAP_SIMULATE_INFO_DISCRIMINATOR_HEX), 13 => return Some(RAYDIUM_STABLE_SWAP_ADMIN_CANCEL_ORDERS_DISCRIMINATOR_HEX), _ => return None, } } #[derive(Debug, Clone)] struct NormalizedVaultPair { base_mint: std::option::Option, quote_mint: std::option::Option, base_vault: std::option::Option, quote_vault: std::option::Option, } fn normalize_pair_with_vaults( coin_mint: std::option::Option, pc_mint: std::option::Option, coin_vault: std::option::Option, pc_vault: std::option::Option, ) -> NormalizedVaultPair { let coin_mint_ref = match coin_mint.as_ref() { Some(coin_mint) => coin_mint.as_str(), None => { return NormalizedVaultPair { base_mint: None, quote_mint: None, base_vault: None, quote_vault: None, }; }, }; let pc_mint_ref = match pc_mint.as_ref() { Some(pc_mint) => pc_mint.as_str(), None => { return NormalizedVaultPair { base_mint: None, quote_mint: None, base_vault: None, quote_vault: None, }; }, }; let coin_is_quote = is_quote_mint(coin_mint_ref); let pc_is_quote = is_quote_mint(pc_mint_ref); let pc_is_normalized_quote = if pc_is_quote && !coin_is_quote { true } else if coin_is_quote && !pc_is_quote { false } else { pc_mint_ref <= coin_mint_ref }; if pc_is_normalized_quote { return NormalizedVaultPair { base_mint: coin_mint, quote_mint: pc_mint, base_vault: coin_vault, quote_vault: pc_vault, }; } return NormalizedVaultPair { base_mint: pc_mint, quote_mint: coin_mint, base_vault: pc_vault, quote_vault: coin_vault, }; } fn is_quote_mint(mint: &str) -> bool { return mint == crate::WSOL_MINT_ID || mint == crate::USDC_MINT_ID || mint == crate::USDT_MINT_ID; } #[derive(Debug, Clone)] struct StableSwapAmounts { trade_side: std::string::String, base_amount_raw: std::string::String, quote_amount_raw: std::string::String, } fn map_swap_amounts( instruction_name: &str, first_amount: std::option::Option, second_amount: std::option::Option, input_mint: std::option::Option<&str>, output_mint: std::option::Option<&str>, base_mint: std::option::Option<&str>, quote_mint: std::option::Option<&str>, ) -> std::option::Option { let first_amount = match first_amount { Some(first_amount) => first_amount, None => return None, }; let second_amount = match second_amount { Some(second_amount) => second_amount, None => return None, }; let input_mint = match input_mint { Some(input_mint) => input_mint, None => return None, }; if output_mint.is_none() { return None; } let base_mint = match base_mint { Some(base_mint) => base_mint, None => return None, }; let quote_mint = match quote_mint { Some(quote_mint) => quote_mint, None => return None, }; if instruction_name == "swap_base_in" { if input_mint == base_mint { return Some(StableSwapAmounts { trade_side: "sell".to_string(), base_amount_raw: first_amount.to_string(), quote_amount_raw: second_amount.to_string(), }); } if input_mint == quote_mint { return Some(StableSwapAmounts { trade_side: "buy".to_string(), base_amount_raw: second_amount.to_string(), quote_amount_raw: first_amount.to_string(), }); } } if instruction_name == "swap_base_out" { if input_mint == base_mint { return Some(StableSwapAmounts { trade_side: "sell".to_string(), base_amount_raw: first_amount.to_string(), quote_amount_raw: second_amount.to_string(), }); } if input_mint == quote_mint { return Some(StableSwapAmounts { trade_side: "buy".to_string(), base_amount_raw: second_amount.to_string(), quote_amount_raw: first_amount.to_string(), }); } } return None; } #[derive(Debug, Clone)] struct StableSwapExactAmounts { trade_side: std::string::String, base_amount_raw: std::string::String, quote_amount_raw: std::string::String, } #[derive(Debug, Clone)] struct TokenBalanceRecord { account_address: std::option::Option, pre_amount_raw: std::option::Option, post_amount_raw: std::option::Option, } #[derive(Debug, Clone)] struct TokenBalanceAccumulator { account_index: std::option::Option, account_address: std::option::Option, mint: std::string::String, pre_amount_raw: std::option::Option, post_amount_raw: std::option::Option, } impl TokenBalanceRecord { fn delta_raw(&self) -> std::option::Option { let pre = parse_i128_or_zero(self.pre_amount_raw.as_deref()); let post = parse_i128_or_zero(self.post_amount_raw.as_deref()); match (pre, post) { (Some(pre), Some(post)) => return Some(post - pre), _ => return None, } } } fn resolve_stable_swap_vault_delta_amounts( base_vault: std::option::Option<&str>, quote_vault: std::option::Option<&str>, token_balance_records: &[TokenBalanceRecord], ) -> std::option::Option { let base_vault = match base_vault { Some(base_vault) => base_vault, None => return None, }; let quote_vault = match quote_vault { Some(quote_vault) => quote_vault, None => return None, }; let base_record = token_balance_record_for_account(token_balance_records, base_vault); let base_record = match base_record { Some(base_record) => base_record, None => return None, }; let quote_record = token_balance_record_for_account(token_balance_records, quote_vault); let quote_record = match quote_record { Some(quote_record) => quote_record, None => return None, }; let base_delta = match base_record.delta_raw() { Some(base_delta) => base_delta, None => return None, }; let quote_delta = match quote_record.delta_raw() { Some(quote_delta) => quote_delta, None => return None, }; if base_delta == 0 || quote_delta == 0 { return None; } if base_delta < 0 && quote_delta > 0 { return Some(StableSwapExactAmounts { trade_side: "buy".to_string(), base_amount_raw: (-base_delta).to_string(), quote_amount_raw: quote_delta.to_string(), }); } if base_delta > 0 && quote_delta < 0 { return Some(StableSwapExactAmounts { trade_side: "sell".to_string(), base_amount_raw: base_delta.to_string(), quote_amount_raw: (-quote_delta).to_string(), }); } return None; } fn token_balance_record_for_account<'a>( token_balance_records: &'a [TokenBalanceRecord], account: &str, ) -> std::option::Option<&'a TokenBalanceRecord> { for record in token_balance_records { if record.account_address.as_deref() == Some(account) { return Some(record); } } return None; } fn parse_i128_or_zero(value: std::option::Option<&str>) -> std::option::Option { let value = match value { Some(value) => value.trim(), None => return Some(0), }; if value.is_empty() { return Some(0); } let parsed = value.parse::(); match parsed { Ok(parsed) => return Some(parsed), Err(_) => return None, } } fn parse_accounts_json( accounts_json: &str, ) -> std::option::Option> { let value = match serde_json::from_str::(accounts_json) { Ok(value) => value, Err(_) => return None, }; let array = match value.as_array() { Some(array) => array, None => return None, }; let mut accounts = std::vec::Vec::new(); for item in array { if let Some(text) = item.as_str() { accounts.push(text.to_string()); } else if let Some(pubkey) = item.get("pubkey").and_then(|value| return value.as_str()) { accounts.push(pubkey.to_string()); } } return Some(accounts); } fn decode_data_json_base58(data_json: &str) -> std::option::Option> { let value = match serde_json::from_str::(data_json) { Ok(value) => value, Err(_) => return None, }; let data_base58 = if let Some(text) = value.as_str() { text.to_string() } else if let Some(text) = value.get("data").and_then(|value| return value.as_str()) { text.to_string() } else { return None; }; let decoded = bs58::decode(data_base58.as_str()).into_vec(); match decoded { Ok(decoded) => return Some(decoded), Err(_) => return None, } } fn account_at( accounts: &[std::string::String], index: usize, ) -> std::option::Option { return accounts.get(index).cloned(); } fn read_u64_le(data: &[u8], offset: usize) -> std::option::Option { let end = offset.saturating_add(8); if data.len() < end { return None; } let bytes = [ data[offset], data[offset + 1], data[offset + 2], data[offset + 3], data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7], ]; return Some(u64::from_le_bytes(bytes)); } fn parse_json_value(input: &str) -> std::option::Option { let parsed = serde_json::from_str::(input); match parsed { Ok(parsed) => return Some(parsed), Err(_) => return None, } } fn parse_optional_json_value( input: std::option::Option<&str>, ) -> std::option::Option { let input = match input { Some(input) => input, None => return None, }; return parse_json_value(input); } fn extract_transaction_account_keys( transaction_json: std::option::Option<&serde_json::Value>, meta_json: std::option::Option<&serde_json::Value>, ) -> std::vec::Vec { let mut account_keys = std::vec::Vec::new(); let message = transaction_json .and_then(|value| return value.get("transaction")) .and_then(|value| return value.get("message")); if let Some(message) = message { extract_account_keys_from_message(message, &mut account_keys); } if account_keys.is_empty() { let message = transaction_json.and_then(|value| return value.get("message")); if let Some(message) = message { extract_account_keys_from_message(message, &mut account_keys); } } if account_keys.is_empty() { let loaded = meta_json.and_then(|value| return value.get("loadedAddresses")); if let Some(loaded) = loaded { append_loaded_addresses(loaded, &mut account_keys); } } return account_keys; } fn extract_account_keys_from_message( message: &serde_json::Value, account_keys: &mut std::vec::Vec, ) { let keys = match message.get("accountKeys").and_then(|value| return value.as_array()) { Some(keys) => keys, None => return, }; for key in keys { if let Some(text) = key.as_str() { account_keys.push(text.to_string()); } else if let Some(pubkey) = key.get("pubkey").and_then(|value| return value.as_str()) { account_keys.push(pubkey.to_string()); } } if let Some(loaded) = message.get("loadedAddresses") { append_loaded_addresses(loaded, account_keys); } } fn append_loaded_addresses( loaded: &serde_json::Value, account_keys: &mut std::vec::Vec, ) { for section in ["writable", "readonly"] { let array = loaded.get(section).and_then(|value| return value.as_array()); let array = match array { Some(array) => array, None => continue, }; for item in array { if let Some(text) = item.as_str() { account_keys.push(text.to_string()); } } } } fn extract_token_balance_records( transaction_json: std::option::Option<&serde_json::Value>, meta_json: std::option::Option<&serde_json::Value>, account_keys: &[std::string::String], ) -> std::vec::Vec { let mut accumulators = std::vec::Vec::new(); let meta_candidates = [ meta_json, transaction_json.and_then(|value| return value.get("meta")), transaction_json .and_then(|value| return value.get("transaction")) .and_then(|value| return value.get("meta")), ]; for meta in meta_candidates.iter().flatten() { collect_token_balance_side( meta.get("preTokenBalances"), account_keys, true, &mut accumulators, ); collect_token_balance_side( meta.get("postTokenBalances"), account_keys, false, &mut accumulators, ); } let mut records = std::vec::Vec::new(); for accumulator in accumulators { records.push(TokenBalanceRecord { account_address: accumulator.account_address, pre_amount_raw: accumulator.pre_amount_raw, post_amount_raw: accumulator.post_amount_raw, }); } return records; } fn collect_token_balance_side( balances: std::option::Option<&serde_json::Value>, account_keys: &[std::string::String], is_pre: bool, accumulators: &mut std::vec::Vec, ) { let array = balances.and_then(|value| return value.as_array()); let array = match array { Some(array) => array, None => return, }; for balance in array { let account_index = balance.get("accountIndex").and_then(|value| return value.as_u64()); let mint = match balance.get("mint").and_then(|value| return value.as_str()) { Some(mint) => mint.to_string(), None => continue, }; let amount = balance .get("uiTokenAmount") .and_then(|value| return value.get("amount")) .and_then(|value| return value.as_str()) .map(|value| return value.to_string()); let account_address = account_address_by_index(account_keys, account_index); let accumulator_index = find_token_balance_accumulator(accumulators.as_slice(), account_index, mint.as_str()); let index = match accumulator_index { Some(index) => index, None => { accumulators.push(TokenBalanceAccumulator { account_index, account_address, mint, pre_amount_raw: None, post_amount_raw: None, }); accumulators.len() - 1 }, }; if is_pre { accumulators[index].pre_amount_raw = amount; } else { accumulators[index].post_amount_raw = amount; } } } fn find_token_balance_accumulator( accumulators: &[TokenBalanceAccumulator], account_index: std::option::Option, mint: &str, ) -> std::option::Option { let mut index = 0usize; while index < accumulators.len() { let accumulator = &accumulators[index]; if accumulator.account_index == account_index && accumulator.mint == mint { return Some(index); } index += 1; } return None; } fn account_address_by_index( account_keys: &[std::string::String], account_index: std::option::Option, ) -> std::option::Option { let account_index = match account_index { Some(account_index) => account_index, None => return None, }; let index = match usize::try_from(account_index) { Ok(index) => index, Err(_) => return None, }; return account_keys.get(index).cloned(); } fn extract_token_mints_by_account( transaction_json: std::option::Option<&serde_json::Value>, meta_json: std::option::Option<&serde_json::Value>, account_keys: &[std::string::String], ) -> std::collections::BTreeMap { let mut map = std::collections::BTreeMap::new(); let meta_candidates = [ meta_json, transaction_json.and_then(|value| return value.get("meta")), transaction_json .and_then(|value| return value.get("transaction")) .and_then(|value| return value.get("meta")), ]; for meta in meta_candidates.iter().flatten() { append_token_balance_mints(meta.get("preTokenBalances"), account_keys, &mut map); append_token_balance_mints(meta.get("postTokenBalances"), account_keys, &mut map); } return map; } fn append_token_balance_mints( balances: std::option::Option<&serde_json::Value>, account_keys: &[std::string::String], map: &mut std::collections::BTreeMap, ) { let array = balances.and_then(|value| return value.as_array()); let array = match array { Some(array) => array, None => return, }; for balance in array { let account_index = balance.get("accountIndex").and_then(|value| return value.as_u64()); let mint = balance.get("mint").and_then(|value| return value.as_str()); let owner = balance.get("owner").and_then(|value| return value.as_str()); let account_index = match account_index { Some(account_index) => account_index, None => continue, }; let mint = match mint { Some(mint) => mint, None => continue, }; let index_result = usize::try_from(account_index); let index = match index_result { Ok(index) => index, Err(_) => continue, }; if let Some(account) = account_keys.get(index) { map.insert(account.clone(), mint.to_string()); } if let Some(owner) = owner { map.insert(owner.to_string(), mint.to_string()); } } } fn mint_for_account( account: std::option::Option<&str>, token_mints_by_account: &std::collections::BTreeMap, ) -> std::option::Option { let account = match account { Some(account) => account, None => return None, }; return token_mints_by_account.get(account).cloned(); } fn insert_optional_string( object: &mut serde_json::Map, key: &str, value: std::option::Option, ) { if let Some(value) = value { object.insert(key.to_string(), serde_json::Value::String(value)); } } fn insert_optional_u8( object: &mut serde_json::Map, key: &str, value: std::option::Option, ) { if let Some(value) = value { object.insert(key.to_string(), serde_json::Value::Number(serde_json::Number::from(value))); } } fn insert_optional_u64( object: &mut serde_json::Map, key: &str, value: std::option::Option, ) { if let Some(value) = value { object.insert(key.to_string(), serde_json::Value::Number(serde_json::Number::from(value))); } } fn insert_optional_u64_string( object: &mut serde_json::Map, key: &str, value: std::option::Option, ) { if let Some(value) = value { object.insert(key.to_string(), serde_json::Value::String(value.to_string())); } } fn bytes_to_hex(data: &[u8]) -> std::string::String { let mut output = std::string::String::new(); for byte in data { output.push_str(format!("{:02x}", byte).as_str()); } return output; } #[cfg(test)] mod tests { fn encode_data(data: &[u8]) -> std::string::String { return bs58::encode(data).into_string(); } #[test] fn classifies_one_byte_stable_swap_discriminators() { let initialize = serde_json::json!(encode_data(&[0_u8, 1_u8, 0, 0, 0, 0, 0, 0, 0, 0])); let deposit = serde_json::json!(encode_data(&[ 3_u8, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ])); let initialize_text = initialize.to_string(); let deposit_text = deposit.to_string(); assert_eq!( super::classify_raydium_stable_swap_instruction_data(initialize_text.as_str()), Some("initialize") ); assert_eq!( super::classify_raydium_stable_swap_instruction_data(deposit_text.as_str()), Some("deposit") ); } #[test] fn decodes_stable_swap_initialize_with_normalized_pair() { let accounts = serde_json::json!([ "TokenProgram1111111111111111111111111111111111", "System111111111111111111111111111111111111", "Rent111111111111111111111111111111111111111", "StablePool11111111111111111111111111111111111", "Authority111111111111111111111111111111111111", "OpenOrders1111111111111111111111111111111111", "LpMint11111111111111111111111111111111111111", crate::USDT_MINT_ID, crate::USDC_MINT_ID, "CoinVault1111111111111111111111111111111111", "PcVault11111111111111111111111111111111111", "TargetOrders11111111111111111111111111111111", "ModelData1111111111111111111111111111111111", "SerumProgram111111111111111111111111111111", "SerumMarket1111111111111111111111111111111", "UserLp111111111111111111111111111111111111", "Wallet111111111111111111111111111111111111" ]); let mut data = vec![0_u8, 1_u8]; data.extend_from_slice(&123_u64.to_le_bytes()); let events = super::decode_raydium_stable_swap_instruction_with_context( 1, 2, "sig", crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID, accounts.to_string().as_str(), Some(serde_json::json!(encode_data(data.as_slice())).to_string().as_str()), &std::collections::BTreeMap::new(), &[], ); assert_eq!(events.len(), 1); match &events[0] { crate::RaydiumStableSwapDecodedEvent::Instruction(event) => { assert_eq!(event.event_kind, "raydium_stable_swap.initialize"); assert_eq!( event.pool_account.as_deref(), Some("StablePool11111111111111111111111111111111111") ); assert_eq!(event.token_b_mint.as_deref(), Some(crate::USDC_MINT_ID)); }, _ => panic!("expected instruction event"), } } }