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