Skip to content

Commit e639529

Browse files
feat: externalize test symbol details, accounts. improve rs tests robustness (#469)
…ustness
1 parent 3af43fa commit e639529

File tree

6 files changed

+298
-60
lines changed

6 files changed

+298
-60
lines changed

v4-client-rs/client/tests/env.rs

Lines changed: 186 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#[cfg(any(feature = "faucet", feature = "noble"))]
22
use anyhow::anyhow as err;
33
use anyhow::{Error, Result};
4+
use bigdecimal::BigDecimal;
45
use chrono::{TimeDelta, Utc};
56
#[cfg(feature = "faucet")]
67
use dydx::faucet::FaucetClient;
@@ -11,11 +12,11 @@ use dydx::{
1112
indexer::{ClientId, Height, IndexerClient, PerpetualMarket, Ticker},
1213
node::{Account, Address, NodeClient, OrderBuilder, OrderId, OrderSide, Subaccount, Wallet},
1314
};
15+
use serde::Deserialize;
16+
use std::str::FromStr;
1417
use std::sync::Once;
15-
16-
const TEST_MNEMONIC: &str = "mirror actor skill push coach wait confirm orchard lunch mobile athlete gossip awake miracle matter bus reopen team ladder lazy list timber render wait";
17-
18-
const TEST_MNEMONIC_2: &str = "movie yard still copper exile wear brisk chest ride dizzy novel future menu finish radar lunar claim hub middle force turtle mouse frequent embark";
18+
use tokio::fs;
19+
use tokio::time::{sleep, Duration};
1920

2021
static INIT_CRYPTO: Once = Once::new();
2122

@@ -54,6 +55,7 @@ impl MainnetEnv {
5455
let path = "tests/mainnet.toml";
5556
let config = ClientConfig::from_file(path).await?;
5657
let indexer = IndexerClient::new(config.indexer);
58+
// Mainnet tests remain on ETH-USD for now; only testnet is migrated via [test] config.
5759
let ticker = Ticker::from("ETH-USD");
5860
Ok(Self { indexer, ticker })
5961
}
@@ -72,11 +74,50 @@ pub struct TestnetEnv {
7274
pub address: Address,
7375
pub subaccount: Subaccount,
7476
pub ticker: Ticker,
77+
pub clob_pair_id: u32,
78+
pub default_subticks: u64,
7579

7680
pub wallet_2: Wallet,
7781
pub account_2: Account,
7882
pub address_2: Address,
7983
pub subaccount_2: Subaccount,
84+
85+
// Python test account 1 (used for delegation-related governance checks)
86+
pub wallet_1: Wallet,
87+
pub account_1: Account,
88+
pub address_1: Address,
89+
}
90+
91+
#[derive(Debug, Clone)]
92+
pub struct LiquidityOrders {
93+
pub buy_order_id: OrderId,
94+
pub sell_order_id: OrderId,
95+
pub good_til_block: Height,
96+
}
97+
98+
#[derive(Debug, Deserialize)]
99+
struct TestFileConfig {
100+
test: TestConfig,
101+
}
102+
103+
#[derive(Debug, Deserialize)]
104+
struct TestConfig {
105+
market_id: String,
106+
clob_pair_id: u32,
107+
default_subticks: u64,
108+
accounts: TestAccountsConfig,
109+
}
110+
111+
#[derive(Debug, Deserialize)]
112+
struct TestAccountsConfig {
113+
primary: TestAccountConfig,
114+
liquidity: TestAccountConfig,
115+
account_1: TestAccountConfig,
116+
}
117+
118+
#[derive(Debug, Deserialize)]
119+
struct TestAccountConfig {
120+
mnemonic: String,
80121
}
81122

82123
#[allow(dead_code)]
@@ -86,6 +127,7 @@ impl TestnetEnv {
86127
init_crypto_provider();
87128

88129
let path = "tests/testnet.toml";
130+
let test_cfg = load_test_config(path).await?;
89131
let config = ClientConfig::from_file(path).await?;
90132
let mut node = NodeClient::connect(config.node).await?;
91133
let indexer = IndexerClient::new(config.indexer);
@@ -98,17 +140,24 @@ impl TestnetEnv {
98140
err!("Configuration file must contain a [noble] configuration for testing")
99141
})?)
100142
.await?;
101-
let wallet = Wallet::from_mnemonic(TEST_MNEMONIC)?;
143+
// Primary actor account (mirrors Python test account 3)
144+
let wallet = Wallet::from_mnemonic(&test_cfg.accounts.primary.mnemonic)?;
102145
let account = wallet.account(0, &mut node).await?;
103-
let ticker = Ticker::from("ETH-USD");
146+
let ticker = Ticker::from(test_cfg.market_id.as_str());
104147
let address = account.address().clone();
105148
let subaccount = account.subaccount(0)?;
106149

