Skip to main content

nautilus_lighter/python/
mod.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//! Python bindings from `pyo3`.
17//!
18//! Lighter's Python surface is intentionally narrow: configuration, environment
19//! selection, and factories. Data and execution clients are consumed directly
20//! through the Rust trait surface and are not exposed to Python.
21
22#![expect(
23    clippy::missing_errors_doc,
24    reason = "errors documented on underlying Rust methods"
25)]
26
27pub mod config;
28pub mod factories;
29
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use nautilus_common::factories::{ClientConfig, DataClientFactory, ExecutionClientFactory};
33use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
34use nautilus_system::get_global_pyo3_registry;
35use pyo3::prelude::*;
36
37use crate::{
38    common::{
39        consts::{LIGHTER, LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX},
40        credential::Credential,
41        enums::LighterEnvironment,
42        urls::lighter_chain_id,
43    },
44    config::{LighterDataClientConfig, LighterExecClientConfig},
45    factories::{LighterDataClientFactory, LighterExecutionClientFactory},
46    http::{
47        client::{LighterHttpClient, LighterRawHttpClient},
48        models::LighterSendTxRequest,
49    },
50    signing::{
51        auth_token::fresh_k,
52        tx::{ApproveIntegratorTxInfo, LighterTx, TxContext, TxInfoJson, sign_tx},
53    },
54};
55
56const TX_EXPIRY_MS: i64 = 5 * 60 * 1_000;
57
58#[expect(clippy::needless_pass_by_value)]
59fn extract_lighter_data_factory(
60    py: Python<'_>,
61    factory: Py<PyAny>,
62) -> PyResult<Box<dyn DataClientFactory>> {
63    match factory.extract::<LighterDataClientFactory>(py) {
64        Ok(f) => Ok(Box::new(f)),
65        Err(e) => Err(to_pyvalue_err(format!(
66            "Failed to extract LighterDataClientFactory: {e}"
67        ))),
68    }
69}
70
71#[expect(clippy::needless_pass_by_value)]
72fn extract_lighter_exec_factory(
73    py: Python<'_>,
74    factory: Py<PyAny>,
75) -> PyResult<Box<dyn ExecutionClientFactory>> {
76    match factory.extract::<LighterExecutionClientFactory>(py) {
77        Ok(f) => Ok(Box::new(f)),
78        Err(e) => Err(to_pyvalue_err(format!(
79            "Failed to extract LighterExecutionClientFactory: {e}"
80        ))),
81    }
82}
83
84#[expect(clippy::needless_pass_by_value)]
85fn extract_lighter_data_config(
86    py: Python<'_>,
87    config: Py<PyAny>,
88) -> PyResult<Box<dyn ClientConfig>> {
89    match config.extract::<LighterDataClientConfig>(py) {
90        Ok(c) => Ok(Box::new(c)),
91        Err(e) => Err(to_pyvalue_err(format!(
92            "Failed to extract LighterDataClientConfig: {e}"
93        ))),
94    }
95}
96
97#[expect(clippy::needless_pass_by_value)]
98fn extract_lighter_exec_config(
99    py: Python<'_>,
100    config: Py<PyAny>,
101) -> PyResult<Box<dyn ClientConfig>> {
102    match config.extract::<LighterExecClientConfig>(py) {
103        Ok(c) => Ok(Box::new(c)),
104        Err(e) => Err(to_pyvalue_err(format!(
105            "Failed to extract LighterExecClientConfig: {e}"
106        ))),
107    }
108}
109
110async fn submit_integrator_revocation(environment: LighterEnvironment) -> anyhow::Result<String> {
111    let credential = Credential::resolve(None, None, None, environment)?
112        .ok_or_else(|| anyhow::anyhow!("no Lighter L2 credentials in env"))?;
113    let chain_id = lighter_chain_id(environment);
114
115    let raw = LighterRawHttpClient::new(environment, None, 30, None)?;
116    let http = LighterHttpClient::from_raw(raw);
117    let next_nonce = http
118        .get_next_nonce(credential.account_index(), credential.api_key_index())
119        .await?
120        .nonce;
121
122    let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64;
123    let tx = ApproveIntegratorTxInfo {
124        context: TxContext {
125            account_index: credential.account_index(),
126            api_key_index: credential.api_key_index(),
127            nonce: next_nonce,
128            expired_at: now_ms.saturating_add(TX_EXPIRY_MS),
129        },
130        integrator_account_index: LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX as i64,
131        max_perps_taker_fee: 0,
132        max_perps_maker_fee: 0,
133        max_spot_taker_fee: 0,
134        max_spot_maker_fee: 0,
135        approval_expiry: 0,
136        skip_nonce: 0,
137    };
138
139    let l2_signed = sign_tx(&tx, chain_id, &credential.private_key()?, fresh_k());
140    let tx_info_str = TxInfoJson::approve_integrator(&tx, &l2_signed, "");
141    let request = LighterSendTxRequest::new(tx.tx_type() as u8, tx_info_str);
142    let response = http.send_tx(&request).await?;
143
144    Ok(format!(
145        "integrator={LIGHTER_NAUTILUS_INTEGRATOR_ACCOUNT_INDEX} account_index={} tx_hash={}",
146        credential.account_index(),
147        response.tx_hash,
148    ))
149}
150
151/// Revoke the Nautilus integrator approval when leaving the adapter.
152///
153/// This cleanup call is not a trading-mode toggle. Live trading through this
154/// adapter requires the approval; the next execution-client startup records a
155/// fresh zero-fee approval.
156///
157/// See:
158/// <https://nautilustrader.io/docs/nightly/integrations/lighter.html#integrator-attribution>.
159///
160/// Reads L2 credentials from `LIGHTER_API_KEY_INDEX`, `LIGHTER_API_SECRET`,
161/// and `LIGHTER_ACCOUNT_INDEX` (or the `LIGHTER_TESTNET_*` variants).
162///
163/// Returns a status string on the awaitable; raises on failure.
164#[pyfunction]
165#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.lighter")]
166#[pyo3(name = "revoke_lighter_integrator", signature = (environment = LighterEnvironment::Mainnet))]
167fn py_revoke_lighter_integrator(
168    py: Python<'_>,
169    environment: LighterEnvironment,
170) -> PyResult<Bound<'_, PyAny>> {
171    pyo3_async_runtimes::tokio::future_into_py(py, async move {
172        submit_integrator_revocation(environment)
173            .await
174            .map(|s| format!("submitted revocation for {s}"))
175            .map_err(to_pyvalue_err)
176    })
177}
178
179/// Loaded as `nautilus_pyo3.lighter`.
180#[pymodule]
181pub fn lighter(m: &Bound<'_, PyModule>) -> PyResult<()> {
182    m.add(stringify!(LIGHTER), LIGHTER)?;
183    m.add_class::<LighterEnvironment>()?;
184    m.add_class::<LighterDataClientConfig>()?;
185    m.add_class::<LighterExecClientConfig>()?;
186    m.add_class::<LighterDataClientFactory>()?;
187    m.add_class::<LighterExecutionClientFactory>()?;
188    m.add_function(wrap_pyfunction!(py_revoke_lighter_integrator, m)?)?;
189
190    let registry = get_global_pyo3_registry();
191
192    if let Err(e) =
193        registry.register_factory_extractor(LIGHTER.to_string(), extract_lighter_data_factory)
194    {
195        return Err(to_pyruntime_err(format!(
196            "Failed to register Lighter data factory extractor: {e}"
197        )));
198    }
199
200    if let Err(e) =
201        registry.register_exec_factory_extractor(LIGHTER.to_string(), extract_lighter_exec_factory)
202    {
203        return Err(to_pyruntime_err(format!(
204            "Failed to register Lighter exec factory extractor: {e}"
205        )));
206    }
207
208    if let Err(e) = registry.register_config_extractor(
209        "LighterDataClientConfig".to_string(),
210        extract_lighter_data_config,
211    ) {
212        return Err(to_pyruntime_err(format!(
213            "Failed to register Lighter data config extractor: {e}"
214        )));
215    }
216
217    if let Err(e) = registry.register_config_extractor(
218        "LighterExecClientConfig".to_string(),
219        extract_lighter_exec_config,
220    ) {
221        return Err(to_pyruntime_err(format!(
222            "Failed to register Lighter exec config extractor: {e}"
223        )));
224    }
225
226    Ok(())
227}