use super::*;
use anyhow::{anyhow, ensure, Result};
use solana_sdk::pubkey::Pubkey;
#[derive(Clone)]
pub struct PreparedStonkFunTrade {
pub via: StonkFunViaQuoteParams,
pub input_mint: Pubkey,
pub output_mint: Pubkey,
pub amount_in: u64,
pub minimum_amount_out: u64,
pub slippage_basis_points: u16,
pub is_buy: bool,
}
impl PreparedStonkFunTrade {
pub fn apply_to(self, mut params: SwapParams) -> Result<SwapParams> {
params.trade_type =
if self.is_buy { crate::swqos::TradeType::Buy } else { crate::swqos::TradeType::Sell };
params.input_mint = self.input_mint;
params.output_mint = self.output_mint;
params.input_token_program = None;
params.output_token_program = None;
params.input_amount = Some(self.amount_in);
params.slippage_basis_points = Some(u64::from(self.slippage_basis_points));
params.fixed_output_amount = None;
params.protocol_params = self.via.into_extension();
params.without_rpc()
}
}
fn canonical(mint: Pubkey) -> Pubkey {
if mint == crate::constants::SOL_TOKEN_ACCOUNT {
crate::constants::WSOL_TOKEN_ACCOUNT
} else {
mint
}
}
impl SubscriptionAccountCache {
pub fn prepare_stonkfun_trade(
&self,
meme_hint: PoolTradeHint,
meme_mint: Pubkey,
asset: Pubkey,
conversion: &[CachedRouteStep],
request: CachedQuoteRequest,
ctx: CacheReadContext,
is_buy: bool,
) -> Result<PreparedStonkFunTrade> {
ensure!(
request.amount_in > 0 && request.slippage_basis_points < 10_000,
"Invalid StonkFun preparation request"
);
let account = self
.accounts
.get(&meme_hint.pool)
.ok_or_else(|| anyhow!("Missing cached meme pool"))?;
let (meme, quote) = if account.owner == crate::instruction::utils::bonk::accounts::BONK {
let data = self.get(meme_hint.pool, account.owner, ctx)?;
let p = crate::instruction::utils::bonk_types::pool_state_decode(
data.get(8..).ok_or_else(|| anyhow!("Truncated LaunchLab pool"))?,
)
.ok_or_else(|| anyhow!("Invalid LaunchLab pool"))?;
ensure!(p.base_mint == meme_mint, "Meme mint does not match LaunchLab base mint");
(StonkFunMemeLeg::Curve(self.launchlab(meme_hint, ctx)?), p.quote_mint)
} else {
ensure!(
account.owner == crate::instruction::utils::raydium_cpmm::accounts::RAYDIUM_CPMM,
"Meme pool is neither LaunchLab nor CPMM"
);
let p = self.cpmm_at(meme_hint, ctx, request.unix_timestamp)?;
let quote = if p.base_mint == meme_mint {
p.quote_mint
} else {
ensure!(p.quote_mint == meme_mint, "Meme mint is absent from CPMM");
p.base_mint
};
(StonkFunMemeLeg::Graduated(p), quote)
};
let endpoint = canonical(asset);
ensure!(
endpoint == quote
|| endpoint == crate::constants::WSOL_TOKEN_ACCOUNT
|| endpoint == crate::constants::USDC_TOKEN_ACCOUNT,
"Payment/receipt asset must be SOL, WSOL, USDC or the meme pool quote"
);
ensure!(
!conversion.iter().any(|s| s.pool.pool == meme_hint.pool),
"Quote conversion cannot reuse the meme pool"
);
let meme_min = |amount: u64, buy: bool| -> Result<u64> {
match &meme {
StonkFunMemeLeg::Curve(p) => {
if buy {
Ok(crate::utils::calc::bonk::get_buy_quote(
amount,
p,
0,
u128::from(request.slippage_basis_points),
)?
.minimum_amount_out)
} else {
crate::utils::calc::bonk::get_sell_min_amount_out(
amount,
p,
0,
u128::from(request.slippage_basis_points),
)
}
}
StonkFunMemeLeg::Graduated(p) => {
Ok(crate::utils::calc::raydium_cpmm::compute_swap_amount_for_pool(
p,
if buy { quote == p.base_mint } else { meme_mint == p.base_mint },
amount,
u64::from(request.slippage_basis_points),
)?
.min_amount_out)
}
}
};
let (route, minimum) = if endpoint == quote {
ensure!(conversion.is_empty(), "Direct quote trade must not include conversion hops");
(StonkFunQuoteRoute::default(), meme_min(request.amount_in, is_buy)?)
} else if is_buy {
let route =
self.quote_route_exact_in(conversion, endpoint, quote, request, ctx, true)?;
let credit = route
.preview(
endpoint,
quote,
request.amount_in,
u64::from(request.slippage_basis_points),
true,
)?
.minimum_output_credit;
let minimum = meme_min(credit, true)?;
(route, minimum)
} else {
let credit = meme_min(request.amount_in, false)?;
ensure!(credit > 0, "StonkFun sell produces zero protected quote output; increase input amount");
let route = self.quote_route_exact_in(
conversion,
quote,
endpoint,
CachedQuoteRequest { amount_in: credit, ..request },
ctx,
false,
)?;
let minimum = route
.preview(quote, endpoint, credit, u64::from(request.slippage_basis_points), false)?
.minimum_output_credit;
(route, minimum)
};
ensure!(minimum > 0, "StonkFun quote produces zero protected output");
Ok(PreparedStonkFunTrade {
via: StonkFunViaQuoteParams { meme_leg: meme, sol_hop: StonkFunSolHop::Route(route) },
input_mint: if is_buy { asset } else { meme_mint },
output_mint: if is_buy { meme_mint } else { asset },
amount_in: request.amount_in,
minimum_amount_out: minimum,
slippage_basis_points: request.slippage_basis_points,
is_buy,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instruction::stonkfun::StonkFunInstructionBuilder;
use crate::trading::core::traits::InstructionBuilder;
#[test]
fn sell_rejects_zero_quote_credit_before_conversion_route() {
let asset = crate::constants::SOL_TOKEN_ACCOUNT;
let (cache, meme, mut conversion, mint) = fixture(asset, true);
std::mem::swap(&mut conversion.input_mint, &mut conversion.output_mint);
let result = cache.prepare_stonkfun_trade(
meme, mint, asset, &[CachedRouteStep { pool: conversion, input_amount: None }],
CachedQuoteRequest { amount_in: 1, slippage_basis_points: 300,
unix_timestamp: 1000, maximum_arrays: 6 },
CacheReadContext { slot: 100, epoch: 3, maximum_slot_age: 0 }, false,
);
assert!(result.err().unwrap().to_string().contains("zero protected quote output"));
}
fn fixture(
asset: Pubkey,
inner: bool,
) -> (SubscriptionAccountCache, PoolTradeHint, PoolTradeHint, Pubkey) {
let (mut cache, meme, _) = super::super::subscription_cache::tests::fixture();
let (mut quote, mut hop) = super::super::cached_quote::tests::wp_fixture();
let endpoint = canonical(asset);
hop.input_mint = endpoint;
hop.output_mint = meme.input_mint;
let data = &mut quote.accounts.get_mut(&hop.pool).unwrap().data;
data[101..133].copy_from_slice(endpoint.as_ref());
data[181..213].copy_from_slice(meme.input_mint.as_ref());
let mut mint = vec![0; 82];
mint[45] = 1;
cache
.update(
endpoint,
CachedAccount {
owner: crate::constants::TOKEN_PROGRAM,
data: mint,
slot: 100,
write_version: 1,
},
)
.unwrap();
cache.accounts.extend(quote.accounts);
if inner {
let global = Pubkey::new_unique();
let platform = Pubkey::new_unique();
let mut data = vec![0; 429];
data[..8].copy_from_slice(&[247, 237, 227, 245, 215, 195, 222, 70]);
for (offset, key) in [
(141, global),
(173, platform),
(205, meme.output_mint),
(237, meme.input_mint),
(269, Pubkey::new_unique()),
(301, Pubkey::new_unique()),
(333, Pubkey::new_unique()),
] {
data[offset..offset + 32].copy_from_slice(key.as_ref());
}
for (offset, value) in [
(29, 1_000_000_000u64),
(37, 1_000_000_000),
(45, 1_000_000_000),
(61, 100_000_000),
] {
data[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
let mut g = vec![0; 35];
g[..8].copy_from_slice(&[149, 8, 156, 202, 160, 252, 176, 217]);
g[27..35].copy_from_slice(&3000u64.to_le_bytes());
let mut p = vec![0; 728];
p[..8].copy_from_slice(&[160, 78, 128, 0, 248, 83, 230, 160]);
for (key, data) in [(meme.pool, data), (global, g), (platform, p)] {
cache.accounts.insert(
key,
CachedAccount {
owner: crate::instruction::utils::bonk::accounts::BONK,
data,
slot: 100,
write_version: 1,
},
);
}
}
(cache, meme, hop, meme.output_mint)
}
#[tokio::test]
async fn independent_inner_and_graduated_asset_matrix_builds_without_rpc() {
let ctx = CacheReadContext { slot: 100, epoch: 3, maximum_slot_age: 0 };
for inner in [true, false] {
for asset in [
crate::constants::SOL_TOKEN_ACCOUNT,
crate::constants::WSOL_TOKEN_ACCOUNT,
crate::constants::USDC_TOKEN_ACCOUNT,
] {
let (cache, meme, hop, mint) = fixture(asset, inner);
for buy in [true, false] {
let request = CachedQuoteRequest {
amount_in: if buy { 10_000 } else { 100 },
slippage_basis_points: 100,
unix_timestamp: 1000,
maximum_arrays: 6,
};
let conversion = if buy {
hop
} else {
PoolTradeHint {
input_mint: hop.output_mint,
output_mint: hop.input_mint,
..hop
}
};
let prepared = cache
.prepare_stonkfun_trade(
meme,
mint,
asset,
&[CachedRouteStep { pool: conversion, input_amount: None }],
request,
ctx,
buy,
)
.unwrap();
assert!(prepared.minimum_amount_out > 0);
let mut template = crate::instruction::stonkfun::tests::swap_params(
crate::swqos::TradeType::Buy,
asset,
mint,
prepared.via.clone().into_extension(),
);
template.simulate = false;
template.close_input_mint_ata = false;
template.close_output_mint_ata =
asset == crate::constants::SOL_TOKEN_ACCOUNT && !buy;
let params = prepared.apply_to(template).unwrap();
assert!(params.rpc.is_none());
assert!(params.fixed_output_amount.is_none());
let instructions = if buy {
StonkFunInstructionBuilder.build_buy_instructions(¶ms).await.unwrap()
} else {
StonkFunInstructionBuilder.build_sell_instructions(¶ms).await.unwrap()
};
let economic=instructions.iter().filter(|ix|ix.program_id==crate::instruction::utils::whirlpool::PROGRAM_ID || ix.program_id==crate::instruction::utils::bonk::accounts::BONK || ix.program_id==crate::instruction::utils::raydium_cpmm::accounts::RAYDIUM_CPMM).count();
assert_eq!(economic, 2);
}
for buy in [true, false] {
let request = CachedQuoteRequest {
amount_in: 100,
slippage_basis_points: 100,
unix_timestamp: 1000,
maximum_arrays: 6,
};
let prepared = cache
.prepare_stonkfun_trade(meme, mint, meme.input_mint, &[], request, ctx, buy)
.unwrap();
let mut template = crate::instruction::stonkfun::tests::swap_params(
crate::swqos::TradeType::Buy,
meme.input_mint,
mint,
prepared.via.clone().into_extension(),
);
template.simulate = false;
let params = prepared.apply_to(template).unwrap();
let instructions = if buy {
StonkFunInstructionBuilder.build_buy_instructions(¶ms).await.unwrap()
} else {
StonkFunInstructionBuilder.build_sell_instructions(¶ms).await.unwrap()
};
assert!(!instructions.iter().any(
|ix| ix.program_id == crate::instruction::utils::whirlpool::PROGRAM_ID
));
}
}
}
}
}