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}