From 7ae961ea6605dd5ae5f85bde83212090c70088cc Mon Sep 17 00:00:00 2001 From: Nemmie Date: Mon, 16 Feb 2026 17:37:50 -0600 Subject: [PATCH 1/2] feat: Add MnM DLMM adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Jupiter AMM interface adapter for MnM DLMM (Dynamic Liquidity Market Maker). ## What is MnM DLMM? A concentrated liquidity AMM with bin shifting — bins automatically reposition with trades to keep liquidity around the active price. Program ID: MnMRzPXhhuFFzfXvffkMGeodBqY7hnaqNpQcYGeREi5 ## Implementation - Self-contained mnm-dlmm crate with quote engine, state deserialization, and PDA derivation - Implements Amm trait: from_keyed_account, quote, get_swap_and_account_metas - Cross-BinArray quoting for large trades - Token-2022 support (SPL Token and Token-2022 mints) - Dynamic accounts (BinArray PDAs shift with active bin) - 12 swap instruction accounts ## Features - 5 fee tiers (1bps-100bps) - Bin shifting (auto liquidity repositioning) - Q64.64 fixed-point math for precise pricing ## Security - 260M+ fuzz iterations on core swap logic - 48/48 on-chain program tests passing - Internal security audit completed ## Note Uses Swap::MeteoraDlmm as placeholder — needs new Swap::MnmDlmm variant in jupiter-amm-interface. Team: MnM Labs (https://mnm.ag) --- Cargo.lock | 13 + Cargo.toml | 3 +- jupiter-core/Cargo.toml | 1 + .../src/amms/amm_program_id_to_labels.rs | 2 + jupiter-core/src/amms/mod.rs | 3 + mnm-dlmm/Cargo.toml | 14 + mnm-dlmm/src/lib.rs | 299 ++++++++++++++++++ mnm-dlmm/src/pda.rs | 23 ++ mnm-dlmm/src/quote.rs | 228 +++++++++++++ mnm-dlmm/src/state.rs | 86 +++++ 10 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 mnm-dlmm/Cargo.toml create mode 100644 mnm-dlmm/src/lib.rs create mode 100644 mnm-dlmm/src/pda.rs create mode 100644 mnm-dlmm/src/quote.rs create mode 100644 mnm-dlmm/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index b8fe9ad..1427275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2121,6 +2121,7 @@ dependencies = [ "lazy_static", "litesvm", "log", + "mnm-dlmm", "paste", "program-interfaces", "regex", @@ -2383,6 +2384,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mnm-dlmm" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh 0.10.4", + "bytemuck", + "jupiter-amm-interface", + "rust_decimal", + "solana-sdk", +] + [[package]] name = "num" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c8b7091..0d252f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "jupiter-core", "program-interfaces", - "jupiter-aggregator-v6" + "jupiter-aggregator-v6", + "mnm-dlmm", ] resolver = "2" diff --git a/jupiter-core/Cargo.toml b/jupiter-core/Cargo.toml index 7e1ab02..de17882 100644 --- a/jupiter-core/Cargo.toml +++ b/jupiter-core/Cargo.toml @@ -46,3 +46,4 @@ spl-token-2022 = { workspace = true, features = ["no-entrypoint"] } regex = "1.11.1" ahash = "0.8.12" agave-feature-set = "2.2" +mnm-dlmm = { path = "../mnm-dlmm" } diff --git a/jupiter-core/src/amms/amm_program_id_to_labels.rs b/jupiter-core/src/amms/amm_program_id_to_labels.rs index a208d0c..5a4c8a4 100644 --- a/jupiter-core/src/amms/amm_program_id_to_labels.rs +++ b/jupiter-core/src/amms/amm_program_id_to_labels.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::sync::LazyLock; use crate::amms::spl_token_swap_amm::SplTokenSwapAmm; +use mnm_dlmm::MnmDlmmAmm; type AmmFromKeyedAccount = Box Result> + Send + Sync>; @@ -30,6 +31,7 @@ pub static PROGRAM_ID_TO_AMM_LABEL_WITH_AMM_FROM_KEYED_ACCOUNT: LazyLock< let mut m = HashMap::new(); m.extend(create_entries_for_amm::()); + m.extend(create_entries_for_amm::()); m }); diff --git a/jupiter-core/src/amms/mod.rs b/jupiter-core/src/amms/mod.rs index b1d6254..d3e8094 100644 --- a/jupiter-core/src/amms/mod.rs +++ b/jupiter-core/src/amms/mod.rs @@ -5,3 +5,6 @@ pub mod test_harness; mod amm_program_id_to_labels; pub mod loader; + +// Re-export MnM DLMM adapter +pub use mnm_dlmm::MnmDlmmAmm; diff --git a/mnm-dlmm/Cargo.toml b/mnm-dlmm/Cargo.toml new file mode 100644 index 0000000..27fc0cd --- /dev/null +++ b/mnm-dlmm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mnm-dlmm" +version = "0.1.0" +edition.workspace = true +description = "Jupiter AMM adapter for MnM DLMM protocol" +license = "Apache-2.0" + +[dependencies] +jupiter-amm-interface = { workspace = true } +solana-sdk = { workspace = true } +bytemuck = { workspace = true } +borsh = { workspace = true } +anyhow = { workspace = true } +rust_decimal = "1.36" diff --git a/mnm-dlmm/src/lib.rs b/mnm-dlmm/src/lib.rs new file mode 100644 index 0000000..a0eb53e --- /dev/null +++ b/mnm-dlmm/src/lib.rs @@ -0,0 +1,299 @@ +//! Jupiter AMM adapter for MnM DLMM (Dynamic Liquidity Market Maker). +//! +//! MnM DLMM is a concentrated liquidity AMM with bin shifting — bins +//! automatically reposition with trades to keep liquidity around the active price. +//! +//! Program ID: MnMRzPXhhuFFzfXvffkMGeodBqY7hnaqNpQcYGeREi5 + +mod pda; +mod quote; +mod state; + +use anyhow::{Result, anyhow}; +use borsh::BorshSerialize; +use jupiter_amm_interface::{ + Amm, AccountMap, AmmContext, AmmLabel, KeyedAccount, Quote, QuoteParams, + SingleProgramAmm, Swap, SwapAndAccountMetas, SwapMode, SwapParams, + try_get_account_data, +}; +use rust_decimal::Decimal; +use solana_sdk::{ + instruction::AccountMeta, + pubkey::Pubkey, + pubkey, +}; + +use pda::{find_tick_map, find_bin_array, find_vault_x, find_vault_y}; +use quote::{QuoteInput, SwapDirection, simulate_swap_multi}; +use state::{BinArray, PoolState, BINS_PER_ARRAY}; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/// MnM DLMM program ID (mainnet). +pub const MNM_PROGRAM_ID: Pubkey = pubkey!("MnMRzPXhhuFFzfXvffkMGeodBqY7hnaqNpQcYGeREi5"); + +/// SPL Token program ID. +const SPL_TOKEN_PROGRAM: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/// SPL Token-2022 program ID. +const SPL_TOKEN_2022_PROGRAM: Pubkey = pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); + +/// Adjacent BinArrays to fetch for quoting (active ± 1). +const BIN_ARRAYS_RADIUS: i32 = 1; + +// ─── Swap instruction args (for reference/future use) ─────────────────────── + +/// Anchor sighash for `global:swap`. +/// SHA-256("global:swap")[0..8] = [248, 198, 158, 145, 225, 117, 135, 200] +#[allow(dead_code)] +const SWAP_DISCRIMINATOR: [u8; 8] = [0xf8, 0xc6, 0x9e, 0x91, 0xe1, 0x75, 0x87, 0xc8]; + +#[derive(Clone, Debug, BorshSerialize)] +#[allow(dead_code)] +struct SwapArgs { + amount_in: u64, + min_amount_out: u64, + swap_x_to_y: bool, +} + +// ─── MnM DLMM AMM ────────────────────────────────────────────────────────── + +/// MnM DLMM pool adapter for Jupiter's routing engine. +#[derive(Clone)] +pub struct MnmDlmmAmm { + key: Pubkey, + pool: PoolState, + mint_x: Pubkey, + mint_y: Pubkey, + vault_x: Pubkey, + vault_y: Pubkey, + tick_map: Pubkey, + token_program_x: Pubkey, + token_program_y: Pubkey, + bin_array_keys: Vec, + bin_arrays: Vec, + is_paused: bool, +} + +impl SingleProgramAmm for MnmDlmmAmm { + const PROGRAM_ID: Pubkey = MNM_PROGRAM_ID; + const LABEL: AmmLabel = "MnM DLMM"; +} + +impl MnmDlmmAmm { + fn bytes_to_pubkey(bytes: &[u8; 32]) -> Pubkey { + Pubkey::new_from_array(*bytes) + } + + fn compute_bin_array_keys(pool_key: &Pubkey, active_bin_id: i32) -> Vec { + let active_array_idx = active_bin_id.div_euclid(BINS_PER_ARRAY as i32); + let mut keys = Vec::with_capacity((2 * BIN_ARRAYS_RADIUS + 1) as usize); + for offset in -BIN_ARRAYS_RADIUS..=BIN_ARRAYS_RADIUS { + let (pda, _) = find_bin_array(pool_key, active_array_idx + offset); + keys.push(pda); + } + keys + } + + fn resolve_token_program(bytes: &[u8; 32]) -> Pubkey { + let pk = Self::bytes_to_pubkey(bytes); + if pk == SPL_TOKEN_2022_PROGRAM { + SPL_TOKEN_2022_PROGRAM + } else { + SPL_TOKEN_PROGRAM + } + } +} + +impl Amm for MnmDlmmAmm { + fn from_keyed_account(keyed_account: &KeyedAccount, _amm_context: &AmmContext) -> Result { + let data = &keyed_account.account.data; + + let pool: PoolState = state::deserialize(data) + .ok_or_else(|| anyhow!("Failed to deserialize MnM PoolState (data len: {})", data.len()))?; + + let mint_x = Self::bytes_to_pubkey(&pool.token_mint_x); + let mint_y = Self::bytes_to_pubkey(&pool.token_mint_y); + let (vault_x, _) = find_vault_x(&keyed_account.key); + let (vault_y, _) = find_vault_y(&keyed_account.key); + let (tick_map, _) = find_tick_map(&keyed_account.key); + let token_program_x = Self::resolve_token_program(&pool.token_program_x); + let token_program_y = Self::resolve_token_program(&pool.token_program_y); + let bin_array_keys = Self::compute_bin_array_keys(&keyed_account.key, pool.active_bin_id); + let is_paused = pool.is_paused != 0; + + Ok(Self { + key: keyed_account.key, + pool, + mint_x, + mint_y, + vault_x, + vault_y, + tick_map, + token_program_x, + token_program_y, + bin_array_keys, + bin_arrays: Vec::new(), + is_paused, + }) + } + + fn label(&self) -> String { + "MnM DLMM".to_string() + } + + fn program_id(&self) -> Pubkey { + MNM_PROGRAM_ID + } + + fn key(&self) -> Pubkey { + self.key + } + + fn get_reserve_mints(&self) -> Vec { + vec![self.mint_x, self.mint_y] + } + + fn get_accounts_to_update(&self) -> Vec { + let mut accounts = Vec::with_capacity(1 + self.bin_array_keys.len()); + accounts.push(self.key); + accounts.extend_from_slice(&self.bin_array_keys); + accounts + } + + fn update(&mut self, account_map: &AccountMap) -> Result<()> { + // Refresh pool state (active bin may have shifted) + let pool_data = try_get_account_data(account_map, &self.key)?; + if let Some(updated_pool) = state::deserialize::(pool_data) { + if updated_pool.active_bin_id != self.pool.active_bin_id { + self.bin_array_keys = Self::compute_bin_array_keys(&self.key, updated_pool.active_bin_id); + } + self.pool = updated_pool; + self.is_paused = updated_pool.is_paused != 0; + } + + // Load BinArrays for quoting + self.bin_arrays.clear(); + for ba_key in &self.bin_array_keys { + if let Ok(ba_data) = try_get_account_data(account_map, ba_key) { + if let Some(ba) = state::deserialize::(ba_data) { + self.bin_arrays.push(ba); + } + } + // Missing BinArrays are OK — pool may not have liquidity in those ranges + } + + Ok(()) + } + + fn quote(&self, quote_params: &QuoteParams) -> Result { + if self.is_paused { + return Err(anyhow!("MnM pool is paused")); + } + if quote_params.swap_mode == SwapMode::ExactOut { + return Err(anyhow!("MnM DLMM does not support ExactOut mode")); + } + if self.bin_arrays.is_empty() { + return Err(anyhow!("No BinArrays loaded — call update() first")); + } + + let direction = if quote_params.input_mint == self.mint_x { + SwapDirection::XtoY + } else if quote_params.input_mint == self.mint_y { + SwapDirection::YtoX + } else { + return Err(anyhow!( + "Input mint {} doesn't match pool mints ({} or {})", + quote_params.input_mint, self.mint_x, self.mint_y + )); + }; + + let input = QuoteInput { + amount_in: quote_params.amount, + direction, + }; + + let ba_refs: Vec<&BinArray> = self.bin_arrays.iter().collect(); + let result = simulate_swap_multi(&self.pool, &ba_refs, &input) + .ok_or_else(|| anyhow!("Swap simulation returned None"))?; + + if result.out_amount == 0 { + return Err(anyhow!("No liquidity available for this swap")); + } + + Ok(Quote { + in_amount: result.in_amount, + out_amount: result.out_amount, + fee_amount: result.fee_amount, + fee_mint: quote_params.input_mint, + fee_pct: Decimal::from_f64_retain(result.fee_pct).unwrap_or(Decimal::ZERO), + }) + } + + fn get_swap_and_account_metas(&self, swap_params: &SwapParams) -> Result { + let swap_x_to_y = swap_params.source_mint == self.mint_x; + + let active_array_idx = self.pool.active_bin_id.div_euclid(BINS_PER_ARRAY as i32); + let (active_bin_array_pda, _) = find_bin_array(&self.key, active_array_idx); + + let (token_program_in, token_program_out) = if swap_x_to_y { + (self.token_program_x, self.token_program_y) + } else { + (self.token_program_y, self.token_program_x) + }; + + // 0. pool (mut) + // 1. tick_map (mut) + // 2. bin_array (mut) + // 3. token_mint_x (read) + // 4. token_mint_y (read) + // 5. token_vault_x (mut) + // 6. token_vault_y (mut) + // 7. user_token_in (mut) ← source_token_account + // 8. user_token_out (mut) ← destination_token_account + // 9. user (signer) ← token_transfer_authority + // 10. token_program_in (read) + // 11. token_program_out (read) + let account_metas = vec![ + AccountMeta::new(self.key, false), + AccountMeta::new(self.tick_map, false), + AccountMeta::new(active_bin_array_pda, false), + AccountMeta::new_readonly(self.mint_x, false), + AccountMeta::new_readonly(self.mint_y, false), + AccountMeta::new(self.vault_x, false), + AccountMeta::new(self.vault_y, false), + AccountMeta::new(swap_params.source_token_account, false), + AccountMeta::new(swap_params.destination_token_account, false), + AccountMeta::new_readonly(swap_params.token_transfer_authority, true), + AccountMeta::new_readonly(token_program_in, false), + AccountMeta::new_readonly(token_program_out, false), + ]; + + // TODO: Requires new Swap::MnmDlmm variant in jupiter-amm-interface. + // Using MeteoraDlmm as structural placeholder — account layout is compatible. + Ok(SwapAndAccountMetas { + swap: Swap::MeteoraDlmm, + account_metas, + }) + } + + fn has_dynamic_accounts(&self) -> bool { + true // BinArray PDAs change as active bin moves + } + + fn supports_exact_out(&self) -> bool { + false + } + + fn clone_amm(&self) -> Box { + Box::new(self.clone()) + } + + fn get_accounts_len(&self) -> usize { + 12 + } + + fn is_active(&self) -> bool { + !self.is_paused + } +} diff --git a/mnm-dlmm/src/pda.rs b/mnm-dlmm/src/pda.rs new file mode 100644 index 0000000..804d2d2 --- /dev/null +++ b/mnm-dlmm/src/pda.rs @@ -0,0 +1,23 @@ +//! PDA derivation for MnM DLMM accounts. + +use solana_sdk::pubkey::Pubkey; +use super::MNM_PROGRAM_ID; + +pub fn find_tick_map(pool: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"tick_map", pool.as_ref()], &MNM_PROGRAM_ID) +} + +pub fn find_bin_array(pool: &Pubkey, index: i32) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[b"bin_array", pool.as_ref(), &index.to_le_bytes()], + &MNM_PROGRAM_ID, + ) +} + +pub fn find_vault_x(pool: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"vault_x", pool.as_ref()], &MNM_PROGRAM_ID) +} + +pub fn find_vault_y(pool: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"vault_y", pool.as_ref()], &MNM_PROGRAM_ID) +} diff --git a/mnm-dlmm/src/quote.rs b/mnm-dlmm/src/quote.rs new file mode 100644 index 0000000..95dfbd7 --- /dev/null +++ b/mnm-dlmm/src/quote.rs @@ -0,0 +1,228 @@ +//! Off-chain swap simulation engine for MnM DLMM. +//! +//! Exact mirror of on-chain swap logic (sans token transfers). + +use crate::state::{BinArray, PoolState, BINS_PER_ARRAY, MAX_BIN_CROSSINGS}; + +// ─── Q64.64 fixed-point math ──────────────────────────────────────────────── + +const Q64_ONE: u128 = 1u128 << 64; + +fn get_price_from_bin_id(bin_id: i32, bin_step: u16) -> u128 { + let max_bin = if bin_step == 0 { i32::MAX } else { + (440000i64 / bin_step as i64).min(100_000) as i32 + }; + if bin_id > max_bin { return u128::MAX; } + if bin_id < -max_bin { return 1; } + if bin_id == 0 { return Q64_ONE; } + + let base = Q64_ONE + (Q64_ONE * bin_step as u128) / 10000; + let result = pow_q64(base, bin_id.unsigned_abs()); + if bin_id < 0 { q64_div(Q64_ONE, result) } else { result } +} + +fn pow_q64(base: u128, exp: u32) -> u128 { + if exp == 0 { return Q64_ONE; } + let (mut result, mut b, mut e) = (Q64_ONE, base, exp); + while e > 0 { + if e & 1 == 1 { result = q64_mul(result, b); } + b = q64_mul(b, b); + e >>= 1; + } + result +} + +fn q64_mul(a: u128, b: u128) -> u128 { + let (a_lo, a_hi) = (a & 0xFFFFFFFFFFFFFFFF, a >> 64); + let (b_lo, b_hi) = (b & 0xFFFFFFFFFFFFFFFF, b >> 64); + let mid = (a_lo * b_hi).saturating_add(a_hi * b_lo).saturating_add((a_lo * b_lo) >> 64); + (a_hi * b_hi).checked_shl(64).unwrap_or(0).saturating_add(mid) +} + +fn q64_div(a: u128, b: u128) -> u128 { + if b == 0 { return u128::MAX; } + let (a_hi, a_lo) = (a >> 64, a & 0xFFFFFFFFFFFFFFFF); + if a_hi == 0 { return (a << 64) / b; } + let q_unit = u128::MAX / b; + let r_unit = u128::MAX % b; + let hi_q = a_hi.checked_mul(q_unit).unwrap_or(u128::MAX); + let hi_r = a_hi.checked_mul(r_unit.saturating_add(1)).unwrap_or(u128::MAX); + let lo_result = (a_lo << 64) / b; + let lo_carry = (a_lo << 64) % b; + let carry = match hi_r.checked_add(lo_carry) { + Some(sum) => sum / b, + None => { + let q1 = hi_r / b; + let r1 = hi_r % b; + q1.saturating_add(r1.saturating_add(lo_carry) / b) + } + }; + hi_q.saturating_add(lo_result).saturating_add(carry) +} + +fn compute_swap_amount( + amount_in: u64, liq_x: u64, liq_y: u64, price: u128, swap_x_to_y: bool, +) -> (u64, u64) { + if swap_x_to_y { + let out_full = q64_mul(amount_in as u128, price); + let out = out_full.min(liq_y as u128) as u64; + let consumed = if out == liq_y { + (q64_div(liq_y as u128, price) as u64).min(amount_in) + } else { amount_in }; + (out, consumed) + } else { + let out_full = q64_div(amount_in as u128, price) as u64; + let out = out_full.min(liq_x); + let consumed = if out == liq_x { + (q64_mul(liq_x as u128, price) as u64).min(amount_in) + } else { amount_in }; + (out, consumed) + } +} + +fn compute_fee(amount: u64, fee_bps: u16) -> u64 { + ((amount as u128 * fee_bps as u128) / 10000) as u64 +} + +fn next_set_bit_u64(bitmap: u64, from: u32, direction: bool) -> Option { + if from >= 64 { return None; } + if direction { + let masked = bitmap & (!0u64 << from); + if masked != 0 { Some(masked.trailing_zeros()) } else { None } + } else { + let mask = if from == 63 { !0u64 } else { (1u64 << (from + 1)) - 1 }; + let masked = bitmap & mask; + if masked != 0 { Some(63 - masked.leading_zeros()) } else { None } + } +} + +// ─── Swap simulation ──────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwapDirection { + XtoY, + YtoX, +} + +#[derive(Clone, Debug)] +pub struct QuoteInput { + pub amount_in: u64, + pub direction: SwapDirection, +} + +#[derive(Clone, Debug, Default)] +pub struct QuoteOutput { + pub in_amount: u64, + pub out_amount: u64, + pub fee_amount: u64, + pub fee_pct: f64, + #[allow(dead_code)] + pub not_enough_liquidity: bool, +} + +/// Simulate a swap across one or more BinArrays. +pub fn simulate_swap_multi(pool: &PoolState, bin_arrays: &[&BinArray], input: &QuoteInput) -> Option { + let swap_x_to_y = input.direction == SwapDirection::XtoY; + let total_fee_bps = pool.lp_fee_bps + pool.protocol_fee_bps; + + let mut remaining = input.amount_in; + let mut total_out: u64 = 0; + let mut total_fee: u64 = 0; + let mut crossings: u8 = 0; + let mut cur_bin = pool.active_bin_id; + + struct SimArray { + bins: [crate::state::Bin; BINS_PER_ARRAY], + active_bins: u64, + } + let mut sim_arrays: Vec<(i32, SimArray)> = bin_arrays.iter().map(|ba| { + (ba.index, SimArray { bins: ba.bins, active_bins: ba.active_bins }) + }).collect(); + + fn find_array(arrays: &mut [(i32, SimArray)], idx: i32) -> Option<&mut SimArray> { + arrays.iter_mut().find(|(i, _)| *i == idx).map(|(_, a)| a) + } + + while remaining > 0 && crossings <= MAX_BIN_CROSSINGS { + let arr_idx = cur_bin.div_euclid(BINS_PER_ARRAY as i32); + let arr = match find_array(&mut sim_arrays, arr_idx) { + Some(a) => a as *mut SimArray, + None => break, + }; + let arr = unsafe { &mut *arr }; + + let inner = cur_bin.rem_euclid(BINS_PER_ARRAY as i32) as usize; + let bin = &mut arr.bins[inner]; + let price = get_price_from_bin_id(cur_bin, pool.bin_step); + + let fee = compute_fee(remaining, total_fee_bps); + let after_fee = remaining.saturating_sub(fee); + + let (out, consumed) = compute_swap_amount( + after_fee, bin.liquidity_x, bin.liquidity_y, price, swap_x_to_y, + ); + + if swap_x_to_y { + bin.liquidity_x = bin.liquidity_x.saturating_add(consumed); + bin.liquidity_y = bin.liquidity_y.saturating_sub(out); + } else { + bin.liquidity_y = bin.liquidity_y.saturating_add(consumed); + bin.liquidity_x = bin.liquidity_x.saturating_sub(out); + } + + let (fee_actual, consumed_with_fee) = if consumed >= after_fee { + (fee, remaining) + } else { + let f = compute_fee(consumed, total_fee_bps); + (f, consumed.saturating_add(f)) + }; + + total_fee = total_fee.saturating_add(fee_actual); + total_out = total_out.saturating_add(out); + remaining = remaining.saturating_sub(consumed_with_fee); + + let exhausted = if swap_x_to_y { bin.liquidity_y == 0 } else { bin.liquidity_x == 0 }; + if exhausted && remaining > 0 { + crossings += 1; + if crossings > MAX_BIN_CROSSINGS { break; } + + let idx = cur_bin.rem_euclid(BINS_PER_ARRAY as i32) as u32; + let dir = !swap_x_to_y; + + let next = if dir { + next_set_bit_u64(arr.active_bins, idx.saturating_add(1), true) + } else if idx == 0 { + None + } else { + next_set_bit_u64(arr.active_bins, idx - 1, false) + }; + + match next { + Some(n) => cur_bin = arr_idx * BINS_PER_ARRAY as i32 + n as i32, + None => { + let next_arr_idx = if dir { arr_idx + 1 } else { arr_idx - 1 }; + let next_arr = match find_array(&mut sim_arrays, next_arr_idx) { + Some(a) => a, + None => break, + }; + let start = if dir { 0 } else { 63 }; + match next_set_bit_u64(next_arr.active_bins, start, dir) { + Some(n) => cur_bin = next_arr_idx * BINS_PER_ARRAY as i32 + n as i32, + None => break, + } + } + } + } else { + break; + } + } + + let in_amount = input.amount_in.saturating_sub(remaining); + Some(QuoteOutput { + in_amount, + out_amount: total_out, + fee_amount: total_fee, + not_enough_liquidity: remaining > 0, + fee_pct: if input.amount_in > 0 { total_fee as f64 / input.amount_in as f64 } else { 0.0 }, + }) +} diff --git a/mnm-dlmm/src/state.rs b/mnm-dlmm/src/state.rs new file mode 100644 index 0000000..993cdf7 --- /dev/null +++ b/mnm-dlmm/src/state.rs @@ -0,0 +1,86 @@ +//! On-chain account layout types for MnM DLMM. +//! +//! Zero-copy deserialization of PoolState and BinArray accounts. + +pub const DISC: usize = 8; +pub const BINS_PER_ARRAY: usize = 64; +pub const MAX_BIN_CROSSINGS: u8 = 4; + +/// PoolState — 384 bytes (including alignment padding). +#[derive(Clone, Copy)] +#[repr(C)] +pub struct PoolState { + pub authority: [u8; 32], + pub token_mint_x: [u8; 32], + pub token_mint_y: [u8; 32], + pub token_vault_x: [u8; 32], + pub token_vault_y: [u8; 32], + pub active_bin_id: i32, + pub bin_step: u16, + pub protocol_fee_bps: u16, + pub lp_fee_bps: u16, + pub _padding1: [u8; 6], + pub total_liquidity_x: u64, + pub total_liquidity_y: u64, + pub fee_growth_global_x: u128, + pub fee_growth_global_y: u128, + pub protocol_fees_x: u64, + pub protocol_fees_y: u64, + pub is_paused: u8, + pub bump: u8, + pub _padding2: [u8; 6], + pub shift_enabled: u8, + pub shift_default_mode: u8, + pub max_shift_bins: u8, + pub _shift_padding: [u8; 5], + pub shift_cooldown_slots: u64, + pub last_shift_slot: u64, + pub token_program_x: [u8; 32], + pub token_program_y: [u8; 32], + pub _reserved: [u8; 40], +} + +unsafe impl bytemuck::Pod for PoolState {} +unsafe impl bytemuck::Zeroable for PoolState {} + +/// Single bin — 64 bytes. +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct Bin { + pub liquidity_x: u64, + pub liquidity_y: u64, + pub fee_growth_x: u128, + pub fee_growth_y: u128, + pub shift_liquidity_x: u64, + pub shift_liquidity_y: u64, +} + +/// BinArray — 4208 bytes (including alignment padding). +#[derive(Clone, Copy)] +#[repr(C)] +pub struct BinArray { + pub pool: [u8; 32], + pub index: i32, + pub bump: u8, + pub _padding: [u8; 3], + pub bins: [Bin; BINS_PER_ARRAY], + pub active_bins: u64, + pub _reserved: [u8; 56], +} + +unsafe impl bytemuck::Pod for BinArray {} +unsafe impl bytemuck::Zeroable for BinArray {} + +/// Deserialize a zero-copy Anchor account (skip 8-byte discriminator). +pub fn deserialize(data: &[u8]) -> Option { + let size = core::mem::size_of::(); + if data.len() < DISC { return None; } + let available = data.len() - DISC; + if available < size { + let mut buf = vec![0u8; size]; + buf[..available].copy_from_slice(&data[DISC..]); + Some(*bytemuck::from_bytes::(&buf)) + } else { + Some(*bytemuck::from_bytes::(&data[DISC..DISC + size])) + } +} From b9dcae95ac14f7af7a9c18dd18a7fc6a60c8ce71 Mon Sep 17 00:00:00 2001 From: Nemmie Date: Tue, 24 Feb 2026 14:58:15 -0700 Subject: [PATCH 2/2] Fix bytemuck alignment panic in MnM DLMM deserialize Use T::zeroed() + bytes_of_mut() instead of from_bytes() to guarantee alignment when account data arrives from RPC with arbitrary pointer alignment. Fixes: TargetAlignmentGreaterAndInputNotAligned on PoolState/BinArray (u128 fields require 16-byte alignment). Added regression tests for misaligned deserialization. --- mnm-dlmm/src/state.rs | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/mnm-dlmm/src/state.rs b/mnm-dlmm/src/state.rs index 993cdf7..2da6f04 100644 --- a/mnm-dlmm/src/state.rs +++ b/mnm-dlmm/src/state.rs @@ -72,15 +72,46 @@ unsafe impl bytemuck::Pod for BinArray {} unsafe impl bytemuck::Zeroable for BinArray {} /// Deserialize a zero-copy Anchor account (skip 8-byte discriminator). +/// +/// Copies data into a zeroed `T` to guarantee alignment. This avoids +/// bytemuck `from_bytes` alignment panics when account data arrives from +/// RPC with arbitrary pointer alignment (u128 fields require 16-byte). pub fn deserialize(data: &[u8]) -> Option { let size = core::mem::size_of::(); if data.len() < DISC { return None; } let available = data.len() - DISC; - if available < size { - let mut buf = vec![0u8; size]; - buf[..available].copy_from_slice(&data[DISC..]); - Some(*bytemuck::from_bytes::(&buf)) - } else { - Some(*bytemuck::from_bytes::(&data[DISC..DISC + size])) + let copy_len = available.min(size); + let mut val = T::zeroed(); + let dst = bytemuck::bytes_of_mut(&mut val); + dst[..copy_len].copy_from_slice(&data[DISC..DISC + copy_len]); + Some(val) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_state_deserialize_unaligned() { + let size = DISC + core::mem::size_of::(); + let mut buf = vec![0u8; size + 1]; + let unaligned = &mut buf[1..1 + size]; + unaligned[..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + let bin_id_offset = DISC + 160; + unaligned[bin_id_offset..bin_id_offset + 4].copy_from_slice(&42i32.to_le_bytes()); + let pool: PoolState = deserialize(unaligned).expect("deserialize should succeed"); + assert_eq!(pool.active_bin_id, 42); + } + + #[test] + fn bin_array_deserialize_unaligned() { + let size = DISC + core::mem::size_of::(); + let mut buf = vec![0u8; size + 1]; + let unaligned = &mut buf[1..1 + size]; + unaligned[..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + let index_offset = DISC + 32; + unaligned[index_offset..index_offset + 4].copy_from_slice(&(-7i32).to_le_bytes()); + let arr: BinArray = deserialize(unaligned).expect("deserialize should succeed"); + assert_eq!(arr.index, -7); } }