Skip to main content

nautilus_lighter/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//! Lighter credential storage and resolution.
17//!
18//! Lighter signs L2 transactions with Schnorr signatures over the ecgfp5 curve
19//! (Goldilocks quintic extension field) and Poseidon2 hashing. The cryptographic
20//! primitives live in [`crate::signing`]; this module only handles credential
21//! plumbing (private key bytes, account index, API key index, env-var resolution).
22
23use 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/// Environment variable names for Lighter credentials.
48///
49/// Returns `(api_key_index_var, api_secret_var, account_index_var)`. The
50/// `api_key_index_var` holds the per-account API key slot (0..=254), the
51/// `api_secret_var` holds the hex-encoded private key, and the
52/// `account_index_var` holds the account number assigned at registration.
53#[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/// Environment variable names for credentials on a Lighter protocol deployment.
61///
62/// Returns `(api_key_index_var, api_secret_var, account_index_var)`. The
63/// deployment and environment select an independent credential namespace.
64#[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/// Lighter API credentials required for authenticated REST, private WebSocket,
94/// and L2 transaction signing.
95///
96/// Lighter identifies API keys by numeric index. The API private key signs both
97/// auth tokens and L2 transactions for `(account_index, api_key_index)`.
98#[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    /// Creates a new [`Credential`] instance from a key index, private key, and
117    /// account index.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if `account_index` exceeds the signed range used by the
122    /// Lighter signer or if `api_secret` is not a 40-byte hex private key.
123    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    /// Resolves credentials from provided config values or environment
141    /// variables.
142    ///
143    /// Config values take precedence, but a blank or whitespace-only
144    /// `private_key` falls back to the environment variable. Environment
145    /// variables follow [`credential_env_vars`]. `LIGHTER_API_KEY_INDEX` is
146    /// the per-account API key slot (0..=254), separate from any hex public
147    /// key the venue reports for that slot.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if any resolved numeric field cannot be parsed, if the
152    /// account index exceeds the signed range, or if the API private key is not
153    /// valid 40-byte hex.
154    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    /// Resolves credentials for a deployment from provided config values or environment variables.
170    ///
171    /// Config values take precedence, but a blank or whitespace-only `private_key` falls back to
172    /// the deployment-specific environment variable selected by
173    /// [`credential_env_vars_for_deployment`].
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if any resolved numeric field cannot be parsed, if the account index
178    /// exceeds the signed range, or if the API private key is not valid 40-byte hex.
179    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    /// Returns the Lighter API key index.
206    #[must_use]
207    pub const fn api_key_index(&self) -> u8 {
208        self.api_key_index
209    }
210
211    /// Returns the Lighter account index.
212    #[must_use]
213    pub const fn account_index(&self) -> i64 {
214        self.account_index
215    }
216
217    /// Decodes the API private key for Lighter signing.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the secret is not 40-byte hex, with or without a
222    /// `0x` prefix.
223    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/// Replaces any `auth=<token>` substring with the common redaction marker for logs.
234#[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}