use std::fmt::Debug;
use alloy::{
core::sol,
primitives::{Address as AlloyAddress, U256},
sol_types::SolCall,
};
use revm::{state::AccountInfo, DatabaseRef};
use tycho_common::{simulation::errors::SimulationError, Bytes};
use crate::evm::{
engine_db::engine_db_interface::EngineDatabaseInterface,
protocol::{
curve::{
adapter::{build_pool, detect_eth_variant, CurveVariant, ProbingResults, RawPoolState},
math::Pool,
},
vm::utils::get_code_for_contract,
},
simulation::{PendingOverrides, SimulationEngine},
};
sol! {
#[allow(missing_docs)]
interface ICurve {
function balances(uint256 i) external view returns (uint256);
function A() external view returns (uint256);
function fee() external view returns (uint256);
function initial_A() external view returns (uint256);
function future_A() external view returns (uint256);
function offpeg_fee_multiplier() external view returns (uint256);
function gamma() external view returns (uint256);
function D() external view returns (uint256);
function mid_fee() external view returns (uint256);
function out_fee() external view returns (uint256);
function fee_gamma() external view returns (uint256);
function price_scale() external view returns (uint256);
function MATH() external view returns (address);
function base_pool() external view returns (address);
function version() external view returns (string);
}
#[allow(missing_docs)]
interface ICurveOld {
function balances(int128 i) external view returns (uint256);
}
#[allow(missing_docs)]
interface ICurveTri {
function price_scale(uint256 i) external view returns (uint256);
function precisions() external view returns (uint256[3]);
}
#[allow(missing_docs)]
interface ICurveTwo {
function precisions() external view returns (uint256[2]);
}
#[allow(missing_docs)]
interface IBasePool {
function get_virtual_price() external view returns (uint256);
}
}
const A_PRECISION: u64 = 100;
const STORED_RATES_SELECTOR: [u8; 4] = [0xfd, 0x06, 0x84, 0xb1];
pub const POOL_STATE_ADJUSTED: &str = "pool_state_adjusted";
pub fn encode_raw_state(state: &RawPoolState) -> Result<Bytes, SimulationError> {
serde_json::to_vec(state)
.map(Bytes::from)
.map_err(|e| SimulationError::FatalError(format!("curve state encode failed: {e}")))
}
pub fn decode_raw_state(bytes: &[u8]) -> Result<RawPoolState, SimulationError> {
serde_json::from_slice(bytes)
.map_err(|e| SimulationError::FatalError(format!("curve state decode failed: {e}")))
}
pub fn decode_from_vm<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
variant: CurveVariant,
token_decimals: &[u8],
) -> Result<Pool, SimulationError>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
let state =
read_raw_pool_state(engine, pool, variant, token_decimals, &PendingOverrides::default())?;
build_pool(&state)
.map_err(|e| SimulationError::FatalError(format!("curve build_pool failed: {e}")))
}
pub fn read_raw_pool_state<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
variant: CurveVariant,
decimals: &[u8],
overrides: &PendingOverrides,
) -> Result<RawPoolState, SimulationError>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
PoolReader::new(engine, overrides).read_pool(pool, variant, decimals)
}
struct PoolReader<'a, D: EngineDatabaseInterface + Clone + Debug>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
engine: &'a SimulationEngine<D>,
overrides: &'a PendingOverrides,
}
impl<'a, D: EngineDatabaseInterface + Clone + Debug> PoolReader<'a, D>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
fn new(engine: &'a SimulationEngine<D>, overrides: &'a PendingOverrides) -> Self {
Self { engine, overrides }
}
fn read_pool(
&self,
pool: &AlloyAddress,
variant: CurveVariant,
decimals: &[u8],
) -> Result<RawPoolState, SimulationError> {
let n_coins = decimals.len();
match variant {
CurveVariant::StableSwapV0 => Ok(RawPoolState {
variant,
balances: self.read_balances_int128(pool, n_coins)?,
amp: self.call(pool, ICurve::ACall {})?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
token_decimals: decimals.into(),
..Default::default()
}),
CurveVariant::StableSwapV1 => Ok(RawPoolState {
variant,
balances: self.read_balances(pool, n_coins)?,
amp: self.call(pool, ICurve::ACall {})?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
token_decimals: decimals.into(),
..Default::default()
}),
CurveVariant::StableSwapV2 | CurveVariant::StableSwapSTETH => Ok(RawPoolState {
variant,
balances: self.read_balances(pool, n_coins)?,
amp: self.read_ramped_amp(pool)?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
token_decimals: decimals.into(),
..Default::default()
}),
CurveVariant::StableSwapALend => Ok(RawPoolState {
variant,
balances: self.read_balances(pool, n_coins)?,
amp: self.read_ramped_amp(pool)?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
offpeg_fee_multiplier: Some(self.call(pool, ICurve::offpeg_fee_multiplierCall {})?),
token_decimals: decimals.into(),
..Default::default()
}),
CurveVariant::StableSwapNG => Ok(RawPoolState {
variant,
balances: self.read_balances(pool, n_coins)?,
amp: self.read_ramped_amp(pool)?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
offpeg_fee_multiplier: self.call_opt(pool, ICurve::offpeg_fee_multiplierCall {}),
dynamic_rates: self.read_stored_rates(pool, n_coins),
token_decimals: decimals.into(),
..Default::default()
}),
CurveVariant::StableSwapMeta => {
let mut dynamic_rates = vec![None; n_coins];
if let Some(last) = dynamic_rates.last_mut() {
*last = Some(self.read_base_virtual_price(pool)?);
}
Ok(RawPoolState {
variant,
balances: self.read_balances(pool, n_coins)?,
amp: self.read_ramped_amp(pool)?,
fee: Some(self.call(pool, ICurve::feeCall {})?),
dynamic_rates: Some(dynamic_rates),
token_decimals: decimals.into(),
..Default::default()
})
}
CurveVariant::TwoCryptoV1 |
CurveVariant::TwoCryptoNG |
CurveVariant::TwoCryptoStable => self.read_twocrypto(pool, variant, decimals),
CurveVariant::TriCryptoV1 | CurveVariant::TriCryptoNG => {
self.read_tricrypto(pool, variant, decimals)
}
}
}
fn read_twocrypto(
&self,
pool: &AlloyAddress,
variant: CurveVariant,
decimals: &[u8],
) -> Result<RawPoolState, SimulationError> {
let balances = self.read_balances(pool, 2)?;
let price_scale = self.call(pool, ICurve::price_scaleCall {})?;
let precisions = self
.call_opt(pool, ICurveTwo::precisionsCall {})
.map(|p| p.to_vec());
let gamma = if variant == CurveVariant::TwoCryptoStable {
None
} else {
Some(self.call(pool, ICurve::gammaCall {})?)
};
let eth_variant = if variant == CurveVariant::TwoCryptoV1 {
Some(detect_eth_variant(*pool))
} else {
None
};
Ok(RawPoolState {
variant,
balances,
amp: self.call(pool, ICurve::ACall {})?,
mid_fee: Some(self.call(pool, ICurve::mid_feeCall {})?),
out_fee: Some(self.call(pool, ICurve::out_feeCall {})?),
fee_gamma: Some(self.call(pool, ICurve::fee_gammaCall {})?),
d: Some(self.call(pool, ICurve::DCall {})?),
gamma,
price_scale: Some(vec![price_scale]),
precisions,
eth_variant,
token_decimals: decimals.into(),
..Default::default()
})
}
fn read_tricrypto(
&self,
pool: &AlloyAddress,
variant: CurveVariant,
decimals: &[u8],
) -> Result<RawPoolState, SimulationError> {
let balances = self.read_balances(pool, 3)?;
let ps0 = self.call(pool, ICurveTri::price_scaleCall { i: U256::from(0) })?;
let ps1 = self.call(pool, ICurveTri::price_scaleCall { i: U256::from(1) })?;
let precisions = self
.call_opt(pool, ICurveTri::precisionsCall {})
.map(|p| p.to_vec());
Ok(RawPoolState {
variant,
balances,
amp: self.call(pool, ICurve::ACall {})?,
mid_fee: Some(self.call(pool, ICurve::mid_feeCall {})?),
out_fee: Some(self.call(pool, ICurve::out_feeCall {})?),
fee_gamma: Some(self.call(pool, ICurve::fee_gammaCall {})?),
d: Some(self.call(pool, ICurve::DCall {})?),
gamma: Some(self.call(pool, ICurve::gammaCall {})?),
price_scale: Some(vec![ps0, ps1]),
precisions,
token_decimals: decimals.into(),
..Default::default()
})
}
fn read_ramped_amp(&self, pool: &AlloyAddress) -> Result<U256, SimulationError> {
let initial_a = self.call_opt(pool, ICurve::initial_ACall {});
let future_a = self.call_opt(pool, ICurve::future_ACall {});
match (initial_a, future_a) {
(Some(ia), Some(fa)) if ia == fa => Ok(ia),
_ => Ok(self.call(pool, ICurve::ACall {})? * U256::from(A_PRECISION)),
}
}
fn read_balances(
&self,
pool: &AlloyAddress,
n_coins: usize,
) -> Result<Vec<U256>, SimulationError> {
let mut balances = Vec::with_capacity(n_coins);
for i in 0..n_coins {
balances.push(self.call(pool, ICurve::balancesCall { i: U256::from(i) })?);
}
Ok(balances)
}
fn read_balances_int128(
&self,
pool: &AlloyAddress,
n_coins: usize,
) -> Result<Vec<U256>, SimulationError> {
let mut balances = Vec::with_capacity(n_coins);
for i in 0..n_coins {
balances.push(self.call(pool, ICurveOld::balancesCall { i: i as i128 })?);
}
Ok(balances)
}
fn read_base_virtual_price(&self, pool: &AlloyAddress) -> Result<U256, SimulationError> {
let base_pool = self.call(pool, ICurve::base_poolCall {})?;
self.call(&base_pool, IBasePool::get_virtual_priceCall {})
}
fn read_stored_rates(&self, pool: &AlloyAddress, n_coins: usize) -> Option<Vec<Option<U256>>> {
let res = self
.engine
.simulate(
&self
.overrides
.view_call(*pool, STORED_RATES_SELECTOR.to_vec()),
)
.ok()?;
let out = res.result.as_ref();
if out.len() < n_coins * 32 {
return None;
}
let first_word = U256::from_be_slice(&out[..32]);
let data_offset = if first_word <= U256::from(256u64) && out.len() >= (n_coins + 2) * 32 {
64
} else {
0
};
let rates = (0..n_coins)
.map(|i| {
let start = data_offset + i * 32;
Some(U256::from_be_slice(&out[start..start + 32]))
})
.collect();
Some(rates)
}
fn call<C, R>(&self, to: &AlloyAddress, sol_call: C) -> Result<R, SimulationError>
where
C: SolCall<Return = R>,
{
let res = self
.engine
.simulate(
&self
.overrides
.view_call(*to, sol_call.abi_encode()),
)
.map_err(|e| {
SimulationError::RecoverableError(format!("curve getter call failed: {e}"))
})?;
C::abi_decode_returns(res.result.as_ref())
.map_err(|e| SimulationError::FatalError(format!("curve getter decode failed: {e}")))
}
fn call_opt<C, R>(&self, to: &AlloyAddress, sol_call: C) -> Option<R>
where
C: SolCall<Return = R>,
{
self.call(to, sol_call).ok()
}
}
pub fn read_math_address<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
) -> Option<AlloyAddress>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
PoolReader::new(engine, &PendingOverrides::default()).call_opt(pool, ICurve::MATHCall {})
}
pub async fn load_math_contract<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
) -> Result<(), SimulationError>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
let Some(math) = read_math_address(engine, pool) else {
return Ok(());
};
let code = get_code_for_contract(&math.to_string(), None)
.await
.map_err(|e| {
SimulationError::RecoverableError(format!(
"curve: failed to fetch MATH() code for {math}: {e}"
))
})?;
engine
.state
.init_account(
math,
AccountInfo {
balance: U256::ZERO,
nonce: 0,
code_hash: code.hash_slow(),
code: Some(code),
},
None,
false,
)
.map_err(|e| {
SimulationError::FatalError(format!(
"curve: failed to load MATH() code for {math}: {e:?}"
))
})?;
Ok(())
}
pub fn read_version<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
address: &AlloyAddress,
) -> Option<String>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
PoolReader::new(engine, &PendingOverrides::default()).call_opt(address, ICurve::versionCall {})
}
pub fn probe<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
n_coins: usize,
) -> ProbingResults
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
let overrides = PendingOverrides::default();
let reader = PoolReader::new(engine, &overrides);
let math: Option<AlloyAddress> = reader.call_opt(pool, ICurve::MATHCall {});
ProbingResults {
has_gamma: reader
.call_opt(pool, ICurve::gammaCall {})
.is_some(),
n_coins,
has_math: math.is_some(),
math_version: math.and_then(|math| reader.call_opt(&math, ICurve::versionCall {})),
has_offpeg_fee_multiplier: reader
.call_opt(pool, ICurve::offpeg_fee_multiplierCall {})
.is_some(),
has_stored_rates: reader
.read_stored_rates(pool, n_coins)
.is_some(),
has_version: reader
.call_opt(pool, ICurve::versionCall {})
.is_some(),
has_base_pool: reader
.call_opt(pool, ICurve::base_poolCall {})
.is_some(),
has_int128_balances: reader
.call_opt(pool, ICurveOld::balancesCall { i: 0 })
.is_some(),
pool_address: *pool,
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use alloy::sol;
use tycho_client::feed::BlockHeader;
use super::*;
use crate::evm::{
engine_db::{
simulation_db::SimulationDB,
utils::{get_client, get_runtime},
},
simulation::SimulationEngine,
};
sol! {
function get_dy_stable(int128 i, int128 j, uint256 dx) external view returns (uint256);
function coins(uint256 i) external view returns (address);
function decimals() external view returns (uint8);
}
const ETH_PLACEHOLDER: AlloyAddress =
alloy::primitives::address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
fn read_decimals<D: EngineDatabaseInterface + Clone + Debug>(
engine: &SimulationEngine<D>,
pool: &AlloyAddress,
n: usize,
) -> Vec<u8>
where
<D as DatabaseRef>::Error: Debug,
<D as EngineDatabaseInterface>::Error: Debug,
{
let overrides = PendingOverrides::default();
let reader = PoolReader::new(engine, &overrides);
(0..n)
.map(|i| {
let coin: AlloyAddress = reader
.call(pool, coinsCall { i: U256::from(i) })
.unwrap();
if coin == ETH_PLACEHOLDER {
18
} else {
reader
.call(&coin, decimalsCall {})
.unwrap()
}
})
.collect()
}
fn assert_stable_matches_onchain(
pool: &str,
variant: CurveVariant,
n_coins: usize,
swap: (usize, usize, U256),
block: (u64, u64),
) {
let (i, j, dx) = swap;
let (block_number, timestamp) = block;
let header = BlockHeader { number: block_number, timestamp, ..Default::default() };
let mut db = SimulationDB::new(get_client(None).unwrap(), get_runtime().unwrap(), None);
db.set_block(Some(header));
let engine = SimulationEngine::new(db, false);
let pool = AlloyAddress::from_str(pool).unwrap();
let decimals = read_decimals(&engine, &pool, n_coins);
let decoded = decode_from_vm(&engine, &pool, variant, &decimals).expect("decode failed");
let ours = decoded
.get_amount_out(i, j, dx)
.expect("get_amount_out returned None");
let onchain: U256 = PoolReader::new(&engine, &PendingOverrides::default())
.call(&pool, get_dy_stableCall { i: i as i128, j: j as i128, dx })
.expect("on-chain get_dy failed");
assert_eq!(ours, onchain, "curve quote diverged from on-chain get_dy");
}
#[test]
fn raw_pool_state_roundtrips_through_the_attribute_encoding() {
let u = |s: &str| s.parse::<U256>().unwrap();
let state = RawPoolState {
variant: CurveVariant::StableSwapV2,
balances: vec![u("2466241139205"), u("4200057336")],
token_decimals: vec![18, 6],
amp: u("1707629"),
fee: Some(u("4000000")),
mid_fee: Some(u("3000000")),
out_fee: Some(u("30000000")),
fee_gamma: Some(u("500000000000000")),
offpeg_fee_multiplier: Some(u("20000000000")),
price_scale: Some(vec![u("59372627314351316239076")]),
d: Some(u("7457948167729606869978625")),
gamma: Some(u("11809167828997")),
dynamic_rates: Some(vec![Some(u("1000000000000000000")), None]),
precisions: Some(vec![u("1"), u("1000000000000")]),
eth_variant: Some(true),
};
let encoded = encode_raw_state(&state).expect("encode failed");
assert_eq!(decode_raw_state(&encoded).expect("decode failed"), state);
}
#[test]
fn decoding_malformed_readings_fails() {
assert!(decode_raw_state(b"not json").is_err());
}
#[test]
fn tricrypto_ng_build_pool_matches_onchain_get_dy() {
let u = |s: &str| s.parse::<U256>().unwrap();
let state = RawPoolState {
variant: CurveVariant::TriCryptoNG,
balances: vec![u("2466241139205"), u("4200057336"), u("1595469030050811720465")],
token_decimals: vec![6, 8, 18],
amp: u("1707629"),
mid_fee: Some(u("3000000")),
out_fee: Some(u("30000000")),
fee_gamma: Some(u("500000000000000")),
d: Some(u("7457948167729606869978625")),
gamma: Some(u("11809167828997")),
price_scale: Some(vec![u("59372627314351316239076"), u("1565715369034455123313")]),
..Default::default()
};
let pool = build_pool(&state).expect("build_pool failed");
let dx = U256::from(1_000_000_000u64); assert_eq!(pool.get_amount_out(0, 1, dx), Some(u("1690920")), "USDC->WBTC");
assert_eq!(pool.get_amount_out(0, 2, dx), Some(u("641654961086650131")), "USDC->WETH");
}
#[test]
fn twocrypto_v1_eth_variant_build_pool_matches_onchain_get_dy() {
let u = |s: &str| s.parse::<U256>().unwrap();
let make = |eth_variant: bool| {
build_pool(&RawPoolState {
variant: CurveVariant::TwoCryptoV1,
balances: vec![u("33389428640940997105"), u("1538654846140514380725767708")],
token_decimals: vec![18, 18], amp: u("400000"),
gamma: Some(u("145000000000000")),
d: Some(u("3338917956508624293928")),
price_scale: Some(vec![u("52805053500476")]),
mid_fee: Some(U256::from(26_000_000u64)),
out_fee: Some(U256::from(45_000_000u64)),
fee_gamma: Some(u("230000000000000")),
eth_variant: Some(eth_variant),
..Default::default()
})
.expect("build_pool")
};
let pool = make(true);
assert_eq!(
pool.get_amount_out(0, 1, u("1000000000000000000")),
Some(u("44131555012248406155621024")),
"1 WETH -> CRV",
);
assert_eq!(
pool.get_amount_out(0, 1, u("500000000000000000")),
Some(u("22389351263618692591189738")),
"0.5 WETH -> CRV",
);
assert_eq!(
pool.get_amount_out(1, 0, u("1000000000000000000000")),
Some(u("21806963109202")),
"1000 CRV -> WETH",
);
assert_eq!(
pool.get_amount_out(1, 0, u("10000000000000000000000")),
Some(u("218068351371449")),
"10000 CRV -> WETH",
);
}
#[test]
fn twocrypto_v011_is_stable_not_ng() {
let u = |s: &str| s.parse::<U256>().unwrap();
let base = |variant: CurveVariant, gamma: Option<U256>| {
build_pool(&RawPoolState {
variant,
balances: vec![u("250289528581622891700521"), u("179139571297")],
token_decimals: vec![18, 6],
amp: u("350000"),
mid_fee: Some(u("1000000")),
out_fee: Some(u("20000000")),
fee_gamma: Some(u("63100000000000000")),
d: Some(u("500000118136487176847089")),
gamma,
price_scale: Some(vec![u("1393944381226980604")]),
precisions: Some(vec![u("1"), u("1000000000000")]),
..Default::default()
})
.expect("build_pool")
};
let dx = u("1000000000000000000"); let stable = base(CurveVariant::TwoCryptoStable, None).get_amount_out(0, 1, dx);
let ng =
base(CurveVariant::TwoCryptoNG, Some(u("100000000000000"))).get_amount_out(0, 1, dx);
eprintln!("on-chain get_dy=717271 stable={stable:?} ng={ng:?}");
assert_eq!(stable, Some(u("717271")), "TwoCryptoStable matches on-chain get_dy");
assert_ne!(ng, Some(u("717271")), "TwoCryptoNG does NOT match (current misclassification)");
}
#[test]
fn tricrypto_ng_wrong_coin2_decimals_breaks_quotes() {
let u = |s: &str| s.parse::<U256>().unwrap();
let make = |decimals: Vec<u8>| {
build_pool(&RawPoolState {
variant: CurveVariant::TriCryptoNG,
balances: vec![u("2466241139205"), u("4200057336"), u("1595469030050811720465")],
token_decimals: decimals,
amp: u("1707629"),
mid_fee: Some(u("3000000")),
out_fee: Some(u("30000000")),
fee_gamma: Some(u("500000000000000")),
d: Some(u("7457948167729606869978625")),
gamma: Some(u("11809167828997")),
price_scale: Some(vec![u("59372627314351316239076"), u("1565715369034455123313")]),
..Default::default()
})
.expect("build_pool")
};
let dx_usdc = U256::from(1_000_000_000u64); let dx_wbtc = U256::from(13_656_795u64); for dec2 in [18u8, 0, 6] {
let p = make(vec![6, 8, dec2]);
eprintln!(
"decimals=[6,8,{dec2}] USDC->WETH(0,2)={:?} WBTC->USDC(1,0)={:?}",
p.get_amount_out(0, 2, dx_usdc),
p.get_amount_out(1, 0, dx_wbtc),
);
}
let correct_out = make(vec![6, 8, 18])
.get_amount_out(0, 2, dx_usdc)
.unwrap();
let wrong_out = make(vec![6, 8, 0])
.get_amount_out(0, 2, dx_usdc)
.unwrap();
assert!(correct_out > U256::from(10).pow(U256::from(17)), "correct ~0.6 ETH");
assert!(wrong_out > correct_out * U256::from(100u64), "wrong decimals corrupt the quote");
}
#[test]
#[ignore = "Requires RPC_URL to be set in environment variables or .env file"]
fn differential_3pool_stableswap_v1() {
assert_stable_matches_onchain(
"0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7",
CurveVariant::StableSwapV1,
3,
(0, 1, U256::from(1_000_000_000_000_000_000u128)),
(21_500_000, 1_736_000_000),
);
}
#[test]
#[ignore = "Requires RPC_URL to be set in environment variables or .env file"]
fn differential_stableswap_ng_plain() {
assert_stable_matches_onchain(
"0xf55b0f6f2da5ffddb104b58a60f2862745960442",
CurveVariant::StableSwapNG,
2,
(0, 1, U256::from(1_000_000_000_000_000_000u128)),
(21_500_000, 1_736_000_000),
);
}
}