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::{LighterDeployment, 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";
44const LIGHTER_ROBINHOOD_API_KEY_INDEX_VAR: &str = "LIGHTER_ROBINHOOD_API_KEY_INDEX";
45const LIGHTER_ROBINHOOD_API_SECRET_VAR: &str = "LIGHTER_ROBINHOOD_API_SECRET";
46const LIGHTER_ROBINHOOD_ACCOUNT_INDEX_VAR: &str = "LIGHTER_ROBINHOOD_ACCOUNT_INDEX";
47const LIGHTER_ROBINHOOD_TESTNET_API_KEY_INDEX_VAR: &str = "LIGHTER_ROBINHOOD_TESTNET_API_KEY_INDEX";
48const LIGHTER_ROBINHOOD_TESTNET_API_SECRET_VAR: &str = "LIGHTER_ROBINHOOD_TESTNET_API_SECRET";
49const LIGHTER_ROBINHOOD_TESTNET_ACCOUNT_INDEX_VAR: &str = "LIGHTER_ROBINHOOD_TESTNET_ACCOUNT_INDEX";
50
51#[must_use]
58pub const fn credential_env_vars(
59 environment: LighterEnvironment,
60) -> (&'static str, &'static str, &'static str) {
61 credential_env_vars_for_deployment(LighterDeployment::Lighter, environment)
62}
63
64#[must_use]
69pub const fn credential_env_vars_for_deployment(
70 deployment: LighterDeployment,
71 environment: LighterEnvironment,
72) -> (&'static str, &'static str, &'static str) {
73 match (deployment, environment) {
74 (LighterDeployment::Lighter, LighterEnvironment::Mainnet) => (
75 LIGHTER_API_KEY_INDEX_VAR,
76 LIGHTER_API_SECRET_VAR,
77 LIGHTER_ACCOUNT_INDEX_VAR,
78 ),
79 (LighterDeployment::Lighter, LighterEnvironment::Testnet) => (
80 LIGHTER_TESTNET_API_KEY_INDEX_VAR,
81 LIGHTER_TESTNET_API_SECRET_VAR,
82 LIGHTER_TESTNET_ACCOUNT_INDEX_VAR,
83 ),
84 (LighterDeployment::Robinhood, LighterEnvironment::Mainnet) => (
85 LIGHTER_ROBINHOOD_API_KEY_INDEX_VAR,
86 LIGHTER_ROBINHOOD_API_SECRET_VAR,
87 LIGHTER_ROBINHOOD_ACCOUNT_INDEX_VAR,
88 ),
89 (LighterDeployment::Robinhood, LighterEnvironment::Testnet) => (
90 LIGHTER_ROBINHOOD_TESTNET_API_KEY_INDEX_VAR,
91 LIGHTER_ROBINHOOD_TESTNET_API_SECRET_VAR,
92 LIGHTER_ROBINHOOD_TESTNET_ACCOUNT_INDEX_VAR,
93 ),
94 }
95}
96
97#[derive(Clone, ZeroizeOnDrop)]
103pub struct Credential {
104 api_key_index: u8,
105 account_index: i64,
106 api_secret: Box<[u8]>,
107}
108
109impl Debug for Credential {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.debug_struct(stringify!(Credential))
112 .field("api_key_index", &self.api_key_index)
113 .field("account_index", &self.account_index)
114 .field("api_secret", &REDACTED)
115 .finish()
116 }
117}
118
119impl Credential {
120 pub fn new(
128 api_key_index: u8,
129 api_secret: impl Into<String>,
130 account_index: u64,
131 ) -> anyhow::Result<Self> {
132 let api_key_index = ensure_api_key_index(api_key_index)?;
133 let account_index = i64::try_from(account_index)
134 .context("Lighter account index exceeds signed 64-bit range")?;
135 let credential = Self {
136 api_key_index,
137 account_index,
138 api_secret: api_secret.into().into_bytes().into_boxed_slice(),
139 };
140 credential.private_key()?;
141 Ok(credential)
142 }
143
144 pub fn resolve(
159 private_key: Option<String>,
160 account_index: Option<u64>,
161 api_key_index: Option<u8>,
162 environment: LighterEnvironment,
163 ) -> anyhow::Result<Option<Self>> {
164 Self::resolve_for_deployment(
165 private_key,
166 account_index,
167 api_key_index,
168 LighterDeployment::Lighter,
169 environment,
170 )
171 }
172
173 pub fn resolve_for_deployment(
184 private_key: Option<String>,
185 account_index: Option<u64>,
186 api_key_index: Option<u8>,
187 deployment: LighterDeployment,
188 environment: LighterEnvironment,
189 ) -> anyhow::Result<Option<Self>> {
190 let (api_key_var, api_secret_var, account_index_var) =
191 credential_env_vars_for_deployment(deployment, environment);
192
193 let api_key_index = resolve_api_key_index(api_key_index, api_key_var)?;
194 let account_index = resolve_account_index(account_index, account_index_var)?;
195 let api_secret =
196 get_or_env_var_opt(private_key.filter(|s| !s.trim().is_empty()), api_secret_var)
197 .filter(|s| !s.trim().is_empty());
198
199 credential_from_resolved_values(
200 api_key_index,
201 account_index,
202 api_secret,
203 api_key_var,
204 api_secret_var,
205 account_index_var,
206 )
207 }
208
209 #[must_use]
211 pub const fn api_key_index(&self) -> u8 {
212 self.api_key_index
213 }
214
215 #[must_use]
217 pub const fn account_index(&self) -> i64 {
218 self.account_index
219 }
220
221 pub fn private_key(&self) -> anyhow::Result<PrivateKey> {
228 let mut bytes = [0u8; SCALAR_BYTES];
229 let secret =
230 str::from_utf8(&self.api_secret).context("Lighter API secret must be UTF-8")?;
231 let decoded = decode_private_key_hex(secret)?;
232 bytes.copy_from_slice(&decoded);
233 Ok(PrivateKey::from_le_bytes_reduce(bytes))
234 }
235}
236
237#[must_use]
239pub(crate) fn scrub_auth(text: &str) -> String {
240 let needle = "auth=";
241 if !text.contains(needle) {
242 return text.to_string();
243 }
244
245 let mut out = String::with_capacity(text.len());
246 let mut idx = 0;
247 while idx < text.len() {
248 if let Some(start) = text[idx..].find(needle) {
249 let abs_start = idx + start + needle.len();
250 out.push_str(&text[idx..abs_start]);
251 let end = text[abs_start..]
252 .find(|c: char| c == '&' || c.is_whitespace())
253 .map_or(text.len(), |p| abs_start + p);
254 let token = &text[abs_start..end];
255 out.push_str(&mask_api_key(token));
256 idx = end;
257 } else {
258 out.push_str(&text[idx..]);
259 break;
260 }
261 }
262 out
263}
264
265fn credential_from_resolved_values(
266 api_key_index: Option<u8>,
267 account_index: Option<u64>,
268 api_secret: Option<String>,
269 api_key_var: &str,
270 api_secret_var: &str,
271 account_index_var: &str,
272) -> anyhow::Result<Option<Credential>> {
273 match (api_key_index, account_index, api_secret) {
274 (Some(api_key_index), Some(account_index), Some(api_secret)) => Ok(Some(Credential::new(
275 api_key_index,
276 api_secret,
277 account_index,
278 )?)),
279 (None, None, None) => Ok(None),
280 _ => anyhow::bail!(
281 "incomplete Lighter credentials: set {api_key_var}, {api_secret_var}, and {account_index_var}"
282 ),
283 }
284}
285
286fn resolve_api_key_index(value: Option<u8>, env_var: &str) -> anyhow::Result<Option<u8>> {
287 match value {
288 Some(value) => ensure_api_key_index(value).map(Some),
289 None => get_or_env_var_opt(None::<String>, env_var)
290 .filter(|s| !s.trim().is_empty())
291 .map(|s| parse_api_key_index(&s, env_var))
292 .transpose(),
293 }
294}
295
296fn resolve_account_index(value: Option<u64>, env_var: &str) -> anyhow::Result<Option<u64>> {
297 match value {
298 Some(value) => Ok(Some(value)),
299 None => get_or_env_var_opt(None::<String>, env_var)
300 .filter(|s| !s.trim().is_empty())
301 .map(|s| {
302 s.trim()
303 .parse::<u64>()
304 .with_context(|| format!("{env_var} must be an unsigned integer"))
305 })
306 .transpose(),
307 }
308}
309
310fn parse_api_key_index(value: &str, env_var: &str) -> anyhow::Result<u8> {
311 let index = value
312 .trim()
313 .parse::<u8>()
314 .with_context(|| format!("{env_var} must be an API key index in 0..=254"))?;
315 ensure_api_key_index(index)
316}
317
318fn ensure_api_key_index(value: u8) -> anyhow::Result<u8> {
319 anyhow::ensure!(value <= 254, "Lighter API key index must be in 0..=254");
320 Ok(value)
321}
322
323fn decode_private_key_hex(value: &str) -> anyhow::Result<Vec<u8>> {
324 let value = value.trim();
325 let hex = value
326 .strip_prefix("0x")
327 .or_else(|| value.strip_prefix("0X"))
328 .unwrap_or(value);
329 let bytes = hex::decode(hex).context("Lighter API secret must be valid hex")?;
330 anyhow::ensure!(
331 bytes.len() == SCALAR_BYTES,
332 "Lighter API secret must be a 40-byte hex private key"
333 );
334 Ok(bytes)
335}
336
337#[cfg(test)]
338mod tests {
339 use rstest::rstest;
340
341 use super::*;
342
343 const PRIVATE_KEY_HEX: &str =
344 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
345
346 #[rstest]
347 fn test_credential_env_vars_mainnet() {
348 assert_eq!(
349 credential_env_vars(LighterEnvironment::Mainnet),
350 (
351 "LIGHTER_API_KEY_INDEX",
352 "LIGHTER_API_SECRET",
353 "LIGHTER_ACCOUNT_INDEX"
354 ),
355 );
356 }
357
358 #[rstest]
359 fn test_credential_env_vars_testnet() {
360 assert_eq!(
361 credential_env_vars(LighterEnvironment::Testnet),
362 (
363 "LIGHTER_TESTNET_API_KEY_INDEX",
364 "LIGHTER_TESTNET_API_SECRET",
365 "LIGHTER_TESTNET_ACCOUNT_INDEX"
366 ),
367 );
368 }
369
370 #[rstest]
371 #[case::lighter_mainnet(
372 LighterDeployment::Lighter,
373 LighterEnvironment::Mainnet,
374 (
375 "LIGHTER_API_KEY_INDEX",
376 "LIGHTER_API_SECRET",
377 "LIGHTER_ACCOUNT_INDEX"
378 )
379 )]
380 #[case::lighter_testnet(
381 LighterDeployment::Lighter,
382 LighterEnvironment::Testnet,
383 (
384 "LIGHTER_TESTNET_API_KEY_INDEX",
385 "LIGHTER_TESTNET_API_SECRET",
386 "LIGHTER_TESTNET_ACCOUNT_INDEX"
387 )
388 )]
389 #[case::robinhood_mainnet(
390 LighterDeployment::Robinhood,
391 LighterEnvironment::Mainnet,
392 (
393 "LIGHTER_ROBINHOOD_API_KEY_INDEX",
394 "LIGHTER_ROBINHOOD_API_SECRET",
395 "LIGHTER_ROBINHOOD_ACCOUNT_INDEX"
396 )
397 )]
398 #[case::robinhood_testnet(
399 LighterDeployment::Robinhood,
400 LighterEnvironment::Testnet,
401 (
402 "LIGHTER_ROBINHOOD_TESTNET_API_KEY_INDEX",
403 "LIGHTER_ROBINHOOD_TESTNET_API_SECRET",
404 "LIGHTER_ROBINHOOD_TESTNET_ACCOUNT_INDEX"
405 )
406 )]
407 fn test_credential_env_vars_for_deployment(
408 #[case] deployment: LighterDeployment,
409 #[case] environment: LighterEnvironment,
410 #[case] expected: (&'static str, &'static str, &'static str),
411 ) {
412 assert_eq!(
413 credential_env_vars_for_deployment(deployment, environment),
414 expected,
415 );
416 }
417
418 #[rstest]
419 fn test_resolve_with_config_values() {
420 let credential = Credential::resolve(
421 Some(PRIVATE_KEY_HEX.to_string()),
422 Some(12_345),
423 Some(4),
424 LighterEnvironment::Mainnet,
425 )
426 .unwrap()
427 .unwrap();
428
429 assert_eq!(credential.api_key_index(), 4);
430 assert_eq!(credential.account_index(), 12_345);
431 assert!(credential.private_key().is_ok());
432 }
433
434 #[rstest]
435 fn test_credential_from_resolved_values() {
436 let credential = credential_from_resolved_values(
437 Some(4),
438 Some(12_345),
439 Some(PRIVATE_KEY_HEX.to_string()),
440 LIGHTER_API_KEY_INDEX_VAR,
441 LIGHTER_API_SECRET_VAR,
442 LIGHTER_ACCOUNT_INDEX_VAR,
443 )
444 .unwrap()
445 .unwrap();
446
447 assert_eq!(credential.api_key_index(), 4);
448 assert_eq!(credential.account_index(), 12_345);
449 assert!(credential.private_key().is_ok());
450 }
451
452 #[rstest]
453 fn test_credential_from_resolved_values_rejects_partial_values() {
454 let err = credential_from_resolved_values(
455 Some(4),
456 None,
457 Some(PRIVATE_KEY_HEX.to_string()),
458 LIGHTER_API_KEY_INDEX_VAR,
459 LIGHTER_API_SECRET_VAR,
460 LIGHTER_ACCOUNT_INDEX_VAR,
461 )
462 .unwrap_err();
463
464 assert!(err.to_string().contains("incomplete Lighter credentials"));
465 }
466
467 #[rstest]
468 fn test_resolve_rejects_invalid_api_secret() {
469 let err = Credential::resolve(
470 Some("not-hex".to_string()),
471 Some(12_345),
472 Some(4),
473 LighterEnvironment::Mainnet,
474 )
475 .unwrap_err();
476
477 assert!(err.to_string().contains("valid hex"));
478 }
479
480 #[rstest]
481 fn test_private_key_accepts_prefixed_hex() {
482 let lower_prefixed = format!("0x{PRIVATE_KEY_HEX}");
483 let upper_prefixed = format!("0X{PRIVATE_KEY_HEX}");
484
485 let lower = Credential::new(4, lower_prefixed, 12_345).unwrap();
486 let upper = Credential::new(4, upper_prefixed, 12_345).unwrap();
487
488 assert!(lower.private_key().is_ok());
489 assert!(upper.private_key().is_ok());
490 }
491
492 #[rstest]
493 fn test_debug_redacts_api_secret() {
494 let credential = Credential::new(4, PRIVATE_KEY_HEX, 12_345).unwrap();
495
496 let dbg_out = format!("{credential:?}");
497
498 assert!(dbg_out.contains(REDACTED));
499 assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
500 }
501
502 #[rstest]
503 #[case::no_auth("no auth here", "no auth here")]
504 #[case::short_token("auth=abc", "auth=***")]
505 #[case::long_token("auth=abcdefghijklmnop", "auth=abcd...mnop")]
506 #[case::url_with_ampersand("url?auth=abcdefghijklmnop&other=x", "url?auth=abcd...mnop&other=x")]
507 #[case::empty_token_value("url?auth=&other=x", "url?auth=&other=x")]
508 #[case::multiple_auth(
509 "first auth=token1 mid auth=token2 end",
510 "first auth=****** mid auth=****** end"
511 )]
512 #[case::trailing_whitespace("auth=tok end", "auth=*** end")]
513 #[case::newline_boundary(
514 "first auth=token1\nsecond auth=token2",
515 "first auth=******\nsecond auth=******"
516 )]
517 fn scrub_auth_redacts_token(#[case] input: &str, #[case] expected: &str) {
518 assert_eq!(scrub_auth(input), expected);
519 }
520
521 #[rstest]
522 fn scrub_auth_empty_input_returns_empty() {
523 assert_eq!(scrub_auth(""), "");
524 }
525}