Skip to main content

nautilus_dydx/grpc/
builder.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 builder for dYdX v4 protocol.
17//!
18//! This module provides utilities for building and signing Cosmos SDK transactions
19//! for the dYdX v4 protocol, including support for permissioned key trading via
20//! authenticators.
21//!
22//! # Permissioned Keys
23//!
24//! dYdX supports permissioned keys (authenticators) that allow an account to add
25//! custom logic for verifying and confirming transactions. This enables features like:
26//!
27//! - Delegated signing keys for sub-accounts
28//! - Separated hot/cold wallet architectures
29//! - Trading key separation from withdrawal keys
30//!
31//! See <https://docs.dydx.xyz/concepts/trading/authenticators> for details.
32
33use std::fmt::Debug;
34
35use cosmrs::{
36    Any, Coin,
37    tendermint::chain::Id as ChainIdTendermint,
38    tx::{self, Fee, SignDoc, SignerInfo},
39};
40use dydx_proto::{ToAny, dydxprotocol::accountplus::TxExtension};
41use rust_decimal::{Decimal, prelude::ToPrimitive};
42
43use super::types::ChainId;
44use crate::execution::wallet::Account;
45
46/// Gas adjustment value to avoid rejected transactions caused by gas underestimation.
47const GAS_MULTIPLIER: f64 = 1.8;
48
49/// Transaction builder.
50///
51/// Handles fee calculation, transaction construction, and signing.
52pub struct TxBuilder {
53    chain_id: ChainIdTendermint,
54    fee_denom: String,
55}
56
57impl TxBuilder {
58    /// Create a new transaction builder.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the chain ID cannot be converted.
63    pub fn new(chain_id: ChainId, fee_denom: String) -> Result<Self, anyhow::Error> {
64        Ok(Self {
65            chain_id: chain_id.try_into()?,
66            fee_denom,
67        })
68    }
69
70    /// Estimate a transaction fee.
71    ///
72    /// See also [What Are Crypto Gas Fees?](https://dydx.exchange/crypto-learning/what-are-crypto-gas-fees).
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if fee calculation fails.
77    pub fn calculate_fee(&self, gas_used: Option<u64>) -> Result<Fee, anyhow::Error> {
78        if let Some(gas) = gas_used {
79            self.calculate_fee_from_gas(gas)
80        } else {
81            Ok(Self::default_fee())
82        }
83    }
84
85    /// Calculate fee from gas usage.
86    fn calculate_fee_from_gas(&self, gas_used: u64) -> Result<Fee, anyhow::Error> {
87        let gas_multiplier = Decimal::try_from(GAS_MULTIPLIER)?;
88        let gas_limit = Decimal::from(gas_used) * gas_multiplier;
89
90        // Gas price for dYdX (typically 0.025 adydx per gas)
91        let gas_price = Decimal::new(25, 3); // 0.025
92        let amount = (gas_price * gas_limit).ceil();
93
94        let gas_limit_u64 = gas_limit
95            .to_u64()
96            .ok_or_else(|| anyhow::anyhow!("Failed converting gas limit to u64"))?;
97
98        let amount_u128 = amount
99            .to_u128()
100            .ok_or_else(|| anyhow::anyhow!("Failed converting gas cost to u128"))?;
101
102        Ok(Fee::from_amount_and_gas(
103            Coin {
104                amount: amount_u128,
105                denom: self
106                    .fee_denom
107                    .parse()
108                    .map_err(|e| anyhow::anyhow!("Invalid fee denom: {e}"))?,
109            },
110            gas_limit_u64,
111        ))
112    }
113
114    /// Get default fee (zero fee).
115    fn default_fee() -> Fee {
116        Fee {
117            amount: vec![],
118            gas_limit: 0,
119            payer: None,
120            granter: None,
121        }
122    }
123
124    /// Build a transaction for given messages.
125    ///
126    /// When `authenticator_ids` is provided, the transaction will include a `TxExtension`
127    /// for permissioned key trading, allowing sub-accounts to trade using delegated keys.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if transaction building or signing fails.
132    pub fn build_transaction(
133        &self,
134        account: &Account,
135        msgs: impl IntoIterator<Item = Any>,
136        fee: Option<Fee>,
137        authenticator_ids: Option<&[u64]>,
138    ) -> Result<tx::Raw, anyhow::Error> {
139        let mut builder = tx::BodyBuilder::new();
140        builder.msgs(msgs).memo("");
141
142        // Add authenticators for permissioned key trading if provided
143        if let Some(auth_ids) = authenticator_ids
144            && !auth_ids.is_empty()
145        {
146            let ext = TxExtension {
147                selected_authenticators: auth_ids.to_vec(),
148            };
149            builder.non_critical_extension_option(ext.to_any());
150        }
151
152        let tx_body = builder.finish();
153
154        let fee = fee.unwrap_or_else(|| {
155            self.calculate_fee(None)
156                .unwrap_or_else(|_| Self::default_fee())
157        });
158
159        let auth_info =
160            SignerInfo::single_direct(Some(account.public_key()), account.sequence_number)
161                .auth_info(fee);
162
163        let sign_doc = SignDoc::new(&tx_body, &auth_info, &self.chain_id, account.account_number)
164            .map_err(|e| anyhow::anyhow!("Cannot create sign doc: {e}"))?;
165
166        account.sign(sign_doc)
167    }
168
169    /// Build and simulate a transaction to estimate gas.
170    ///
171    /// Returns the raw transaction bytes suitable for simulation.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if transaction building fails.
176    pub fn build_for_simulation(
177        &self,
178        account: &Account,
179        msgs: impl IntoIterator<Item = Any>,
180    ) -> Result<Vec<u8>, anyhow::Error> {
181        let tx_raw = self.build_transaction(account, msgs, None, None)?;
182        tx_raw.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))
183    }
184}
185
186impl Debug for TxBuilder {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct(stringify!(TxBuilder))
189            .field("chain_id", &self.chain_id)
190            .field("fee_denom", &self.fee_denom)
191            .finish()
192    }
193}