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