1use std::{fmt::Debug, str};
24
25use anyhow::Context;
26use nautilus_core::{
27 env::get_or_env_var_opt,
28 hex,
29 string::secret::{REDACTED, mask_api_key},
30};
31use zeroize::ZeroizeOnDrop;
32
33use crate::{
34 common::enums::LighterEnvironment,
35 signing::{curve::SCALAR_BYTES, schnorr::PrivateKey},
36};
37
38const LIGHTER_API_KEY_INDEX_VAR: &str = "LIGHTER_API_KEY_INDEX";
39const LIGHTER_API_SECRET_VAR: &str = "LIGHTER_API_SECRET";
40const LIGHTER_ACCOUNT_INDEX_VAR: &str = "LIGHTER_ACCOUNT_INDEX";
41const LIGHTER_TESTNET_API_KEY_INDEX_VAR: &str = "LIGHTER_TESTNET_API_KEY_INDEX";
42const LIGHTER_TESTNET_API_SECRET_VAR: &str = "LIGHTER_TESTNET_API_SECRET";
43const LIGHTER_TESTNET_ACCOUNT_INDEX_VAR: &str = "LIGHTER_TESTNET_ACCOUNT_INDEX";
44
45#[must_use]
52pub const fn credential_env_vars(
53 environment: LighterEnvironment,
54) -> (&'static str, &'static str, &'static str) {
55 match environment {
56 LighterEnvironment::Mainnet => (
57 LIGHTER_API_KEY_INDEX_VAR,
58 LIGHTER_API_SECRET_VAR,
59 LIGHTER_ACCOUNT_INDEX_VAR,
60 ),
61 LighterEnvironment::Testnet => (
62 LIGHTER_TESTNET_API_KEY_INDEX_VAR,
63 LIGHTER_TESTNET_API_SECRET_VAR,
64 LIGHTER_TESTNET_ACCOUNT_INDEX_VAR,
65 ),
66 }
67}
68
69#[derive(Clone, ZeroizeOnDrop)]
75pub struct Credential {
76 api_key_index: u8,
77 account_index: i64,
78 api_secret: Box<[u8]>,
79}
80
81impl Debug for Credential {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct(stringify!(Credential))
84 .field("api_key_index", &self.api_key_index)
85 .field("account_index", &self.account_index)
86 .field("api_secret", &REDACTED)
87 .finish()
88 }
89}
90
91impl Credential {
92 pub fn new(
100 api_key_index: u8,
101 api_secret: impl Into<String>,
102 account_index: u64,
103 ) -> anyhow::Result<Self> {
104 let api_key_index = ensure_api_key_index(api_key_index)?;
105 let account_index = i64::try_from(account_index)
106 .context("Lighter account index exceeds signed 64-bit range")?;
107 let credential = Self {
108 api_key_index,
109 account_index,
110 api_secret: api_secret.into().into_bytes().into_boxed_slice(),
111 };
112 credential.private_key()?;
113 Ok(credential)
114 }
115
116 pub fn resolve(
131 private_key: Option<String>,
132 account_index: Option<u64>,
133 api_key_index: Option<u8>,
134 environment: LighterEnvironment,
135 ) -> anyhow::Result<Option<Self>> {
136 let (api_key_var, api_secret_var, account_index_var) = credential_env_vars(environment);
137
138 let api_key_index = resolve_api_key_index(api_key_index, api_key_var)?;
139 let account_index = resolve_account_index(account_index, account_index_var)?;
140 let api_secret =
141 get_or_env_var_opt(private_key.filter(|s| !s.trim().is_empty()), api_secret_var)
142 .filter(|s| !s.trim().is_empty());
143
144 credential_from_resolved_values(
145 api_key_index,
146 account_index,
147 api_secret,
148 api_key_var,
149 api_secret_var,
150 account_index_var,
151 )
152 }
153
154 #[must_use]
156 pub const fn api_key_index(&self) -> u8 {
157 self.api_key_index
158 }
159
160 #[must_use]
162 pub const fn account_index(&self) -> i64 {
163 self.account_index
164 }
165
166 pub fn private_key(&self) -> anyhow::Result<PrivateKey> {
173 let mut bytes = [0u8; SCALAR_BYTES];
174 let secret =
175 str::from_utf8(&self.api_secret).context("Lighter API secret must be UTF-8")?;
176 let decoded = decode_private_key_hex(secret)?;
177 bytes.copy_from_slice(&decoded);
178 Ok(PrivateKey::from_le_bytes_reduce(bytes))
179 }
180}
181
182#[must_use]
184pub(crate) fn scrub_auth(text: &str) -> String {
185 let needle = "auth=";
186 if !text.contains(needle) {
187 return text.to_string();
188 }
189
190 let mut out = String::with_capacity(text.len());
191 let mut idx = 0;
192 while idx < text.len() {
193 if let Some(start) = text[idx..].find(needle) {
194 let abs_start = idx + start + needle.len();
195 out.push_str(&text[idx..abs_start]);
196 let end = text[abs_start..]
197 .find(|c: char| c == '&' || c.is_whitespace())
198 .map_or(text.len(), |p| abs_start + p);
199 let token = &text[abs_start..end];
200 out.push_str(&mask_api_key(token));
201 idx = end;
202 } else {
203 out.push_str(&text[idx..]);
204 break;
205 }
206 }
207 out
208}
209
210fn credential_from_resolved_values(
211 api_key_index: Option<u8>,
212 account_index: Option<u64>,
213 api_secret: Option<String>,
214 api_key_var: &str,
215 api_secret_var: &str,
216 account_index_var: &str,
217) -> anyhow::Result<Option<Credential>> {
218 match (api_key_index, account_index, api_secret) {
219 (Some(api_key_index), Some(account_index), Some(api_secret)) => Ok(Some(Credential::new(
220 api_key_index,
221 api_secret,
222 account_index,
223 )?)),
224 (None, None, None) => Ok(None),
225 _ => anyhow::bail!(
226 "incomplete Lighter credentials: set {api_key_var}, {api_secret_var}, and {account_index_var}"
227 ),
228 }
229}
230
231fn resolve_api_key_index(value: Option<u8>, env_var: &str) -> anyhow::Result<Option<u8>> {
232 match value {
233 Some(value) => ensure_api_key_index(value).map(Some),
234 None => get_or_env_var_opt(None::<String>, env_var)
235 .filter(|s| !s.trim().is_empty())
236 .map(|s| parse_api_key_index(&s, env_var))
237 .transpose(),
238 }
239}
240
241fn resolve_account_index(value: Option<u64>, env_var: &str) -> anyhow::Result<Option<u64>> {
242 match value {
243 Some(value) => Ok(Some(value)),
244 None => get_or_env_var_opt(None::<String>, env_var)
245 .filter(|s| !s.trim().is_empty())
246 .map(|s| {
247 s.trim()
248 .parse::<u64>()
249 .with_context(|| format!("{env_var} must be an unsigned integer"))
250 })
251 .transpose(),
252 }
253}
254
255fn parse_api_key_index(value: &str, env_var: &str) -> anyhow::Result<u8> {
256 let index = value
257 .trim()
258 .parse::<u8>()
259 .with_context(|| format!("{env_var} must be an API key index in 0..=254"))?;
260 ensure_api_key_index(index)
261}
262
263fn ensure_api_key_index(value: u8) -> anyhow::Result<u8> {
264 anyhow::ensure!(value <= 254, "Lighter API key index must be in 0..=254");
265 Ok(value)
266}
267
268fn decode_private_key_hex(value: &str) -> anyhow::Result<Vec<u8>> {
269 let value = value.trim();
270 let hex = value
271 .strip_prefix("0x")
272 .or_else(|| value.strip_prefix("0X"))
273 .unwrap_or(value);
274 let bytes = hex::decode(hex).context("Lighter API secret must be valid hex")?;
275 anyhow::ensure!(
276 bytes.len() == SCALAR_BYTES,
277 "Lighter API secret must be a 40-byte hex private key"
278 );
279 Ok(bytes)
280}
281
282#[cfg(test)]
283mod tests {
284 use rstest::rstest;
285
286 use super::*;
287
288 const PRIVATE_KEY_HEX: &str =
289 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
290
291 #[rstest]
292 fn test_credential_env_vars_mainnet() {
293 assert_eq!(
294 credential_env_vars(LighterEnvironment::Mainnet),
295 (
296 "LIGHTER_API_KEY_INDEX",
297 "LIGHTER_API_SECRET",
298 "LIGHTER_ACCOUNT_INDEX"
299 ),
300 );
301 }
302
303 #[rstest]
304 fn test_credential_env_vars_testnet() {
305 assert_eq!(
306 credential_env_vars(LighterEnvironment::Testnet),
307 (
308 "LIGHTER_TESTNET_API_KEY_INDEX",
309 "LIGHTER_TESTNET_API_SECRET",
310 "LIGHTER_TESTNET_ACCOUNT_INDEX"
311 ),
312 );
313 }
314
315 #[rstest]
316 fn test_resolve_with_config_values() {
317 let credential = Credential::resolve(
318 Some(PRIVATE_KEY_HEX.to_string()),
319 Some(12_345),
320 Some(4),
321 LighterEnvironment::Mainnet,
322 )
323 .unwrap()
324 .unwrap();
325
326 assert_eq!(credential.api_key_index(), 4);
327 assert_eq!(credential.account_index(), 12_345);
328 assert!(credential.private_key().is_ok());
329 }
330
331 #[rstest]
332 fn test_credential_from_resolved_values() {
333 let credential = credential_from_resolved_values(
334 Some(4),
335 Some(12_345),
336 Some(PRIVATE_KEY_HEX.to_string()),
337 LIGHTER_API_KEY_INDEX_VAR,
338 LIGHTER_API_SECRET_VAR,
339 LIGHTER_ACCOUNT_INDEX_VAR,
340 )
341 .unwrap()
342 .unwrap();
343
344 assert_eq!(credential.api_key_index(), 4);
345 assert_eq!(credential.account_index(), 12_345);
346 assert!(credential.private_key().is_ok());
347 }
348
349 #[rstest]
350 fn test_credential_from_resolved_values_rejects_partial_values() {
351 let err = credential_from_resolved_values(
352 Some(4),
353 None,
354 Some(PRIVATE_KEY_HEX.to_string()),
355 LIGHTER_API_KEY_INDEX_VAR,
356 LIGHTER_API_SECRET_VAR,
357 LIGHTER_ACCOUNT_INDEX_VAR,
358 )
359 .unwrap_err();
360
361 assert!(err.to_string().contains("incomplete Lighter credentials"));
362 }
363
364 #[rstest]
365 fn test_resolve_rejects_invalid_api_secret() {
366 let err = Credential::resolve(
367 Some("not-hex".to_string()),
368 Some(12_345),
369 Some(4),
370 LighterEnvironment::Mainnet,
371 )
372 .unwrap_err();
373
374 assert!(err.to_string().contains("valid hex"));
375 }
376
377 #[rstest]
378 fn test_private_key_accepts_prefixed_hex() {
379 let lower_prefixed = format!("0x{PRIVATE_KEY_HEX}");
380 let upper_prefixed = format!("0X{PRIVATE_KEY_HEX}");
381
382 let lower = Credential::new(4, lower_prefixed, 12_345).unwrap();
383 let upper = Credential::new(4, upper_prefixed, 12_345).unwrap();
384
385 assert!(lower.private_key().is_ok());
386 assert!(upper.private_key().is_ok());
387 }
388
389 #[rstest]
390 fn test_debug_redacts_api_secret() {
391 let credential = Credential::new(4, PRIVATE_KEY_HEX, 12_345).unwrap();
392
393 let dbg_out = format!("{credential:?}");
394
395 assert!(dbg_out.contains(REDACTED));
396 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
397 }
398
399 #[rstest]
400 #[case::no_auth("no auth here", "no auth here")]
401 #[case::short_token("auth=abc", "auth=***")]
402 #[case::long_token("auth=abcdefghijklmnop", "auth=abcd...mnop")]
403 #[case::url_with_ampersand("url?auth=abcdefghijklmnop&other=x", "url?auth=abcd...mnop&other=x")]
404 #[case::empty_token_value("url?auth=&other=x", "url?auth=&other=x")]
405 #[case::multiple_auth(
406 "first auth=token1 mid auth=token2 end",
407 "first auth=****** mid auth=****** end"
408 )]
409 #[case::trailing_whitespace("auth=tok end", "auth=*** end")]
410 #[case::newline_boundary(
411 "first auth=token1\nsecond auth=token2",
412 "first auth=******\nsecond auth=******"
413 )]
414 fn scrub_auth_redacts_token(#[case] input: &str, #[case] expected: &str) {
415 assert_eq!(scrub_auth(input), expected);
416 }
417
418 #[rstest]
419 fn scrub_auth_empty_input_returns_empty() {
420 assert_eq!(scrub_auth(""), "");
421 }
422
423 #[allow(unsafe_code)] mod serial_tests {
428 use super::*;
429
430 const LIGHTER_ENV_VARS: &[&str] = &[
431 "LIGHTER_API_KEY_INDEX",
432 "LIGHTER_API_SECRET",
433 "LIGHTER_ACCOUNT_INDEX",
434 "LIGHTER_TESTNET_API_KEY_INDEX",
435 "LIGHTER_TESTNET_API_SECRET",
436 "LIGHTER_TESTNET_ACCOUNT_INDEX",
437 ];
438
439 struct EnvGuard {
442 saved: Vec<(&'static str, Option<String>)>,
443 }
444
445 impl EnvGuard {
446 fn clear_lighter() -> Self {
447 let saved = LIGHTER_ENV_VARS
448 .iter()
449 .map(|&name| (name, std::env::var(name).ok()))
450 .collect::<Vec<_>>();
451 for &(name, _) in &saved {
452 unsafe { std::env::remove_var(name) };
456 }
457 Self { saved }
458 }
459 }
460
461 impl Drop for EnvGuard {
462 fn drop(&mut self) {
463 for (name, original) in &self.saved {
464 match original {
465 Some(value) => unsafe { std::env::set_var(name, value) },
467 None => unsafe { std::env::remove_var(name) },
468 }
469 }
470 }
471 }
472
473 #[rstest]
474 #[case::empty("")]
475 #[case::whitespace(" ")]
476 fn resolve_blank_private_key_falls_back_to_env_secret(#[case] blank: &str) {
477 let _guard = EnvGuard::clear_lighter();
478 unsafe { std::env::set_var("LIGHTER_API_SECRET", PRIVATE_KEY_HEX) };
480
481 let credential = Credential::resolve(
482 Some(blank.to_string()),
483 Some(12_345),
484 Some(4),
485 LighterEnvironment::Mainnet,
486 )
487 .unwrap()
488 .unwrap();
489
490 let from_env = Credential::new(4, PRIVATE_KEY_HEX, 12_345).unwrap();
491 assert_eq!(
492 credential.private_key().unwrap().to_le_bytes(),
493 from_env.private_key().unwrap().to_le_bytes(),
494 );
495 }
496
497 #[rstest]
498 fn resolve_blank_private_key_without_env_returns_none() {
499 let _guard = EnvGuard::clear_lighter();
500
501 let resolved = Credential::resolve(
502 Some(" ".to_string()),
503 None,
504 None,
505 LighterEnvironment::Mainnet,
506 )
507 .unwrap();
508
509 assert!(resolved.is_none());
510 }
511
512 #[rstest]
513 fn resolve_blank_env_secret_returns_none() {
514 let _guard = EnvGuard::clear_lighter();
515 unsafe { std::env::set_var("LIGHTER_API_SECRET", " ") };
517
518 let resolved =
519 Credential::resolve(None, None, None, LighterEnvironment::Mainnet).unwrap();
520
521 assert!(resolved.is_none());
522 }
523
524 #[rstest]
525 fn resolve_prefers_non_blank_config_over_env_secret() {
526 let _guard = EnvGuard::clear_lighter();
527 unsafe { std::env::set_var("LIGHTER_API_SECRET", "not-hex") };
530
531 let credential = Credential::resolve(
532 Some(PRIVATE_KEY_HEX.to_string()),
533 Some(12_345),
534 Some(4),
535 LighterEnvironment::Mainnet,
536 )
537 .unwrap()
538 .unwrap();
539
540 assert!(credential.private_key().is_ok());
541 }
542 }
543}