Skip to main content

nautilus_derive/common/
credential.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//! Derive credential storage.
17//!
18//! Derive identifies a trading account through a three-part tuple rather than a
19//! single API key:
20//!
21//! 1. `wallet_address`: the owner's EOA or multisig address, sent in the
22//!    `X-DeriveWallet` header and the `owner` slot of every signed action.
23//! 2. `session_key`: a secp256k1 private key registered to the wallet. Signs
24//!    REST/WS auth headers and EIP-712 typed-data actions. May be the owner
25//!    EOA's key but is more commonly a scoped session key.
26//! 3. `subaccount_id`: per-wallet integer slot that holds the positions and
27//!    signs each `private/order` request.
28//!
29//! # Credential resolution
30//!
31//! Credentials are resolved in the following priority order:
32//!
33//! 1. Explicit values from config
34//! 2. `DERIVE_WALLET_ADDRESS` / `DERIVE_SESSION_PRIVATE_KEY` / `DERIVE_SUBACCOUNT_ID`
35//!    env vars (or the `_TESTNET_` variants when targeting testnet)
36//!
37//! The session-key bytes are zeroized on drop.
38
39use std::fmt::{Debug, Display};
40
41use anyhow::Context;
42use nautilus_core::{
43    env::{get_or_env_var, get_or_env_var_opt},
44    string::secret::REDACTED,
45};
46use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
47
48use crate::common::enums::DeriveEnvironment;
49
50/// Returns the environment-variable triple `(wallet, session_key, subaccount)`
51/// for the given environment.
52#[must_use]
53pub fn credential_env_vars(
54    environment: DeriveEnvironment,
55) -> (&'static str, &'static str, &'static str) {
56    match environment {
57        DeriveEnvironment::Mainnet => (
58            "DERIVE_WALLET_ADDRESS",
59            "DERIVE_SESSION_PRIVATE_KEY",
60            "DERIVE_SUBACCOUNT_ID",
61        ),
62        DeriveEnvironment::Testnet => (
63            "DERIVE_TESTNET_WALLET_ADDRESS",
64            "DERIVE_TESTNET_SESSION_PRIVATE_KEY",
65            "DERIVE_TESTNET_SUBACCOUNT_ID",
66        ),
67    }
68}
69
70/// Owner wallet + session-key + subaccount triple.
71#[derive(Clone, Zeroize, ZeroizeOnDrop)]
72pub struct DeriveCredential {
73    wallet_address: String,
74    session_key: String,
75    #[zeroize(skip)]
76    subaccount_id: u64,
77}
78
79impl DeriveCredential {
80    /// Creates a new [`DeriveCredential`] instance.
81    #[must_use]
82    pub fn new(wallet_address: String, session_key: String, subaccount_id: u64) -> Self {
83        Self {
84            wallet_address,
85            session_key,
86            subaccount_id,
87        }
88    }
89
90    /// Returns the owner's EOA or multisig address (`X-DeriveWallet`).
91    #[must_use]
92    pub fn wallet_address(&self) -> &str {
93        &self.wallet_address
94    }
95
96    /// Returns the secp256k1 session-key private key (hex-encoded).
97    #[must_use]
98    pub fn session_key(&self) -> &str {
99        &self.session_key
100    }
101
102    /// Returns the subaccount integer ID.
103    #[must_use]
104    pub const fn subaccount_id(&self) -> u64 {
105        self.subaccount_id
106    }
107
108    /// Resolves a [`DeriveCredential`] from explicit values, falling back to
109    /// the documented environment variables when fields are unset.
110    ///
111    /// Resolution order per field is: explicit value, then env var. The env
112    /// var name set is selected by `environment` via [`credential_env_vars`].
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if any of the wallet address, session key, or subaccount ID cannot be
117    /// resolved from either source, or when the subaccount id env var is not a valid `u64`.
118    pub fn resolve(
119        wallet_address: Option<String>,
120        session_key: Option<String>,
121        subaccount_id: Option<u64>,
122        environment: DeriveEnvironment,
123    ) -> anyhow::Result<Self> {
124        let session_key = session_key.map(Zeroizing::new);
125        let (wallet_var, key_var, subaccount_var) = credential_env_vars(environment);
126
127        let wallet_address = get_or_env_var(wallet_address, wallet_var).with_context(|| {
128            format!("Derive wallet address missing (set {wallet_var} or config)")
129        })?;
130
131        let mut session_key = match session_key {
132            Some(key) => key,
133            None => Zeroizing::new(get_or_env_var(None, key_var).with_context(|| {
134                format!("Derive session key missing (set {key_var} or config)")
135            })?),
136        };
137
138        let subaccount_id = match subaccount_id {
139            Some(id) => id,
140            None => get_or_env_var_opt(None, subaccount_var)
141                .with_context(|| {
142                    format!("Derive subaccount id missing (set {subaccount_var} or config)")
143                })?
144                .parse::<u64>()
145                .with_context(|| format!("failed to parse {subaccount_var} as u64"))?,
146        };
147
148        Ok(Self::new(
149            wallet_address,
150            std::mem::take(&mut *session_key),
151            subaccount_id,
152        ))
153    }
154}
155
156impl Debug for DeriveCredential {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct(stringify!(DeriveCredential))
159            .field("wallet_address", &self.wallet_address)
160            .field("session_key", &REDACTED)
161            .field("subaccount_id", &self.subaccount_id)
162            .finish()
163    }
164}
165
166impl Display for DeriveCredential {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        write!(
169            f,
170            "DeriveCredential(wallet={}, subaccount={})",
171            self.wallet_address, self.subaccount_id
172        )
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use std::{env, process::Command};
179
180    use rstest::rstest;
181
182    use super::*;
183
184    const TEST_WALLET: &str = "0x0000000000000000000000000000000000001234";
185    const TEST_SESSION_KEY: &str =
186        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
187    const TEST_SUBACCOUNT: u64 = 30769;
188
189    #[rstest]
190    fn test_credential_debug_redacts_session_key() {
191        let cred = DeriveCredential::new(
192            TEST_WALLET.to_string(),
193            TEST_SESSION_KEY.to_string(),
194            TEST_SUBACCOUNT,
195        );
196        let debug = format!("{cred:?}");
197        assert!(debug.contains("redacted"));
198        assert!(!debug.contains(TEST_SESSION_KEY));
199        assert!(debug.contains(TEST_WALLET));
200        assert!(debug.contains(&TEST_SUBACCOUNT.to_string()));
201    }
202
203    #[rstest]
204    fn test_credential_display_omits_session_key() {
205        let cred = DeriveCredential::new(
206            TEST_WALLET.to_string(),
207            TEST_SESSION_KEY.to_string(),
208            TEST_SUBACCOUNT,
209        );
210        let display = format!("{cred}");
211        assert!(display.contains(TEST_WALLET));
212        assert!(!display.contains(TEST_SESSION_KEY));
213    }
214
215    #[rstest]
216    fn test_credential_env_vars_for_mainnet() {
217        let (wallet, key, sub) = credential_env_vars(DeriveEnvironment::Mainnet);
218        assert_eq!(wallet, "DERIVE_WALLET_ADDRESS");
219        assert_eq!(key, "DERIVE_SESSION_PRIVATE_KEY");
220        assert_eq!(sub, "DERIVE_SUBACCOUNT_ID");
221    }
222
223    #[rstest]
224    fn test_credential_env_vars_for_testnet() {
225        let (wallet, key, sub) = credential_env_vars(DeriveEnvironment::Testnet);
226        assert_eq!(wallet, "DERIVE_TESTNET_WALLET_ADDRESS");
227        assert_eq!(key, "DERIVE_TESTNET_SESSION_PRIVATE_KEY");
228        assert_eq!(sub, "DERIVE_TESTNET_SUBACCOUNT_ID");
229    }
230
231    #[rstest]
232    fn test_credential_accessors() {
233        let cred = DeriveCredential::new(
234            TEST_WALLET.to_string(),
235            TEST_SESSION_KEY.to_string(),
236            TEST_SUBACCOUNT,
237        );
238        assert_eq!(cred.wallet_address(), TEST_WALLET);
239        assert_eq!(cred.session_key(), TEST_SESSION_KEY);
240        assert_eq!(cred.subaccount_id(), TEST_SUBACCOUNT);
241    }
242
243    #[rstest]
244    fn test_resolve_prefers_explicit_values() {
245        let cred = DeriveCredential::resolve(
246            Some(TEST_WALLET.to_string()),
247            Some(TEST_SESSION_KEY.to_string()),
248            Some(TEST_SUBACCOUNT),
249            DeriveEnvironment::Testnet,
250        )
251        .unwrap();
252        assert_eq!(cred.wallet_address(), TEST_WALLET);
253        assert_eq!(cred.session_key(), TEST_SESSION_KEY);
254        assert_eq!(cred.subaccount_id(), TEST_SUBACCOUNT);
255    }
256
257    #[rstest]
258    fn test_resolve_environment_and_incomplete_credentials() {
259        const CASE_VAR: &str = "NAUTILUS_DERIVE_CREDENTIAL_TEST_CASE";
260        let environments = [DeriveEnvironment::Mainnet, DeriveEnvironment::Testnet];
261        let cases = [
262            "fallback",
263            "explicit",
264            "mixed",
265            "wallet",
266            "key",
267            "subaccount",
268            "invalid",
269        ];
270
271        let Ok(case) = env::var(CASE_VAR) else {
272            for (index, environment) in environments.into_iter().enumerate() {
273                for case in cases {
274                    let mut command = Command::new(env::current_exe().unwrap());
275                    command.args([
276                        "--exact",
277                        "common::credential::tests::test_resolve_environment_and_incomplete_credentials",
278                        "--nocapture",
279                    ]);
280
281                    for (other_index, other) in environments.into_iter().enumerate() {
282                        let (wallet, key, subaccount) = credential_env_vars(other);
283                        command
284                            .env(wallet, format!("wallet-{other_index}"))
285                            .env(key, format!("key-{other_index}"))
286                            .env(subaccount, (41 + other_index).to_string());
287                    }
288
289                    let (wallet, key, subaccount) = credential_env_vars(environment);
290
291                    match case {
292                        "wallet" => {
293                            command.env_remove(wallet);
294                        }
295                        "key" => {
296                            command.env_remove(key);
297                        }
298                        "subaccount" => {
299                            command.env_remove(subaccount);
300                        }
301                        "invalid" => {
302                            command.env(subaccount, "invalid-subaccount");
303                        }
304                        _ => {}
305                    }
306
307                    let output = command
308                        .env(CASE_VAR, format!("{index}:{case}"))
309                        .output()
310                        .unwrap();
311
312                    assert!(
313                        output.status.success(),
314                        "credential case {index}:{case} failed: {}{}",
315                        String::from_utf8_lossy(&output.stdout),
316                        String::from_utf8_lossy(&output.stderr),
317                    );
318                }
319            }
320
321            return;
322        };
323
324        let (index, case) = case.split_once(':').unwrap();
325        let index: usize = index.parse().unwrap();
326        let environment = environments[index];
327        let explicit = case == "explicit";
328        let result = DeriveCredential::resolve(
329            (explicit || case == "mixed").then(|| TEST_WALLET.to_string()),
330            explicit.then(|| TEST_SESSION_KEY.to_string()),
331            explicit.then_some(TEST_SUBACCOUNT),
332            environment,
333        );
334
335        if matches!(case, "fallback" | "explicit" | "mixed") {
336            let credential = result.unwrap();
337            assert_eq!(
338                credential.wallet_address(),
339                if explicit || case == "mixed" {
340                    TEST_WALLET.to_string()
341                } else {
342                    format!("wallet-{index}")
343                }
344            );
345            assert_eq!(
346                credential.session_key(),
347                if explicit {
348                    TEST_SESSION_KEY.to_string()
349                } else {
350                    format!("key-{index}")
351                }
352            );
353            assert_eq!(
354                credential.subaccount_id(),
355                if explicit {
356                    TEST_SUBACCOUNT
357                } else {
358                    41 + index as u64
359                }
360            );
361        } else {
362            let (wallet, key, subaccount) = credential_env_vars(environment);
363
364            let expected = match case {
365                "wallet" => format!("Derive wallet address missing (set {wallet} or config)"),
366                "key" => format!("Derive session key missing (set {key} or config)"),
367                "subaccount" => {
368                    format!("Derive subaccount id missing (set {subaccount} or config)")
369                }
370                "invalid" => format!("failed to parse {subaccount} as u64"),
371                _ => unreachable!(),
372            };
373
374            assert_eq!(result.unwrap_err().to_string(), expected);
375        }
376    }
377
378    #[rstest]
379    fn test_credential_zeroizes_secret_fields() {
380        fn requires_zeroize_on_drop<T: ZeroizeOnDrop>() {}
381        requires_zeroize_on_drop::<DeriveCredential>();
382
383        let mut credential = DeriveCredential::new(
384            TEST_WALLET.to_string(),
385            TEST_SESSION_KEY.to_string(),
386            TEST_SUBACCOUNT,
387        );
388        credential.zeroize();
389
390        assert_eq!(credential.wallet_address(), "");
391        assert_eq!(credential.session_key(), "");
392        assert_eq!(credential.subaccount_id(), TEST_SUBACCOUNT);
393    }
394}