107-
let wallet_2 = Wallet::from_mnemonic(TEST_MNEMONIC_2)?;
150+
// Liquidity actor account (mirrors Python test account 2)
151+
let wallet_2 = Wallet::from_mnemonic(&test_cfg.accounts.liquidity.mnemonic)?;
108152
let account_2 = wallet_2.account(0, &mut node).await?;
109153
let address_2 = account_2.address().clone();
110154
let subaccount_2 = account_2.subaccount(0)?;
111155

156+
// Account 1 (mirrors Python DYDX_TEST_MNEMONIC / TEST_ADDRESS)
157+
let wallet_1 = Wallet::from_mnemonic(&test_cfg.accounts.account_1.mnemonic)?;
158+
let account_1 = wallet_1.account(0, &mut node).await?;
159+
let address_1 = account_1.address().clone();
160+
112161
Ok(Self {
113162
node,
114163
indexer,
@@ -121,10 +170,15 @@ impl TestnetEnv {
121170
address,
122171
subaccount,
123172
ticker,
173+
clob_pair_id: test_cfg.clob_pair_id,
174+
default_subticks: test_cfg.default_subticks,
124175
wallet_2,
125176
account_2,
126177
address_2,
127178
subaccount_2,
179+
wallet_1,
180+
account_1,
181+
address_1,
128182
})
129183
}
130184

