|
| 1 | +//! Mock Jupiter v6 swap aggregator for testing the stop-loss vault. |
| 2 | +//! |
| 3 | +//! Real Jupiter aggregates across many AMMs and routes through possibly |
| 4 | +//! multiple pools. The instruction that the vault uses against real Jupiter is |
| 5 | +//! `shared_accounts_route` — a single permissioned route through Jupiter's |
| 6 | +//! shared program-owned accounts. |
| 7 | +//! |
| 8 | +//! This mock implements ONE instruction with the same external shape (an |
| 9 | +//! 8-byte Anchor-style discriminator + a borsh argument struct + a fixed |
| 10 | +//! account list head). Instead of actually routing through DEXes, the mock: |
| 11 | +//! |
| 12 | +//! 1. Reads the current price from a mock Switchboard feed passed in |
| 13 | +//! remaining accounts. |
| 14 | +//! 2. Transfers `in_amount` of the input mint from the user's source ATA to |
| 15 | +//! the mock liquidity pool's input ATA. |
| 16 | +//! 3. Transfers `in_amount * price / 10^scale` adjusted for decimal |
| 17 | +//! differences of the output mint from the mock pool's output ATA back |
| 18 | +//! to the user's destination ATA. |
| 19 | +//! |
| 20 | +//! This is enough to exercise the vault's swap path in tests. NOT FOR |
| 21 | +//! PRODUCTION — real Jupiter swaps go through real liquidity, real price |
| 22 | +//! impact, real slippage, and real route accounts. |
| 23 | +use anchor_lang::prelude::*; |
| 24 | +use anchor_spl::token::{self, Token, TokenAccount, Transfer}; |
| 25 | + |
| 26 | +declare_id!("DSMyed6WZ2US8nfwLQtF7en9jcd9exn7c4qQd52Nffx1"); |
| 27 | + |
| 28 | +#[program] |
| 29 | +pub mod mock_jupiter { |
| 30 | + use super::*; |
| 31 | + |
| 32 | + /// Mock of Jupiter v6's `shared_accounts_route`. Same argument layout, same |
| 33 | + /// account order at the head, but executes a deterministic price multiply |
| 34 | + /// instead of a real route. The `_route_plan_len`, `_quoted_out_amount`, |
| 35 | + /// `_slippage_bps` and `_platform_fee_bps` arguments are accepted but |
| 36 | + /// ignored — the mock's "route" is the single Switchboard price. |
| 37 | + pub fn shared_accounts_route( |
| 38 | + ctx: Context<SharedAccountsRoute>, |
| 39 | + _id: u8, |
| 40 | + _route_plan_len: u8, |
| 41 | + in_amount: u64, |
| 42 | + _quoted_out_amount: u64, |
| 43 | + _slippage_bps: u16, |
| 44 | + _platform_fee_bps: u8, |
| 45 | + ) -> Result<()> { |
| 46 | + // Decode the mock Switchboard feed from the dedicated account slot. |
| 47 | + // Anchor account layout is `[8-byte discriminator | borsh struct]`. |
| 48 | + // The vault passes the same feed account here as it does for its own |
| 49 | + // pre-flight price check, so prices are consistent. |
| 50 | + let feed_account = &ctx.accounts.price_feed; |
| 51 | + let feed_data = feed_account.try_borrow_data()?; |
| 52 | + require!( |
| 53 | + feed_data.len() |
| 54 | + >= MOCK_FEED_DISCRIMINATOR_LENGTH + MOCK_FEED_PAYLOAD_LENGTH, |
| 55 | + MockJupiterError::FeedDataTooShort |
| 56 | + ); |
| 57 | + // Skip the 8-byte Anchor discriminator and decode the fixed-layout |
| 58 | + // payload: 32 (authority) + 16 (price i128) + 4 (scale u32) + |
| 59 | + // 8 (last_update_slot u64). |
| 60 | + let payload = |
| 61 | + &feed_data[MOCK_FEED_DISCRIMINATOR_LENGTH..MOCK_FEED_DISCRIMINATOR_LENGTH |
| 62 | + + MOCK_FEED_PAYLOAD_LENGTH]; |
| 63 | + let price_bytes: [u8; 16] = payload[32..48] |
| 64 | + .try_into() |
| 65 | + .map_err(|_| MockJupiterError::FeedDataTooShort)?; |
| 66 | + let price = i128::from_le_bytes(price_bytes); |
| 67 | + let scale_bytes: [u8; 4] = payload[48..52] |
| 68 | + .try_into() |
| 69 | + .map_err(|_| MockJupiterError::FeedDataTooShort)?; |
| 70 | + let scale = u32::from_le_bytes(scale_bytes); |
| 71 | + drop(feed_data); |
| 72 | + |
| 73 | + require!(price > 0, MockJupiterError::NonPositivePrice); |
| 74 | + |
| 75 | + // Pull the user's volatile tokens into the mock pool. |
| 76 | + let cpi_in = CpiContext::new( |
| 77 | + ctx.accounts.token_program.key(), |
| 78 | + Transfer { |
| 79 | + from: ctx.accounts.source_token_account.to_account_info(), |
| 80 | + to: ctx.accounts.program_source_token_account.to_account_info(), |
| 81 | + authority: ctx.accounts.user_transfer_authority.to_account_info(), |
| 82 | + }, |
| 83 | + ); |
| 84 | + token::transfer(cpi_in, in_amount)?; |
| 85 | + |
| 86 | + // Compute the stable amount the user receives. |
| 87 | + // |
| 88 | + // `price` has `scale` decimal places (e.g. scale=8, price=200_00000000 |
| 89 | + // means $200). `in_amount` is in the input mint's smallest units |
| 90 | + // (e.g. lamports for SOL). The output token has its own decimals on |
| 91 | + // its mint; the caller passes them explicitly so this mock can scale |
| 92 | + // correctly without doing a CPI to the mint. |
| 93 | + // |
| 94 | + // out_amount = in_amount * price * 10^output_decimals |
| 95 | + // / (10^scale * 10^input_decimals) |
| 96 | + let in_decimals = ctx.accounts.input_mint_decimals.decimals as u32; |
| 97 | + let out_decimals = ctx.accounts.output_mint_decimals.decimals as u32; |
| 98 | + |
| 99 | + let in_amount_u128 = in_amount as u128; |
| 100 | + let price_u128 = u128::try_from(price) |
| 101 | + .map_err(|_| MockJupiterError::NonPositivePrice)?; |
| 102 | + let numerator = in_amount_u128 |
| 103 | + .checked_mul(price_u128) |
| 104 | + .ok_or(MockJupiterError::MathOverflow)? |
| 105 | + .checked_mul(ten_pow(out_decimals)?) |
| 106 | + .ok_or(MockJupiterError::MathOverflow)?; |
| 107 | + let denominator = ten_pow(scale)? |
| 108 | + .checked_mul(ten_pow(in_decimals)?) |
| 109 | + .ok_or(MockJupiterError::MathOverflow)?; |
| 110 | + let out_amount_u128 = numerator |
| 111 | + .checked_div(denominator) |
| 112 | + .ok_or(MockJupiterError::MathOverflow)?; |
| 113 | + let out_amount: u64 = out_amount_u128 |
| 114 | + .try_into() |
| 115 | + .map_err(|_| MockJupiterError::MathOverflow)?; |
| 116 | + |
| 117 | + // Push the stable tokens back to the user from the mock pool. |
| 118 | + // The pool ATA is owned by a PDA so we sign for it. |
| 119 | + let pool_authority_bump = ctx.bumps.pool_authority; |
| 120 | + let signer_seeds: &[&[&[u8]]] = |
| 121 | + &[&[POOL_AUTHORITY_SEED, &[pool_authority_bump]]]; |
| 122 | + let cpi_out = CpiContext::new_with_signer( |
| 123 | + ctx.accounts.token_program.key(), |
| 124 | + Transfer { |
| 125 | + from: ctx |
| 126 | + .accounts |
| 127 | + .program_destination_token_account |
| 128 | + .to_account_info(), |
| 129 | + to: ctx.accounts.destination_token_account.to_account_info(), |
| 130 | + authority: ctx.accounts.pool_authority.to_account_info(), |
| 131 | + }, |
| 132 | + signer_seeds, |
| 133 | + ); |
| 134 | + token::transfer(cpi_out, out_amount)?; |
| 135 | + Ok(()) |
| 136 | + } |
| 137 | + |
| 138 | + /// Convenience instruction so tests can derive a stable PDA-owned pool |
| 139 | + /// authority without rolling their own keypair scheme. Not part of the |
| 140 | + /// Jupiter API surface. |
| 141 | + pub fn initialize_pool_authority(_ctx: Context<InitializePoolAuthority>) -> Result<()> { |
| 142 | + Ok(()) |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +/// 8-byte Anchor discriminator length. Anchor accounts and Anchor instructions |
| 147 | +/// both prefix their serialised data with an 8-byte discriminator, so this |
| 148 | +/// constant is shared. |
| 149 | +pub const MOCK_FEED_DISCRIMINATOR_LENGTH: usize = 8; |
| 150 | +/// Fixed payload length of `mock_switchboard::MockFeed`: |
| 151 | +/// 32 (authority Pubkey) + 16 (price i128) + 4 (scale u32) + 8 (last_update_slot u64). |
| 152 | +pub const MOCK_FEED_PAYLOAD_LENGTH: usize = 32 + 16 + 4 + 8; |
| 153 | + |
| 154 | +/// PDA seed for the mock pool authority. Tests fund the mock pool ATAs owned |
| 155 | +/// by this PDA so the pool has stables to disburse. |
| 156 | +pub const POOL_AUTHORITY_SEED: &[u8] = b"mock-jupiter-pool"; |
| 157 | + |
| 158 | +fn ten_pow(power: u32) -> Result<u128> { |
| 159 | + 10u128 |
| 160 | + .checked_pow(power) |
| 161 | + .ok_or_else(|| error!(MockJupiterError::MathOverflow)) |
| 162 | +} |
| 163 | + |
| 164 | +/// Stub PDA the mock pool ATAs are owned by. Holds no state; existence makes |
| 165 | +/// it a valid signer authority for `Transfer` CPIs out of pool ATAs. |
| 166 | +#[account] |
| 167 | +pub struct PoolAuthority {} |
| 168 | + |
| 169 | +#[derive(Accounts)] |
| 170 | +pub struct InitializePoolAuthority<'info> { |
| 171 | + /// CHECK: PDA derived from POOL_AUTHORITY_SEED; never read or written. |
| 172 | + /// Existence as an account is incidental — Anchor still requires us to |
| 173 | + /// declare it, but it doesn't need any data. |
| 174 | + #[account( |
| 175 | + seeds = [POOL_AUTHORITY_SEED], |
| 176 | + bump, |
| 177 | + )] |
| 178 | + pub pool_authority: UncheckedAccount<'info>, |
| 179 | + |
| 180 | + #[account(mut)] |
| 181 | + pub payer: Signer<'info>, |
| 182 | + |
| 183 | + pub system_program: Program<'info, System>, |
| 184 | +} |
| 185 | + |
| 186 | +#[derive(Accounts)] |
| 187 | +pub struct SharedAccountsRoute<'info> { |
| 188 | + pub token_program: Program<'info, Token>, |
| 189 | + |
| 190 | + /// User signing for the swap. In Jupiter this is `userTransferAuthority`; |
| 191 | + /// for the vault path this will be the vault PDA signing for itself. |
| 192 | + pub user_transfer_authority: Signer<'info>, |
| 193 | + |
| 194 | + /// User's source token account (vault's volatile ATA for our use). |
| 195 | + #[account(mut)] |
| 196 | + pub source_token_account: Box<Account<'info, TokenAccount>>, |
| 197 | + |
| 198 | + /// Mock pool's input token account (receives `in_amount`). |
| 199 | + #[account(mut)] |
| 200 | + pub program_source_token_account: Box<Account<'info, TokenAccount>>, |
| 201 | + |
| 202 | + /// Mock pool's output token account (pays out the stable). |
| 203 | + #[account(mut)] |
| 204 | + pub program_destination_token_account: Box<Account<'info, TokenAccount>>, |
| 205 | + |
| 206 | + /// User's destination token account (vault's stable ATA for our use). |
| 207 | + #[account(mut)] |
| 208 | + pub destination_token_account: Box<Account<'info, TokenAccount>>, |
| 209 | + |
| 210 | + /// CHECK: read-only price feed; payload layout is validated when read. |
| 211 | + pub price_feed: UncheckedAccount<'info>, |
| 212 | + |
| 213 | + /// Decimal-only view of input mint. We just need `decimals`. |
| 214 | + pub input_mint_decimals: Box<Account<'info, anchor_spl::token::Mint>>, |
| 215 | + /// Decimal-only view of output mint. |
| 216 | + pub output_mint_decimals: Box<Account<'info, anchor_spl::token::Mint>>, |
| 217 | + |
| 218 | + /// CHECK: PDA that owns the pool ATAs. |
| 219 | + #[account( |
| 220 | + seeds = [POOL_AUTHORITY_SEED], |
| 221 | + bump, |
| 222 | + )] |
| 223 | + pub pool_authority: UncheckedAccount<'info>, |
| 224 | +} |
| 225 | + |
| 226 | +#[error_code] |
| 227 | +pub enum MockJupiterError { |
| 228 | + #[msg("Mock Switchboard feed account data is shorter than expected.")] |
| 229 | + FeedDataTooShort, |
| 230 | + #[msg("Mock Switchboard feed reported a non-positive price.")] |
| 231 | + NonPositivePrice, |
| 232 | + #[msg("Math overflow while computing swap output.")] |
| 233 | + MathOverflow, |
| 234 | +} |
0 commit comments