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