Skip to content

fix(python): use nanosecond nonces on every signed HTTP endpoint - #8

Open
Dusk1e wants to merge 1 commit into
Bulk-trade:mainfrom
Dusk1e:fix/python-nonce-scale
Open

fix(python): use nanosecond nonces on every signed HTTP endpoint#8
Dusk1e wants to merge 1 commit into
Bulk-trade:mainfrom
Dusk1e:fix/python-nonce-scale

Conversation

@Dusk1e

@Dusk1e Dusk1e commented Aug 12, 2026

Copy link
Copy Markdown

Summary

The Rust SDK has one nonce source, make_nonce() in crates/api-rust/src/api/parts/mod.rs, and every signed method uses it:

pub fn make_nonce() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos() as u64
}

BulkHttpClient.place_orders and BulkWebSocketClient agree — both use time.time_ns(). But four other signed endpoints on the same client divide it:

endpoint nonce expression unit
place_orders time.time_ns() ns
update_leverage int(time.time_ns() / 100000) 100 µs
manage_agent_wallet int(time.time_ns() / 1000000) ms
whitelist_faucet int(time.time_ns() / 1000000) ms
request_faucet int(time.time_ns() / 1000000) ms

Two consequences:

The nonce jumps backwards. Sampled at one frozen instant, the four divided endpoints emit a value 100000x–1000000x below what every other signed call from the same account emits. Any account that has placed an order is then sending leverage, agent-wallet and faucet transactions far below its own high-water mark.

Dividing quantises the nonce. int(ns / 1000000) is millisecond-granular by construction, so two calls inside the same millisecond produce the same nonce — which is the one thing a nonce has to avoid.

Changes

Use time.time_ns() in all four, matching place_orders, the WebSocket client and make_nonce(). Explicit nonce= arguments are untouched, and the two stale "in milliseconds" comments are corrected.

Verification

Loading bulk_http.py with the stubs the repo's own tests use, freezing the clock and reading the nonce each endpoint puts on the wire:

clock frozen at time.time_ns() == 1786500000123456789
(Rust SDK make_nonce() would return 1786500000123456789)

                      before                             after
endpoint              nonce on the wire   vs Rust        nonce on the wire   vs Rust
------------------------------------------------------------------------------------
place_orders          1786500000123456789        1x      1786500000123456789      1x
update_leverage       17865000001234        100000x      1786500000123456789      1x
manage_agent_wallet   1786500000123        1000000x      1786500000123456789      1x
whitelist_faucet      1786500000123        1000000x      1786500000123456789      1x
request_faucet        1786500000123        1000000x      1786500000123456789      1x

distinct nonces from two calls 500,000 ns apart, inside one millisecond:
                      before              after
  manage_agent_wallet 1 (SAME NONCE)      2 (unique)
  whitelist_faucet    1 (SAME NONCE)      2 (unique)
  request_faucet      1 (SAME NONCE)      2 (unique)

tests/test_history_http.py and tests/test_wire_serialization.py are unchanged by this (20 passed either way; the history failures on main are #7).

update_leverage divided time.time_ns() by 1e5, and manage_agent_wallet,
whitelist_faucet and request_faucet divided it by 1e6. place_orders, the
WebSocket client and the Rust SDK's make_nonce() all use plain nanoseconds,
so those four endpoints put a nonce on the wire 100000x-1000000x smaller
than every other signed call from the same account.

Dividing also quantises the nonce: at 1 ms granularity two calls inside the
same millisecond reuse one nonce.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant