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