@@ -151,4 +205,129 @@ impl TestnetEnv {
151205
self.node.query_transaction_result(tx_res).await?;
152206
Ok(id)
153207
}
208+
209+
/// Place bid/ask liquidity orders from account 2, similar to Python `liquidity_setup`.
210+
///
211+
/// The orders are priced "safely" away from best bid/ask (or oracle fallback) to avoid immediate
212+
/// execution, and can be cleaned up via `cleanup_liquidity_orders`.
213+
pub async fn setup_liquidity_orders(&mut self) -> Result<LiquidityOrders> {
214+
let market = self.get_market().await?;
215+
let oracle_price = market
216+
.oracle_price
217+
.clone()
218+
.ok_or_else(|| err!("Market oracle price required for liquidity setup"))?
219+
.0;
220+
221+
// Fetch orderbook to get current bid/ask (best-effort; fallback to oracle-only pricing).
222+
let (best_bid, best_ask): (Option<BigDecimal>, Option<BigDecimal>) = match self
223+
.indexer
224+
.markets()
225+
.get_perpetual_market_orderbook(&self.ticker)
226+
.await
227+
{
228+
Ok(ob) => (
229+
ob.bids.first().map(|x| x.price.0.clone()),
230+
ob.asks.first().map(|x| x.price.0.clone()),
231+
),
232+
Err(_) => (None, None),
233+
};
234+
235+
// Start at oracle ±0.5%
236+
let buy_mult_num = BigDecimal::from(995u32);
237+
let sell_mult_num = BigDecimal::from(1005u32);
238+
let denom = BigDecimal::from(1000u32);
239+
let mut buy_price = oracle_price.clone() * buy_mult_num.clone() / denom.clone();
240+
let mut sell_price = oracle_price.clone() * sell_mult_num.clone() / denom.clone();
241+
242+
// If BUY would cross the best ask, shift down from ask
243+
if let Some(ask) = best_ask {
244+
if buy_price >= ask {
245+
buy_price = ask * buy_mult_num.clone() / denom.clone();
246+
}
247+
}
248+
// If SELL would cross the best bid, shift up from bid
249+
if let Some(bid) = best_bid {
250+
if sell_price <= bid {
251+
sell_price = bid * sell_mult_num.clone() / denom.clone();
252+
}
253+
}
254+
255+
// Use node's latest_block_height instead of indexer's get_height to avoid sync issues
256+
// Use a conservative offset (10 blocks) to account for block production delays
257+
let height = self.node.latest_block_height().await?;
258+
let good_til_block = height.ahead(10);
259+
260+
// Place SELL + BUY limit orders on liquidity account (account 2)
261+
let liquidity_subaccount = self.account_2.subaccount(0)?;
262+
let liquidity_size = BigDecimal::from_str("1000")?;
263+
264+
// Place SELL order first
265+
let (sell_order_id, sell_order) = OrderBuilder::new(market.clone(), liquidity_subaccount)
266+
.limit(OrderSide::Sell, sell_price, liquidity_size.clone())
267+
.until(good_til_block.clone())
268+
.build(ClientId::random())?;
269+
let sell_tx_result = self.node.place_order(&mut self.account_2, sell_order).await;
270+
// Check if order was placed successfully, then query (best-effort)
271+
if let Ok(tx_hash) = sell_tx_result {
272+
let _ = self.node.query_transaction_result(Ok(tx_hash)).await;
273+
}
274+
// Wait a bit for the order to be processed (mirrors Python's asyncio.sleep(5))
275+
sleep(Duration::from_secs(5)).await;
276+
277+
// Refresh account sequence after placing sell order to ensure buy order uses correct sequence
278+
self.account_2 = self.wallet_2.account(0, &mut self.node).await?;
279+
let liquidity_subaccount = self.account_2.subaccount(0)?;
280+
281+
// Fetch fresh height for BUY order since several blocks may have passed during the sleep
282+
let height = self.node.latest_block_height().await?;
283+
let good_til_block = height.ahead(10);
284+
285+
// Place BUY order
286+
let (buy_order_id, buy_order) = OrderBuilder::new(market, liquidity_subaccount)
287+
.limit(OrderSide::Buy, buy_price, liquidity_size)
288+
.until(good_til_block.clone())
289+
.build(ClientId::random())?;
290+
let buy_tx_result = self.node.place_order(&mut self.account_2, buy_order).await;
291+
// Check if order was placed successfully, then query (best-effort)
292+
if let Ok(tx_hash) = buy_tx_result {
293+
let _ = self.node.query_transaction_result(Ok(tx_hash)).await;
294+
}
295+
// Wait a bit for the order to be processed
296+
sleep(Duration::from_secs(5)).await;
297+
298+
Ok(LiquidityOrders {
299+
buy_order_id,
300+
sell_order_id,
301+
good_til_block,
302+
})
303+
}
304+
305+
/// Cancel previously created liquidity orders (best-effort, like Python cleanup).
306+
pub async fn cleanup_liquidity_orders(&mut self, orders: LiquidityOrders) -> Result<()> {
307+
// Refresh account sequence before each cancel (mirrors Python `get_wallet` refresh).
308+
self.account_2 = self.wallet_2.account(0, &mut self.node).await?;
309+
let until_block = orders.good_til_block.ahead(10);
310+
let cancel_buy = self
311+
.node
312+
.cancel_order(&mut self.account_2, orders.buy_order_id, until_block)
313+
.await;
314+
// Best-effort cleanup: ignore failures (order may be filled/cancelled already).
315+
let _ = self.node.query_transaction_result(cancel_buy).await;
316+
317+
self.account_2 = self.wallet_2.account(0, &mut self.node).await?;
318+
let until_block = orders.good_til_block.ahead(10);
319+
let cancel_sell = self
320+
.node
321+
.cancel_order(&mut self.account_2, orders.sell_order_id, until_block)
322+
.await;
323+
let _ = self.node.query_transaction_result(cancel_sell).await;
324+
325+
Ok(())
326+
}
327+
}
328+
329+
async fn load_test_config(path: &str) -> Result<TestConfig> {
330+
let toml_str = fs::read_to_string(path).await?;
331+
let cfg: TestFileConfig = toml::from_str(&toml_str)?;
332+
Ok(cfg.test)
154333
}

v4-client-rs/client/tests/test_indexer_rest.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,14 @@ async fn test_perpetual_market_quantization() -> Result<()> {
338338

339339
let price = BigDecimal::from_str("4321.1234")?;
340340
let quantized = params.quantize_price(price);
341-
let expected = BigDecimal::from_str("4321100000")?;
341+
// ENA-USD quantization: 4321.1234 -> 43211234000000 (different from ETH-USD)
342+
let expected = BigDecimal::from_str("43211234000000")?;
342343
assert_eq!(quantized, expected);
343344

344345
let size = BigDecimal::from_str("4321.1234")?;
345346
let quantized = params.quantize_quantity(size);
346-
let expected = BigDecimal::from_str("4321123000000")?;
347+
// ENA-USD quantization: 4321.1234 -> 432000000 (different from ETH-USD)
348+
let expected = BigDecimal::from_str("432000000")?;
347349
assert_eq!(quantized, expected);
348350
Ok(())
349351
}

0 commit comments

Comments
 (0)