11#[ cfg( any( feature = "faucet" , feature = "noble" ) ) ]
22use anyhow:: anyhow as err;
33use anyhow:: { Error , Result } ;
4+ use bigdecimal:: BigDecimal ;
45use chrono:: { TimeDelta , Utc } ;
56#[ cfg( feature = "faucet" ) ]
67use 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 ;
1417use 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
2021static 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}
0 commit comments