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