Skip to main content

nautilus_dydx/execution/
tx_manager.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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//! Transaction manager for dYdX v4 protocol.
17//!
18//! This module provides centralized transaction management including:
19//! - Atomic sequence number tracking for stateful (long-term/conditional) orders
20//! - Transaction building and signing
21//! - Chain synchronization for sequence recovery
22//!
23//! # Sequence Management
24//!
25//! dYdX has two transaction types with different sequence behavior:
26//!
27//! - **Stateful orders** (long-term, conditional): Use Cosmos SDK sequences for replay
28//!   protection. Each transaction requires a unique, incrementing sequence number.
29//! - **Short-term orders**: Use Good-Til-Block (GTB) for replay protection. The chain's
30//!   `ClobDecorator` ante handler skips sequence checking, so sequences are not consumed.
31//!   Use [`TransactionManager::get_cached_sequence`] for these: it returns the current value
32//!   without incrementing.
33//!
34//! For stateful orders, this module provides:
35//! 1. `AtomicU64` for lock-free sequence allocation via [`TransactionManager::allocate_sequence`]
36//! 2. Lazy initialization from chain on first use
37//! 3. [`TransactionManager::resync_sequence`] for recovery after mismatch errors
38//! 4. Batch allocation via [`TransactionManager::allocate_sequences`] for parallel stateful
39//!    broadcasts
40
41use std::sync::{
42    Arc,
43    atomic::{AtomicU64, Ordering},
44};
45
46use cosmrs::Any;
47use parking_lot::RwLock;
48
49use super::{types::PreparedTransaction, wallet::Wallet};
50use crate::{
51    error::DydxError,
52    grpc::{DydxGrpcClient, TxBuilder, types::ChainId},
53    proto::AccountAuthenticator,
54};
55
56/// Sentinel value indicating sequence is uninitialized.
57pub const SEQUENCE_UNINITIALIZED: u64 = u64::MAX;
58
59/// Default fee denomination for dYdX transactions.
60const FEE_DENOM: &str = "adydx";
61
62/// Transaction manager responsible for wallet, sequence tracking, and transaction building.
63///
64/// This is the single source of truth for:
65/// - Wallet and signing operations
66/// - Sequence numbers (ensuring concurrent order operations don't race)
67/// - Authenticator resolution for permissioned key trading
68///
69/// # Thread Safety
70///
71/// All methods are safe to call from multiple tasks concurrently. Sequence
72/// allocation uses atomic compare-exchange operations for lock-free performance.
73#[derive(Debug)]
74pub struct TransactionManager {
75    /// gRPC client for chain queries.
76    grpc_client: DydxGrpcClient,
77    /// Wallet for transaction signing (created from private key).
78    wallet: Wallet,
79    /// Main account address (for account lookups).
80    /// May differ from wallet's signing address when using permissioned keys.
81    wallet_address: String,
82    /// Chain ID for transaction building.
83    chain_id: ChainId,
84    /// Authenticator IDs for permissioned key trading.
85    authenticator_ids: RwLock<Vec<u64>>,
86    /// Atomic sequence counter. Value `SEQUENCE_UNINITIALIZED` means uninitialized.
87    sequence_number: Arc<AtomicU64>,
88    /// Cached account number (never changes for a given address).
89    /// Value 0 means uninitialized.
90    account_number: AtomicU64,
91}
92
93impl TransactionManager {
94    /// Creates a new transaction manager.
95    ///
96    /// Creates wallet from private key internally. The sequence number is initialized
97    /// to `SEQUENCE_UNINITIALIZED` and will be fetched from chain on first use, or
98    /// can be proactively initialized by calling [`Self::initialize_sequence`].
99    ///
100    /// # Errors
101    ///
102    /// Returns error if wallet creation from private key fails.
103    pub fn new(
104        grpc_client: DydxGrpcClient,
105        private_key: &str,
106        wallet_address: String,
107        chain_id: ChainId,
108    ) -> Result<Self, DydxError> {
109        let wallet = Wallet::from_private_key(private_key)
110            .map_err(|e| DydxError::Wallet(format!("Failed to create wallet: {e}")))?;
111
112        Ok(Self {
113            grpc_client,
114            wallet,
115            wallet_address,
116            chain_id,
117            authenticator_ids: RwLock::new(Vec::new()),
118            sequence_number: Arc::new(AtomicU64::new(SEQUENCE_UNINITIALIZED)),
119            account_number: AtomicU64::new(0),
120        })
121    }
122
123    /// Proactively initializes the sequence number from chain.
124    ///
125    /// Call this during connect() to ensure orders can be submitted immediately
126    /// without first-transaction latency penalty. Also catches auth errors early.
127    ///
128    /// Returns the initialized sequence number.
129    ///
130    /// # Errors
131    ///
132    /// Returns error if chain query fails.
133    pub async fn initialize_sequence(&self) -> Result<u64, DydxError> {
134        let mut grpc = self.grpc_client.clone();
135        let base_account = grpc.get_account(&self.wallet_address).await.map_err(|e| {
136            DydxError::Grpc(Box::new(tonic::Status::internal(format!(
137                "Failed to fetch account for sequence init: {e}"
138            ))))
139        })?;
140
141        let chain_seq = base_account.sequence;
142        self.sequence_number.store(chain_seq, Ordering::SeqCst);
143        log::debug!("Initialized sequence from chain: {chain_seq}");
144        Ok(chain_seq)
145    }
146
147    /// Resolves authenticator IDs if using permissioned keys (API wallet).
148    ///
149    /// Compares the wallet's signing address with the main account address.
150    /// If they differ, fetches authenticators from chain and finds the one
151    /// matching this wallet's public key.
152    ///
153    /// Call this during connect() after creating the TransactionManager.
154    ///
155    /// # Errors
156    ///
157    /// Returns error if:
158    /// - Using permissioned key but no authenticators found for main account
159    /// - No authenticator matches the wallet's public key
160    /// - gRPC query fails
161    pub async fn resolve_authenticators(&self) -> Result<(), DydxError> {
162        // Check if we already have authenticator IDs configured
163        {
164            let ids = self.authenticator_ids.read();
165            if !ids.is_empty() {
166                log::debug!("Using pre-configured authenticator IDs: {:?}", *ids);
167                return Ok(());
168            }
169        }
170
171        // Get the wallet's address (derived from private key)
172        let account = self
173            .wallet
174            .account_offline()
175            .map_err(|e| DydxError::Wallet(format!("Failed to derive account: {e}")))?;
176        let signing_address = account.address.clone();
177        let signing_pubkey = account.public_key();
178
179        // Check if we're using an API wallet (signing address != main account)
180        if signing_address == self.wallet_address {
181            log::debug!(
182                "Signing wallet matches main account {}, no authenticator needed",
183                self.wallet_address
184            );
185            return Ok(());
186        }
187
188        log::debug!(
189            "Detected permissioned key setup: signing with {} for main account {}",
190            signing_address,
191            self.wallet_address
192        );
193
194        // Fetch authenticators for the main account
195        let mut grpc = self.grpc_client.clone();
196        let authenticators = grpc
197            .get_authenticators(&self.wallet_address)
198            .await
199            .map_err(|e| {
200                DydxError::Grpc(Box::new(tonic::Status::internal(format!(
201                    "Failed to fetch authenticators from chain: {e}"
202                ))))
203            })?;
204
205        if authenticators.is_empty() {
206            return Err(DydxError::Config(format!(
207                "No authenticators found for {}. \
208                 Please create an API Trading Key in the dYdX UI first.",
209                self.wallet_address
210            )));
211        }
212
213        log::debug!(
214            "Found {} authenticator(s) for {}",
215            authenticators.len(),
216            self.wallet_address
217        );
218
219        // Find authenticators matching the API wallet's public key
220        let signing_pubkey_bytes = signing_pubkey.to_bytes();
221        let signing_pubkey_b64 = base64::Engine::encode(
222            &base64::engine::general_purpose::STANDARD,
223            &signing_pubkey_bytes,
224        );
225
226        let mut matching_ids = Vec::new();
227
228        for auth in &authenticators {
229            if Self::authenticator_matches_pubkey(auth, &signing_pubkey_b64) {
230                matching_ids.push(auth.id);
231                log::debug!("Found matching authenticator: id={}", auth.id);
232            }
233        }
234
235        if matching_ids.is_empty() {
236            return Err(DydxError::Config(format!(
237                "No authenticator matches the API wallet's public key. \
238                 Ensure the API Trading Key was created for wallet {}. \
239                 Available authenticators: {:?}",
240                signing_address,
241                authenticators.iter().map(|a| a.id).collect::<Vec<_>>()
242            )));
243        }
244
245        // Store the resolved authenticator IDs
246        {
247            let mut ids = self.authenticator_ids.write();
248            *ids = matching_ids.clone();
249        }
250        log::debug!("Resolved authenticator IDs: {matching_ids:?}");
251
252        Ok(())
253    }
254
255    /// Checks if an authenticator contains a SignatureVerification matching the public key.
256    ///
257    /// Expected authenticator config format (JSON array of sub-authenticators):
258    /// ```json
259    /// [{"type": "SignatureVerification", "config": "<base64-pubkey>"}, ...]
260    /// ```
261    fn authenticator_matches_pubkey(auth: &AccountAuthenticator, pubkey_b64: &str) -> bool {
262        #[derive(serde::Deserialize)]
263        struct SubAuth {
264            #[serde(rename = "type")]
265            auth_type: String,
266            config: String,
267        }
268
269        // auth.config is raw bytes (Vec<u8>) containing JSON
270        let config_str = match String::from_utf8(auth.config.clone()) {
271            Ok(s) => s,
272            Err(e) => {
273                log::warn!(
274                    "Authenticator id={} has invalid UTF-8 config (len={}): {}",
275                    auth.id,
276                    auth.config.len(),
277                    e
278                );
279                return false;
280            }
281        };
282
283        log::debug!(
284            "Checking authenticator id={}, type={}, config={}",
285            auth.id,
286            auth.r#type,
287            config_str
288        );
289
290        match serde_json::from_str::<Vec<SubAuth>>(&config_str) {
291            Ok(sub_auths) => {
292                for sub in sub_auths {
293                    log::debug!(
294                        "  Sub-authenticator: type={}, config={}",
295                        sub.auth_type,
296                        sub.config
297                    );
298
299                    if sub.auth_type == "SignatureVerification" && sub.config == pubkey_b64 {
300                        log::debug!("  -> MATCH! pubkey_b64={pubkey_b64}");
301                        return true;
302                    }
303                }
304            }
305            Err(e) => {
306                log::warn!(
307                    "Authenticator id={} config is not in expected JSON array format: {} (config={})",
308                    auth.id,
309                    e,
310                    config_str
311                );
312            }
313        }
314
315        false
316    }
317
318    /// Allocates the next sequence number atomically.
319    ///
320    /// If the sequence is uninitialized (0), fetches from chain first.
321    /// Uses compare-exchange for lock-free concurrent access.
322    ///
323    /// # Errors
324    ///
325    /// Returns error if chain query fails during initialization.
326    pub async fn allocate_sequence(&self) -> Result<u64, DydxError> {
327        loop {
328            let current = self.sequence_number.load(Ordering::SeqCst);
329            if current == SEQUENCE_UNINITIALIZED {
330                // Initialize from chain
331                self.initialize_sequence_from_chain().await?;
332                continue;
333            }
334            // Atomic get-and-increment
335            if self
336                .sequence_number
337                .compare_exchange(current, current + 1, Ordering::SeqCst, Ordering::SeqCst)
338                .is_ok()
339            {
340                return Ok(current);
341            }
342            // Another thread modified it, retry
343        }
344    }
345
346    /// Allocates N sequence numbers for optimistic parallel broadcast.
347    ///
348    /// Returns a vector of consecutive sequences that can be used concurrently.
349    /// The caller is responsible for handling partial failures by resyncing.
350    ///
351    /// # Arguments
352    ///
353    /// * `count` - Number of sequences to allocate
354    ///
355    /// # Errors
356    ///
357    /// Returns error if chain query fails during initialization.
358    ///
359    /// # Example
360    ///
361    /// ```ignore
362    /// let sequences = tx_manager.allocate_sequences(3).await?;
363    /// // sequences = [10, 11, 12] - three consecutive sequence numbers
364    /// ```
365    pub async fn allocate_sequences(&self, count: usize) -> Result<Vec<u64>, DydxError> {
366        if count == 0 {
367            return Ok(Vec::new());
368        }
369
370        loop {
371            let current = self.sequence_number.load(Ordering::SeqCst);
372            if current == SEQUENCE_UNINITIALIZED {
373                self.initialize_sequence_from_chain().await?;
374                continue;
375            }
376            let new_value = current + count as u64;
377
378            if self
379                .sequence_number
380                .compare_exchange(current, new_value, Ordering::SeqCst, Ordering::SeqCst)
381                .is_ok()
382            {
383                return Ok((current..new_value).collect());
384            }
385            // Another thread modified it, retry
386        }
387    }
388
389    /// Initializes the sequence counter from chain state.
390    ///
391    /// Only sets the value if it's still 0 (another thread might have set it).
392    async fn initialize_sequence_from_chain(&self) -> Result<(), DydxError> {
393        let mut grpc = self.grpc_client.clone();
394        let base_account = grpc.get_account(&self.wallet_address).await.map_err(|e| {
395            DydxError::Grpc(Box::new(tonic::Status::internal(format!(
396                "Failed to fetch account for sequence init: {e}"
397            ))))
398        })?;
399
400        let chain_seq = base_account.sequence;
401        // Only set if still uninitialized (another thread might have set it)
402        if self
403            .sequence_number
404            .compare_exchange(
405                SEQUENCE_UNINITIALIZED,
406                chain_seq,
407                Ordering::SeqCst,
408                Ordering::SeqCst,
409            )
410            .is_ok()
411        {
412            log::debug!("Initialized sequence from chain: {chain_seq}");
413        }
414        Ok(())
415    }
416
417    /// Resyncs the sequence counter from chain after a mismatch error.
418    ///
419    /// Called by the broadcaster's retry logic when a sequence mismatch is detected.
420    /// Unconditionally stores the chain's current sequence.
421    ///
422    /// # Errors
423    ///
424    /// Returns error if chain query fails.
425    pub async fn resync_sequence(&self) -> Result<(), DydxError> {
426        let mut grpc = self.grpc_client.clone();
427        let base_account = grpc.get_account(&self.wallet_address).await.map_err(|e| {
428            DydxError::Grpc(Box::new(tonic::Status::internal(format!(
429                "Failed to fetch account for resync: {e}"
430            ))))
431        })?;
432
433        let chain_seq = base_account.sequence;
434        self.sequence_number.store(chain_seq, Ordering::SeqCst);
435        log::debug!("Resynced sequence from chain: {chain_seq}");
436        Ok(())
437    }
438
439    /// Returns the current sequence value without allocation.
440    ///
441    /// Useful for logging and debugging. Returns `SEQUENCE_UNINITIALIZED` if not yet initialized.
442    #[must_use]
443    pub fn current_sequence(&self) -> u64 {
444        self.sequence_number.load(Ordering::SeqCst)
445    }
446
447    /// Returns the cached sequence for short-term orders without incrementing.
448    ///
449    /// # Errors
450    ///
451    /// Returns error if chain query fails during initialization.
452    pub async fn get_cached_sequence(&self) -> Result<u64, DydxError> {
453        let current = self.sequence_number.load(Ordering::SeqCst);
454        if current == SEQUENCE_UNINITIALIZED {
455            self.initialize_sequence_from_chain().await?;
456            return Ok(self.sequence_number.load(Ordering::SeqCst));
457        }
458        Ok(current)
459    }
460
461    /// Builds and signs a transaction with the given messages and sequence.
462    ///
463    /// Uses cached account_number (fetched once from chain) to avoid repeated queries.
464    ///
465    /// # Arguments
466    ///
467    /// * `msgs` - Proto messages to include in transaction
468    /// * `sequence` - Pre-allocated sequence number
469    /// * `operation` - Human-readable name for logging
470    ///
471    /// # Errors
472    ///
473    /// Returns error if account lookup fails or transaction building fails.
474    pub async fn build_transaction(
475        &self,
476        msgs: Vec<Any>,
477        sequence: u64,
478        operation: &str,
479    ) -> Result<PreparedTransaction, DydxError> {
480        // Derive account for signing (address/account_id are cached in wallet)
481        let mut account = self
482            .wallet
483            .account_offline()
484            .map_err(|e| DydxError::Wallet(format!("Failed to derive account: {e}")))?;
485
486        // Read authenticator IDs (resolved during connect if using permissioned keys)
487        let auth_ids_snapshot: Vec<u64> = {
488            let ids = self.authenticator_ids.read();
489            ids.clone()
490        };
491
492        if !auth_ids_snapshot.is_empty() {
493            log::debug!(
494                "Using permissioned key mode: signing with {} for main account {}",
495                account.address,
496                self.wallet_address
497            );
498        }
499
500        // Get or cache account number (it never changes for a given address)
501        let account_num = self.get_or_fetch_account_number().await?;
502
503        // Set account info for signing
504        account.set_account_info(account_num, sequence);
505
506        // Build transaction
507        let tx_builder =
508            TxBuilder::new(self.chain_id.clone(), FEE_DENOM.to_string()).map_err(|e| {
509                DydxError::Grpc(Box::new(tonic::Status::internal(format!(
510                    "TxBuilder init failed: {e}"
511                ))))
512            })?;
513
514        // For permissioned key trading, each message needs an authenticator ID.
515        // Repeat the configured authenticator ID(s) for each message in the batch.
516        let expanded_auth_ids: Vec<u64> = if auth_ids_snapshot.is_empty() {
517            Vec::new()
518        } else {
519            // For each message, use the first authenticator ID
520            // (typically there's only one configured for the trading key)
521            std::iter::repeat_n(auth_ids_snapshot[0], msgs.len()).collect()
522        };
523
524        let auth_ids = if expanded_auth_ids.is_empty() {
525            None
526        } else {
527            Some(expanded_auth_ids.as_slice())
528        };
529
530        let tx_raw = tx_builder
531            .build_transaction(&account, msgs, None, auth_ids)
532            .map_err(|e| {
533                DydxError::Grpc(Box::new(tonic::Status::internal(format!(
534                    "Failed to build tx: {e}"
535                ))))
536            })?;
537
538        let tx_bytes = tx_raw.to_bytes().map_err(|e| {
539            DydxError::Grpc(Box::new(tonic::Status::internal(format!(
540                "Failed to serialize tx: {e}"
541            ))))
542        })?;
543
544        log::debug!(
545            "Built {} with {} bytes, sequence={}",
546            operation,
547            tx_bytes.len(),
548            sequence
549        );
550
551        Ok(PreparedTransaction {
552            tx_bytes,
553            sequence,
554            operation: operation.to_string(),
555        })
556    }
557
558    /// Gets the cached account number, or fetches it from chain if not yet cached.
559    ///
560    /// Account numbers are immutable on-chain, so we only need to fetch once.
561    async fn get_or_fetch_account_number(&self) -> Result<u64, DydxError> {
562        let cached = self.account_number.load(Ordering::SeqCst);
563        if cached != 0 {
564            return Ok(cached);
565        }
566
567        // Fetch from chain
568        let mut grpc = self.grpc_client.clone();
569        let base_account = grpc.get_account(&self.wallet_address).await.map_err(|e| {
570            DydxError::Grpc(Box::new(tonic::Status::internal(format!(
571                "Failed to fetch account: {e}"
572            ))))
573        })?;
574
575        let account_num = base_account.account_number;
576
577        // Cache it (CAS to handle concurrent fetches)
578        let _ = self.account_number.compare_exchange(
579            0,
580            account_num,
581            Ordering::SeqCst,
582            Ordering::SeqCst,
583        );
584
585        log::debug!("Cached account_number from chain: {account_num}");
586        Ok(account_num)
587    }
588
589    /// Convenience method: allocate sequence, build, and return prepared transaction.
590    ///
591    /// This is the typical flow for single transaction submission.
592    ///
593    /// # Arguments
594    ///
595    /// * `msgs` - Proto messages to include in transaction
596    /// * `operation` - Human-readable name for logging
597    ///
598    /// # Errors
599    ///
600    /// Returns error if sequence allocation or transaction building fails.
601    pub async fn prepare_transaction(
602        &self,
603        msgs: Vec<Any>,
604        operation: &str,
605    ) -> Result<PreparedTransaction, DydxError> {
606        let sequence = self.allocate_sequence().await?;
607        self.build_transaction(msgs, sequence, operation).await
608    }
609
610    /// Returns the wallet address.
611    #[must_use]
612    pub fn wallet_address(&self) -> &str {
613        &self.wallet_address
614    }
615
616    /// Returns the chain ID.
617    #[must_use]
618    pub fn chain_id(&self) -> &ChainId {
619        &self.chain_id
620    }
621}