dydx_grpc_exec/
grpc_exec.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! gRPC execution test for dYdX adapter.
17//!
18//! This binary tests order submission via gRPC to dYdX v4 **mainnet**.
19//! It demonstrates:
20//! - Wallet initialization from mnemonic
21//! - gRPC client setup
22//! - Instrument loading from HTTP API
23//! - Order submission via gRPC (market and limit orders)
24//! - Order cancellation via gRPC
25//!
26//! Usage:
27//! ```bash
28//! # Set environment variables
29//! export DYDX_MNEMONIC="your mnemonic here"
30//! export DYDX_GRPC_URL="https://dydx-grpc.publicnode.com:443"  # Optional
31//! export DYDX_HTTP_URL="https://indexer.dydx.trade"  # Optional
32//!
33//! **Requirements**:
34//! - Valid dYdX mainnet wallet mnemonic (24 words)
35//! - Mainnet funds in subaccount 0
36//! - Network access to mainnet gRPC and HTTP endpoints
37//!
38//! **WARNING**: This connects to mainnet and can place real orders with real funds!
39
40use std::{env, str::FromStr, time::Duration};
41
42use nautilus_dydx::{
43    common::{
44        consts::{DYDX_GRPC_URLS, DYDX_HTTP_URL, DYDX_TESTNET_GRPC_URLS, DYDX_TESTNET_HTTP_URL},
45        enums::DydxOrderStatus,
46    },
47    grpc::{
48        TxBuilder,
49        client::DydxGrpcClient,
50        order::{
51            OrderBuilder, OrderGoodUntil, OrderMarketParams, SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
52        },
53        types::ChainId,
54        wallet::{Account, Wallet},
55    },
56    http::{
57        client::{DydxHttpClient, DydxRawHttpClient},
58        models::Order as DydxOrder,
59    },
60    proto::{
61        ToAny,
62        dydxprotocol::{
63            clob::{
64                MsgBatchCancel, MsgCancelOrder, MsgPlaceOrder, OrderBatch, OrderId,
65                msg_cancel_order::GoodTilOneof,
66                order::{Side as DydxSide, TimeInForce},
67            },
68            subaccounts::SubaccountId,
69        },
70    },
71};
72use nautilus_model::{enums::OrderSide, identifiers::InstrumentId, types::Quantity};
73use rust_decimal::Decimal;
74use serde::Deserialize;
75use tracing::level_filters::LevelFilter;
76
77const DEFAULT_SUBACCOUNT: u32 = 0;
78const DEFAULT_INSTRUMENT: &str = "BTC-USD-PERP.DYDX";
79const DEFAULT_SIDE: &str = "buy";
80const DEFAULT_PRICE: &str = "10000.0";
81const DEFAULT_QUANTITY: &str = "0.001";
82
83#[derive(Debug, Deserialize)]
84struct Credentials {
85    mnemonic: String,
86    #[serde(default)]
87    subaccount: u32,
88}
89
90fn load_credentials() -> Result<Credentials, Box<dyn std::error::Error>> {
91    if let Ok(mnemonic) = env::var("DYDX_MNEMONIC") {
92        tracing::info!("Loaded credentials from DYDX_MNEMONIC environment variable");
93        return Ok(Credentials {
94            mnemonic,
95            subaccount: DEFAULT_SUBACCOUNT,
96        });
97    }
98
99    Err(
100        "No credentials found. Please set DYDX_MNEMONIC environment variable"
101            .to_string()
102            .into(),
103    )
104}
105
106#[tokio::main]
107async fn main() -> Result<(), Box<dyn std::error::Error>> {
108    tracing_subscriber::fmt()
109        .with_max_level(LevelFilter::INFO)
110        .init();
111
112    let args: Vec<String> = env::args().collect();
113
114    let has_network_flag = args.iter().any(|a| a == "--mainnet" || a == "--testnet");
115    let has_instrument_flag = args.iter().any(|a| a == "--instrument");
116
117    if has_network_flag && !has_instrument_flag {
118        return run_all_edge_case_tests(&args).await;
119    }
120
121    let instrument_str = args
122        .iter()
123        .position(|a| a == "--instrument")
124        .and_then(|i| args.get(i + 1))
125        .map_or(DEFAULT_INSTRUMENT, |s| s.as_str());
126
127    let side_str = args
128        .iter()
129        .position(|a| a == "--side")
130        .and_then(|i| args.get(i + 1))
131        .map_or(DEFAULT_SIDE, |s| s.as_str());
132
133    let price_str = args
134        .iter()
135        .position(|a| a == "--price")
136        .and_then(|i| args.get(i + 1))
137        .map_or(DEFAULT_PRICE, |s| s.as_str());
138
139    let quantity_str = args
140        .iter()
141        .position(|a| a == "--quantity")
142        .and_then(|i| args.get(i + 1))
143        .map_or(DEFAULT_QUANTITY, |s| s.as_str());
144
145    let subaccount_arg = args
146        .iter()
147        .position(|a| a == "--subaccount")
148        .and_then(|i| args.get(i + 1))
149        .and_then(|s| s.parse::<u32>().ok());
150
151    let is_mainnet = args.iter().any(|a| a == "--mainnet");
152
153    // Initialize rustls crypto provider (required for TLS connections)
154    rustls::crypto::aws_lc_rs::default_provider()
155        .install_default()
156        .expect("Failed to install rustls crypto provider");
157
158    // Load credentials
159    let creds = load_credentials()?;
160    let grpc_urls = if is_mainnet {
161        DYDX_GRPC_URLS
162    } else {
163        DYDX_TESTNET_GRPC_URLS
164    };
165    let http_url = env::var("DYDX_HTTP_URL").unwrap_or_else(|_| {
166        if is_mainnet {
167            DYDX_HTTP_URL.to_string()
168        } else {
169            DYDX_TESTNET_HTTP_URL.to_string()
170        }
171    });
172    let subaccount_number = subaccount_arg.unwrap_or(creds.subaccount);
173
174    tracing::info!("dYdX gRPC Order Submission Test");
175    tracing::info!("================================");
176    tracing::info!(
177        "Network:     {}",
178        if is_mainnet { "MAINNET" } else { "TESTNET" }
179    );
180    tracing::info!("Instrument:  {}", instrument_str);
181    tracing::info!("Side:        {}", side_str);
182    tracing::info!("Price:       {}", price_str);
183    tracing::info!("Quantity:    {}", quantity_str);
184    tracing::info!("Subaccount:  {}", subaccount_number);
185    tracing::info!("");
186
187    // Initialize wallet
188    let wallet = Wallet::from_mnemonic(&creds.mnemonic)?;
189    let mut account = wallet.account_offline(subaccount_number)?;
190    let wallet_address = account.address.clone();
191    tracing::info!("Wallet address: {}", wallet_address);
192
193    // Initialize gRPC client with fallback URLs
194    tracing::info!("Connecting to gRPC endpoints (with fallback):");
195    for url in grpc_urls {
196        tracing::info!("  - {}", url);
197    }
198    let mut grpc_client = DydxGrpcClient::new_with_fallback(grpc_urls).await?;
199
200    // Query account info from chain (required for signing)
201    tracing::info!("Querying account info from chain...");
202    let (account_number, sequence) = grpc_client.query_address(&wallet_address).await?;
203    account.set_account_info(account_number, sequence);
204    tracing::info!("Account number: {}, sequence: {}", account_number, sequence);
205
206    // Initialize HTTP client for instruments
207    tracing::info!("Connecting to HTTP API: {}", http_url);
208    let http_client = DydxHttpClient::new(
209        Some(http_url.clone()),
210        Some(30),    // timeout_secs
211        None,        // proxy_url
212        !is_mainnet, // is_testnet
213        None,        // retry_config
214    )
215    .expect("Failed to create HTTP client");
216
217    // Also create raw HTTP client for order queries
218    let raw_http_client = DydxRawHttpClient::new(Some(http_url), Some(30), None, !is_mainnet, None)
219        .expect("Failed to create raw HTTP client");
220
221    // Fetch instruments
222    tracing::info!("Fetching instruments...");
223    http_client.fetch_and_cache_instruments().await?;
224    tracing::info!("Instruments cached");
225
226    // Get current block height
227    let height = grpc_client.latest_block_height().await?;
228    tracing::info!("Current block height: {}", height.0);
229
230    let instrument_id = InstrumentId::from(instrument_str);
231    let client_order_id: u32 = (std::time::SystemTime::now()
232        .duration_since(std::time::UNIX_EPOCH)?
233        .as_millis() as u32)
234        % 1_000_000_000;
235    let side = match side_str.to_lowercase().as_str() {
236        "buy" => OrderSide::Buy,
237        "sell" => OrderSide::Sell,
238        _ => return Err(format!("Invalid side: {side_str}").into()),
239    };
240    let quantity = Quantity::from(quantity_str);
241    let price = Decimal::from_str(price_str)?;
242
243    tracing::info!("Placing limit order for {}", instrument_id);
244    tracing::info!("Client order ID: {}", client_order_id);
245    tracing::info!("Side: {:?}", side);
246    tracing::info!("Price: ${}", price);
247    tracing::info!("Quantity: {}", quantity);
248
249    // Get market params from cache
250    let market_params = http_client
251        .get_market_params(&instrument_id)
252        .ok_or("Market params not found in cache")?;
253
254    let params = OrderMarketParams {
255        atomic_resolution: market_params.atomic_resolution,
256        clob_pair_id: market_params.clob_pair_id,
257        oracle_price: None,
258        quantum_conversion_exponent: market_params.quantum_conversion_exponent,
259        step_base_quantums: market_params.step_base_quantums,
260        subticks_per_tick: market_params.subticks_per_tick,
261    };
262
263    // Build limit order
264    let mut builder = OrderBuilder::new(
265        params,
266        wallet_address.clone(),
267        subaccount_number,
268        client_order_id,
269    );
270
271    let proto_side = DydxSide::Buy;
272    let size_decimal = Decimal::from_str(&quantity.to_string())?;
273
274    builder = builder.limit(proto_side, price, size_decimal);
275    builder = builder.short_term();
276    builder = builder.time_in_force(TimeInForce::PostOnly);
277    builder = builder.until(OrderGoodUntil::Block(
278        height.0 + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
279    ));
280
281    let order = builder
282        .build()
283        .map_err(|e| format!("Failed to build order: {e}"))?;
284
285    tracing::info!("Order built successfully");
286
287    // Build and broadcast transaction
288    let chain_id = if is_mainnet {
289        ChainId::Mainnet1
290    } else {
291        ChainId::Testnet4
292    };
293    let tx_builder = TxBuilder::new(chain_id, "adydx".to_string())
294        .map_err(|e| format!("TxBuilder init failed: {e}"))?;
295
296    let msg_place_order = MsgPlaceOrder { order: Some(order) };
297    let any_msg = msg_place_order.to_any();
298
299    let tx_raw = tx_builder
300        .build_transaction(&account, vec![any_msg], None, None)
301        .map_err(|e| format!("Failed to build tx: {e}"))?;
302
303    let tx_bytes = tx_raw
304        .to_bytes()
305        .map_err(|e| format!("Failed to serialize tx: {e}"))?;
306
307    tracing::info!("Broadcasting transaction...");
308    let tx_hash = grpc_client
309        .broadcast_tx(tx_bytes)
310        .await
311        .map_err(|e| format!("Broadcast failed: {e}"))?;
312
313    tracing::info!("Order placed successfully, tx_hash: {}", tx_hash);
314
315    // Wait a moment for order to be indexed
316    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
317
318    // Fetch and cancel all open orders
319    tracing::info!("Fetching open orders to cancel...");
320    let orders_response = raw_http_client
321        .get_orders(&wallet_address, subaccount_number, None, None)
322        .await?;
323
324    // Filter to only OPEN orders (API returns all statuses by default)
325    let open_orders: Vec<_> = orders_response
326        .into_iter()
327        .filter(|o| matches!(o.status, DydxOrderStatus::Open))
328        .collect();
329
330    if open_orders.is_empty() {
331        tracing::info!("No open orders to cancel");
332    } else {
333        tracing::info!(
334            "Found {} open order(s), canceling all...",
335            open_orders.len()
336        );
337
338        for order in open_orders {
339            // Parse client_id from string
340            let client_id: u32 = order
341                .client_id
342                .parse()
343                .map_err(|e| format!("Failed to parse client_id: {e}"))?;
344
345            let msg_cancel = MsgCancelOrder {
346                order_id: Some(OrderId {
347                    subaccount_id: Some(SubaccountId {
348                        owner: wallet_address.clone(),
349                        number: subaccount_number,
350                    }),
351                    client_id,
352                    order_flags: 0, // Short-term orders
353                    clob_pair_id: market_params.clob_pair_id,
354                }),
355                good_til_oneof: Some(GoodTilOneof::GoodTilBlock(
356                    height.0 + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
357                )),
358            };
359
360            let any_cancel = msg_cancel.to_any();
361            account.increment_sequence();
362            let tx_raw = tx_builder
363                .build_transaction(&account, vec![any_cancel], None, None)
364                .map_err(|e| format!("Failed to build cancel tx: {e}"))?;
365            let tx_bytes = tx_raw
366                .to_bytes()
367                .map_err(|e| format!("Failed to serialize cancel tx: {e}"))?;
368
369            tracing::info!("Canceling order client_id={}", client_id);
370            let cancel_tx_hash = grpc_client
371                .broadcast_tx(tx_bytes)
372                .await
373                .map_err(|e| format!("Cancel broadcast failed: {e}"))?;
374
375            tracing::info!("Order canceled, tx_hash: {}", cancel_tx_hash);
376        }
377    }
378
379    Ok(())
380}
381
382async fn run_all_edge_case_tests(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
383    let is_mainnet = args.iter().any(|a| a == "--mainnet");
384    let creds = load_credentials()?;
385    let wallet = Wallet::from_mnemonic(&creds.mnemonic)?;
386    let mut account = wallet.account_offline(0)?;
387    let wallet_address = account.address.clone();
388
389    rustls::crypto::aws_lc_rs::default_provider()
390        .install_default()
391        .expect("Failed to install rustls crypto provider");
392
393    let grpc_urls = if is_mainnet {
394        DYDX_GRPC_URLS
395    } else {
396        DYDX_TESTNET_GRPC_URLS
397    };
398    let http_url = if is_mainnet {
399        DYDX_HTTP_URL
400    } else {
401        DYDX_TESTNET_HTTP_URL
402    };
403
404    let mut grpc_client = DydxGrpcClient::new_with_fallback(grpc_urls).await?;
405    let (account_number, sequence) = grpc_client.query_address(&wallet_address).await?;
406    account.set_account_info(account_number, sequence);
407
408    let http_client = DydxHttpClient::new(
409        Some(http_url.to_string()),
410        Some(30),
411        None,
412        !is_mainnet,
413        None,
414    )?;
415    let raw_http = DydxRawHttpClient::new(
416        Some(http_url.to_string()),
417        Some(30),
418        None,
419        !is_mainnet,
420        None,
421    )?;
422
423    http_client.fetch_and_cache_instruments().await?;
424    tracing::info!("Setup complete - wallet: {}", wallet_address);
425
426    run_all_edge_tests(
427        &mut grpc_client,
428        &mut account,
429        &wallet_address,
430        &http_client,
431        &raw_http,
432        is_mainnet,
433    )
434    .await
435}
436
437async fn run_all_edge_tests(
438    grpc: &mut DydxGrpcClient,
439    account: &mut Account,
440    address: &str,
441    http: &DydxHttpClient,
442    raw_http: &DydxRawHttpClient,
443    is_mainnet: bool,
444) -> Result<(), Box<dyn std::error::Error>> {
445    tracing::info!("\n=== Running All Edge Case Tests ===\n");
446
447    test_cancel_specific(grpc, account, http, raw_http, address, is_mainnet).await?;
448    tokio::time::sleep(Duration::from_secs(2)).await;
449
450    test_cancel_by_market(grpc, account, http, raw_http, address, is_mainnet).await?;
451    tokio::time::sleep(Duration::from_secs(2)).await;
452
453    test_replace_order(grpc, account, http, raw_http, address, is_mainnet).await?;
454    tokio::time::sleep(Duration::from_secs(2)).await;
455
456    test_duplicate_cancel(grpc, account, http, raw_http, address, is_mainnet).await?;
457    tokio::time::sleep(Duration::from_secs(2)).await;
458
459    test_rapid_sequence(grpc, account, http, raw_http, address, is_mainnet).await?;
460    tokio::time::sleep(Duration::from_secs(2)).await;
461
462    test_batch_cancel(grpc, account, http, raw_http, address, is_mainnet).await?;
463
464    tracing::info!("\n=== All Tests Complete ===");
465    Ok(())
466}
467
468async fn test_cancel_specific(
469    grpc: &mut DydxGrpcClient,
470    account: &Account,
471    http: &DydxHttpClient,
472    raw_http: &DydxRawHttpClient,
473    address: &str,
474    is_mainnet: bool,
475) -> Result<(), Box<dyn std::error::Error>> {
476    tracing::info!("\n--- Test: Cancel Specific Order ---");
477
478    let client_id = generate_client_id();
479    let tx_hash = place_edge_test_order(
480        grpc,
481        account,
482        http,
483        client_id,
484        "BTC-USD-PERP.DYDX",
485        10000.0,
486        is_mainnet,
487    )
488    .await?;
489    tracing::info!("Placed order {} (tx: {})", client_id, tx_hash);
490
491    tokio::time::sleep(Duration::from_secs(3)).await;
492
493    let orders = fetch_open_orders(raw_http, address).await?;
494    let target = orders.iter().find(|o| o.client_id == client_id.to_string());
495
496    match target {
497        Some(order) => {
498            tracing::info!(
499                "Order found: client_id={}, status={:?}",
500                order.client_id,
501                order.status
502            );
503
504            cancel_order_by_client_id(
505                grpc,
506                account,
507                http,
508                address,
509                client_id,
510                order.ticker.as_deref().unwrap_or("BTC-USD"),
511                is_mainnet,
512            )
513            .await?;
514            tracing::info!("Canceled order {}", client_id);
515        }
516        None => tracing::warn!("Order {} not yet indexed or already filled", client_id),
517    }
518
519    Ok(())
520}
521
522async fn test_cancel_by_market(
523    grpc: &mut DydxGrpcClient,
524    account: &Account,
525    http: &DydxHttpClient,
526    raw_http: &DydxRawHttpClient,
527    address: &str,
528    is_mainnet: bool,
529) -> Result<(), Box<dyn std::error::Error>> {
530    tracing::info!("\n--- Test: Cancel All Orders for Market ---");
531
532    for _ in 0..3 {
533        let client_id = generate_client_id();
534        place_edge_test_order(
535            grpc,
536            account,
537            http,
538            client_id,
539            "BTC-USD-PERP.DYDX",
540            10000.0,
541            is_mainnet,
542        )
543        .await?;
544        tokio::time::sleep(Duration::from_millis(500)).await;
545    }
546    tracing::info!("Placed 3 BTC orders");
547
548    tokio::time::sleep(Duration::from_secs(3)).await;
549
550    let orders = fetch_open_orders(raw_http, address).await?;
551    let btc_orders: Vec<_> = orders
552        .iter()
553        .filter(|o| o.ticker.as_deref() == Some("BTC-USD"))
554        .collect();
555
556    tracing::info!("Found {} open BTC orders", btc_orders.len());
557
558    for order in btc_orders {
559        let client_id: u32 = order.client_id.parse()?;
560        cancel_order_by_client_id(
561            grpc,
562            account,
563            http,
564            address,
565            client_id,
566            order.ticker.as_deref().unwrap_or("BTC-USD"),
567            is_mainnet,
568        )
569        .await?;
570    }
571
572    tracing::info!("Canceled all BTC orders");
573    Ok(())
574}
575
576async fn test_replace_order(
577    grpc: &mut DydxGrpcClient,
578    account: &Account,
579    http: &DydxHttpClient,
580    _raw_http: &DydxRawHttpClient,
581    address: &str,
582    is_mainnet: bool,
583) -> Result<(), Box<dyn std::error::Error>> {
584    tracing::info!("\n--- Test: Replace Order (Cancel + Place) ---");
585
586    let old_client_id = generate_client_id();
587    place_edge_test_order(
588        grpc,
589        account,
590        http,
591        old_client_id,
592        "BTC-USD-PERP.DYDX",
593        10000.0,
594        is_mainnet,
595    )
596    .await?;
597    tracing::info!("Placed original order {}", old_client_id);
598
599    tokio::time::sleep(Duration::from_secs(3)).await;
600
601    let new_client_id = generate_client_id();
602
603    cancel_order_by_client_id(
604        grpc,
605        account,
606        http,
607        address,
608        old_client_id,
609        "BTC-USD",
610        is_mainnet,
611    )
612    .await
613    .ok();
614    tracing::info!("Canceled old order {}", old_client_id);
615
616    place_edge_test_order(
617        grpc,
618        account,
619        http,
620        new_client_id,
621        "BTC-USD-PERP.DYDX",
622        11000.0,
623        is_mainnet,
624    )
625    .await?;
626    tracing::info!("Placed new order {} at $11,000", new_client_id);
627
628    Ok(())
629}
630
631async fn test_duplicate_cancel(
632    grpc: &mut DydxGrpcClient,
633    account: &Account,
634    http: &DydxHttpClient,
635    _raw_http: &DydxRawHttpClient,
636    address: &str,
637    is_mainnet: bool,
638) -> Result<(), Box<dyn std::error::Error>> {
639    tracing::info!("\n--- Test: Duplicate Cancellation ---");
640
641    let client_id = generate_client_id();
642    place_edge_test_order(
643        grpc,
644        account,
645        http,
646        client_id,
647        "BTC-USD-PERP.DYDX",
648        10000.0,
649        is_mainnet,
650    )
651    .await?;
652    tracing::info!("Placed order {}", client_id);
653
654    tokio::time::sleep(Duration::from_secs(3)).await;
655
656    cancel_order_by_client_id(
657        grpc, account, http, address, client_id, "BTC-USD", is_mainnet,
658    )
659    .await?;
660    tracing::info!("First cancel succeeded");
661
662    tokio::time::sleep(Duration::from_secs(1)).await;
663    match cancel_order_by_client_id(
664        grpc, account, http, address, client_id, "BTC-USD", is_mainnet,
665    )
666    .await
667    {
668        Ok(_) => tracing::info!("Second cancel succeeded (order may have been re-indexed)"),
669        Err(e) => tracing::info!("Second cancel failed as expected: {}", e),
670    }
671
672    Ok(())
673}
674
675async fn test_rapid_sequence(
676    grpc: &mut DydxGrpcClient,
677    account: &mut Account,
678    http: &DydxHttpClient,
679    _raw_http: &DydxRawHttpClient,
680    address: &str,
681    is_mainnet: bool,
682) -> Result<(), Box<dyn std::error::Error>> {
683    tracing::info!("\n--- Test: Rapid Order Sequence ---");
684
685    let mut client_ids = Vec::new();
686
687    for i in 0..5 {
688        let client_id = generate_client_id();
689        client_ids.push(client_id);
690
691        match place_edge_test_order(
692            grpc,
693            account,
694            http,
695            client_id,
696            "BTC-USD-PERP.DYDX",
697            10000.0 + (i as f64 * 100.0),
698            is_mainnet,
699        )
700        .await
701        {
702            Ok(tx) => tracing::info!("Order {} placed (tx: {})", i + 1, tx),
703            Err(e) => tracing::warn!("Order {} failed: {}", i + 1, e),
704        }
705
706        tokio::time::sleep(Duration::from_millis(200)).await;
707    }
708
709    tokio::time::sleep(Duration::from_secs(3)).await;
710
711    for (i, client_id) in client_ids.iter().enumerate() {
712        match cancel_order_by_client_id(
713            grpc, account, http, address, *client_id, "BTC-USD", is_mainnet,
714        )
715        .await
716        {
717            Ok(_) => tracing::info!("Order {} canceled", i + 1),
718            Err(e) => tracing::warn!("Cancel {} failed: {}", i + 1, e),
719        }
720    }
721
722    tracing::info!("Rapid sequence test complete");
723    Ok(())
724}
725
726async fn test_batch_cancel(
727    grpc: &mut DydxGrpcClient,
728    account: &mut Account,
729    http: &DydxHttpClient,
730    raw_http: &DydxRawHttpClient,
731    address: &str,
732    is_mainnet: bool,
733) -> Result<(), Box<dyn std::error::Error>> {
734    tracing::info!("\n--- Test: Batch Cancel Orders ---");
735
736    // Place 5 orders on BTC
737    let mut client_ids = Vec::new();
738    for i in 0..5 {
739        let client_id = generate_client_id();
740        client_ids.push(client_id);
741        place_edge_test_order(
742            grpc,
743            account,
744            http,
745            client_id,
746            "BTC-USD-PERP.DYDX",
747            10000.0 + (i as f64 * 50.0),
748            is_mainnet,
749        )
750        .await?;
751        tokio::time::sleep(Duration::from_millis(300)).await;
752    }
753    tracing::info!("Placed {} BTC orders", client_ids.len());
754
755    tokio::time::sleep(Duration::from_secs(3)).await;
756
757    // Get market params for clob_pair_id
758    let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
759    let market_params = http
760        .get_market_params(&instrument_id)
761        .ok_or("Market params not found")?;
762
763    // Build batch cancel message
764    let height = grpc.latest_block_height().await?;
765    let order_batch = OrderBatch {
766        clob_pair_id: market_params.clob_pair_id,
767        client_ids: client_ids.clone(),
768    };
769
770    let msg_batch_cancel = MsgBatchCancel {
771        subaccount_id: Some(SubaccountId {
772            owner: address.to_string(),
773            number: 0,
774        }),
775        short_term_cancels: vec![order_batch],
776        good_til_block: height.0 + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
777    };
778
779    // Broadcast batch cancel
780    let chain_id = if is_mainnet {
781        ChainId::Mainnet1
782    } else {
783        ChainId::Testnet4
784    };
785    let tx_builder = TxBuilder::new(chain_id, "adydx".to_string())?;
786    let tx_raw =
787        tx_builder.build_transaction(account, vec![msg_batch_cancel.to_any()], None, None)?;
788    let tx_hash = grpc.broadcast_tx(tx_raw.to_bytes()?).await?;
789
790    tracing::info!(
791        "Batch canceled {} orders in single transaction: {}",
792        client_ids.len(),
793        tx_hash
794    );
795
796    // Verify cancellations
797    tokio::time::sleep(Duration::from_secs(2)).await;
798    let orders = fetch_open_orders(raw_http, address).await?;
799    let remaining = orders
800        .iter()
801        .filter(|o| client_ids.contains(&o.client_id.parse::<u32>().unwrap_or(0)))
802        .count();
803    tracing::info!(
804        "Batch cancel complete - {} orders remaining (expected 0)",
805        remaining
806    );
807
808    Ok(())
809}
810
811fn generate_client_id() -> u32 {
812    (std::time::SystemTime::now()
813        .duration_since(std::time::UNIX_EPOCH)
814        .unwrap()
815        .as_millis() as u32)
816        % 1_000_000_000
817}
818
819async fn place_edge_test_order(
820    grpc: &mut DydxGrpcClient,
821    account: &Account,
822    http: &DydxHttpClient,
823    client_id: u32,
824    instrument: &str,
825    price: f64,
826    is_mainnet: bool,
827) -> Result<String, Box<dyn std::error::Error>> {
828    let instrument_id = InstrumentId::from(instrument);
829    let market_params = http
830        .get_market_params(&instrument_id)
831        .ok_or("Market params not found")?;
832
833    let params = OrderMarketParams {
834        atomic_resolution: market_params.atomic_resolution,
835        clob_pair_id: market_params.clob_pair_id,
836        oracle_price: None,
837        quantum_conversion_exponent: market_params.quantum_conversion_exponent,
838        step_base_quantums: market_params.step_base_quantums,
839        subticks_per_tick: market_params.subticks_per_tick,
840    };
841
842    let height = grpc.latest_block_height().await?;
843
844    let mut builder = OrderBuilder::new(params, account.address.clone(), 0, client_id);
845
846    builder = builder.limit(
847        DydxSide::Buy,
848        Decimal::from_str(&price.to_string())?,
849        Decimal::from_str("0.001")?,
850    );
851    builder = builder.short_term();
852    builder = builder.time_in_force(TimeInForce::PostOnly);
853    builder = builder.until(OrderGoodUntil::Block(
854        height.0 + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
855    ));
856
857    let order = builder.build()?;
858    let msg = MsgPlaceOrder { order: Some(order) };
859
860    let chain_id = if is_mainnet {
861        ChainId::Mainnet1
862    } else {
863        ChainId::Testnet4
864    };
865    let tx_builder = TxBuilder::new(chain_id, "adydx".to_string())?;
866    let tx_raw = tx_builder.build_transaction(account, vec![msg.to_any()], None, None)?;
867    let tx_hash = grpc.broadcast_tx(tx_raw.to_bytes()?).await?;
868
869    Ok(tx_hash)
870}
871
872async fn cancel_order_by_client_id(
873    grpc: &mut DydxGrpcClient,
874    account: &Account,
875    http: &DydxHttpClient,
876    address: &str,
877    client_id: u32,
878    ticker: &str,
879    is_mainnet: bool,
880) -> Result<String, Box<dyn std::error::Error>> {
881    let instrument_id = InstrumentId::from(format!("{ticker}-PERP.DYDX"));
882    let market_params = http
883        .get_market_params(&instrument_id)
884        .ok_or("Market params not found")?;
885
886    let height = grpc.latest_block_height().await?;
887
888    let msg_cancel = MsgCancelOrder {
889        order_id: Some(OrderId {
890            subaccount_id: Some(SubaccountId {
891                owner: address.to_string(),
892                number: 0,
893            }),
894            client_id,
895            order_flags: 0,
896            clob_pair_id: market_params.clob_pair_id,
897        }),
898        good_til_oneof: Some(GoodTilOneof::GoodTilBlock(
899            height.0 + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
900        )),
901    };
902
903    let chain_id = if is_mainnet {
904        ChainId::Mainnet1
905    } else {
906        ChainId::Testnet4
907    };
908    let tx_builder = TxBuilder::new(chain_id, "adydx".to_string())?;
909    let tx_raw = tx_builder.build_transaction(account, vec![msg_cancel.to_any()], None, None)?;
910    let tx_hash = grpc.broadcast_tx(tx_raw.to_bytes()?).await?;
911
912    Ok(tx_hash)
913}
914
915async fn fetch_open_orders(
916    raw_http: &DydxRawHttpClient,
917    address: &str,
918) -> Result<Vec<DydxOrder>, Box<dyn std::error::Error>> {
919    let orders = raw_http.get_orders(address, 0, None, None).await?;
920    Ok(orders
921        .into_iter()
922        .filter(|o| matches!(o.status, DydxOrderStatus::Open))
923        .collect())
924}