Skip to main content

nautilus_dydx/execution/
wallet.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//! Wallet and account management for dYdX v4.
17//!
18//! This module provides wallet functionality for managing signing keys for Cosmos SDK transactions.
19//! Wallets are created from hex-encoded private keys.
20
21use std::fmt::Debug;
22
23use anyhow::Context;
24use cosmrs::{
25    AccountId,
26    crypto::{PublicKey, secp256k1::SigningKey},
27    tx,
28};
29use nautilus_core::{hex, string::secret::REDACTED};
30use zeroize::{Zeroize, ZeroizeOnDrop};
31
32/// Account prefix for dYdX addresses.
33///
34/// See [Cosmos accounts](https://docs.cosmos.network/sdk/latest/learn).
35const BECH32_PREFIX_DYDX: &str = "dydx";
36
37/// Wallet for dYdX v4 transaction signing.
38///
39/// A wallet holds a secp256k1 private key used to sign Cosmos SDK transactions.
40/// The private key bytes are stored to allow recreating SigningKey (which doesn't
41/// implement Clone). Address and account_id are pre-computed during construction
42/// to avoid repeated derivation.
43///
44/// # Security
45///
46/// Private key bytes should be treated as sensitive material.
47#[derive(Zeroize, ZeroizeOnDrop)]
48pub struct Wallet {
49    /// Raw private key bytes (32 bytes for secp256k1).
50    /// Stored separately because SigningKey doesn't implement Clone or expose bytes.
51    private_key_bytes: Box<[u8]>,
52    /// Pre-computed dYdX address (bech32 encoded).
53    #[zeroize(skip)]
54    address: String,
55    /// Pre-computed Cosmos SDK account ID.
56    #[zeroize(skip)]
57    account_id: AccountId,
58}
59
60impl Debug for Wallet {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct(stringify!(Wallet))
63            .field("private_key_bytes", &REDACTED)
64            .field("address", &self.address)
65            .finish()
66    }
67}
68
69impl Clone for Wallet {
70    fn clone(&self) -> Self {
71        Self {
72            private_key_bytes: self.private_key_bytes.clone(),
73            address: self.address.clone(),
74            account_id: self.account_id.clone(),
75        }
76    }
77}
78
79impl Wallet {
80    /// Create a wallet from a hex-encoded private key.
81    ///
82    /// The private key should be a 32-byte secp256k1 key encoded as hex,
83    /// optionally with a `0x` prefix. Address and account ID are derived
84    /// during construction.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the private key is invalid hex or not a valid secp256k1 key.
89    pub fn from_private_key(private_key_hex: &str) -> anyhow::Result<Self> {
90        let key_bytes = hex::decode(private_key_hex.trim_start_matches("0x"))
91            .context("Invalid hex private key")?;
92
93        // Validate the key and derive address/account_id
94        let signing_key = SigningKey::from_slice(&key_bytes)
95            .map_err(|e| anyhow::anyhow!("Invalid secp256k1 private key: {e}"))?;
96
97        let public_key = signing_key.public_key();
98        let account_id = public_key
99            .account_id(BECH32_PREFIX_DYDX)
100            .map_err(|e| anyhow::anyhow!("Failed to derive account ID: {e}"))?;
101        let address = account_id.to_string();
102
103        Ok(Self {
104            private_key_bytes: key_bytes.into_boxed_slice(),
105            address,
106            account_id,
107        })
108    }
109
110    /// Get a dYdX account with zero account and sequence numbers.
111    ///
112    /// Creates an account using the pre-computed address/account_id.
113    /// SigningKey is recreated from stored bytes (it doesn't implement Clone).
114    /// Account and sequence numbers must be set before signing.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the signing key creation fails.
119    pub fn account_offline(&self) -> Result<Account, anyhow::Error> {
120        // SigningKey doesn't impl Clone, so recreate from stored bytes
121        let key = SigningKey::from_slice(&self.private_key_bytes)
122            .map_err(|e| anyhow::anyhow!("Failed to create signing key: {e}"))?;
123
124        Ok(Account {
125            address: self.address.clone(),
126            account_id: self.account_id.clone(),
127            key,
128            account_number: 0,
129            sequence_number: 0,
130        })
131    }
132
133    /// Returns the pre-computed wallet address.
134    #[must_use]
135    pub fn address(&self) -> &str {
136        &self.address
137    }
138}
139
140/// Represents a dYdX account.
141///
142/// An account contains the signing key and metadata needed to sign and broadcast transactions.
143/// The `account_number` and `sequence_number` must be set from on-chain data before signing.
144///
145/// See also [`Wallet`].
146pub struct Account {
147    /// dYdX address (bech32 encoded).
148    pub address: String,
149    /// Cosmos SDK account ID.
150    pub account_id: AccountId,
151    /// Private signing key.
152    key: SigningKey,
153    /// On-chain account number (must be fetched before signing).
154    pub account_number: u64,
155    /// Transaction sequence number (must be fetched before signing).
156    pub sequence_number: u64,
157}
158
159impl Debug for Account {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct(stringify!(Account))
162            .field("address", &self.address)
163            .field("account_id", &self.account_id)
164            .field("key", &REDACTED)
165            .field("account_number", &self.account_number)
166            .field("sequence_number", &self.sequence_number)
167            .finish()
168    }
169}
170
171impl Account {
172    /// Get the public key associated with this account.
173    #[must_use]
174    pub fn public_key(&self) -> PublicKey {
175        self.key.public_key()
176    }
177
178    /// Sign a [`SignDoc`](tx::SignDoc) with the private key.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if signing fails.
183    pub fn sign(&self, doc: tx::SignDoc) -> Result<tx::Raw, anyhow::Error> {
184        doc.sign(&self.key)
185            .map_err(|e| anyhow::anyhow!("Failed to sign transaction: {e}"))
186    }
187
188    /// Update account and sequence numbers from on-chain data.
189    pub fn set_account_info(&mut self, account_number: u64, sequence_number: u64) {
190        self.account_number = account_number;
191        self.sequence_number = sequence_number;
192    }
193
194    /// Increment the sequence number (used after successful transaction broadcast).
195    pub fn increment_sequence(&mut self) {
196        self.sequence_number += 1;
197    }
198
199    /// Derive a subaccount for this account.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the subaccount number is invalid.
204    pub fn subaccount(&self, number: u32) -> Result<Subaccount, anyhow::Error> {
205        Ok(Subaccount {
206            address: self.address.clone(),
207            number,
208        })
209    }
210}
211
212/// A subaccount within a dYdX account.
213///
214/// Each account can have multiple subaccounts for organizing positions and balances.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct Subaccount {
217    /// Parent account address.
218    pub address: String,
219    /// Subaccount number.
220    pub number: u32,
221}
222
223impl Subaccount {
224    /// Create a new subaccount.
225    #[must_use]
226    pub fn new(address: String, number: u32) -> Self {
227        Self { address, number }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use rstest::rstest;
234
235    use super::*;
236
237    #[rstest]
238    fn wallet_zeroize_clears_private_key_bytes() {
239        let private_key = hex::encode([1_u8; 32]);
240        let mut wallet = Wallet::from_private_key(&private_key).unwrap();
241
242        wallet.zeroize();
243
244        assert!(wallet.private_key_bytes.iter().all(|byte| *byte == 0));
245    }
246}