1use 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#[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#[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 #[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 #[must_use]
92 pub fn wallet_address(&self) -> &str {
93 &self.wallet_address
94 }
95
96 #[must_use]
98 pub fn session_key(&self) -> &str {
99 &self.session_key
100 }
101
102 #[must_use]
104 pub const fn subaccount_id(&self) -> u64 {
105 self.subaccount_id
106 }
107
108 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}