Skip to main content

Order Concurrency & Nonces

This page explains how to submit many orders concurrently from a single account - the capability quote engines and HFT desks need - and how the windowed nonce model makes it safe. If your client assumes strictly sequential nonces, read this first: Senticore does not require that, and assuming it does will needlessly serialize your flow.

The problem with sequential nonces​

A strictly sequential nonce rule — each order's nonce must be exactly previous + 1 — turns one account into a single-file queue: order N+1 cannot be accepted until order N has been acknowledged. For a market maker running tens or hundreds of in-flight quotes per account, that head-of-line blocking caps throughput at one round-trip per order and makes a single slow ack stall the whole strategy.

The solution: a windowed nonce​

Each account has a nonce window of 256. A signed action's nonce is accepted if it is:

  • inside the open window [nonceFloor, nonceFloor + 256), and
  • not already used or in flight.

Gaps and out-of-order submission are allowed. You do not have to wait for an ack before sending the next nonce, and you do not have to reserve a range. This means an account can have up to 256 orders in flight at once, submitted concurrently across as many parallel connections as you like.

The floor advances automatically: as contiguous nonces at the bottom of the window are consumed, nonceFloor slides up and the window opens further ahead. So a steadily-incrementing counter keeps the window moving with you.

In long-running market-maker processes, still treat nonceFloor as live server state. Refresh it from a fresh bootstrap during startup, after maintenance actions such as cancel-all, and whenever a nonce reject returns a newer floor. Historical holes can close later and move the floor farther than a stale local counter expects; snapping to the server-provided floor keeps the quote loop moving without serializing the hotpath.

Concurrency is per account

The window is per account, not per connection. Two connections (or an HTTP client plus a FIX session) sharing one account share one window. Coordinate your nonce counter across them, or shard strategies across accounts.

Nonce is replay protection, not execution priority​

A lower nonce does not get matched first. Execution order is decided by the order in which signed actions arrive at the sequencer, not by nonce value. The nonce only protects against replay and duplicates. Send nonce 4810 and 4811 concurrently and whichever the sequencer ingests first is sequenced first - design your strategy around arrival order, not nonce order.

How to pipeline orders​

  1. Keep a single monotonically increasing nonce counter per account (start it at nonceFloor from any recent nonce response, or use 0 for a fresh account after confirming the account's bootstrap state).
  2. For each order, take the next counter value, sign, and fire immediately - do not block on the previous ack.
  3. Keep your in-flight depth at or below the window size (256). If you routinely need more than 256 simultaneously-open nonces on one account, spread the strategy across multiple accounts.
  4. Reconcile fills and order state from the private execution stream or a full-mode response, not from nonce order.
// Fire 50 quotes concurrently from one account - no waiting between submits.
const base = nonceFloor; // from a recent response
const orders = buildQuotes(); // your 50 price/size pairs
const chainBinding = (await client.orderEntry.getActionChainBinding()).data;

await Promise.all(
orders.map((order, i) => {
const payload = {
account,
nonce: base + i, // unique, in-window, out-of-order OK
ts: Date.now(),
action: order,
};
return client.trading.submitSignedAction(
signAction(payload, key, { chainBinding }),
{ idempotencyKey: `quote-${base + i}` },
);
}),
);

You may submit these on one connection or many; the window does not care. The admission precheck explicitly ignores an action's own already-claimed pending nonce, so a burst like the one above does not falsely reject itself - while a genuinely duplicate (account, nonce) is still rejected.

Recovering from a nonce reject​

Every nonce rejection is structured and carries the window so you can resync without guessing. The response includes nonceFloor, nonceWindow (256), nextUsableNonce (a convenience hint equal to nonceFloor), and a stable code:

{
"accepted": false,
"code": "nonce_below_floor",
"error": "nonce 4600 is below replay window; nonceFloor=4810 nonceWindow=256",
"nextUsableNonce": 4810,
"nonceFloor": 4810,
"nonceWindow": 256
}
CodeRetryableWhat to do
nonce_below_floorNo - pick freshThe nonce is permanently stale/consumed. Advance your counter to at least nonceFloor and pick an unused in-window value.
nonce_outside_windowAfter the floor advancesYou ran too far ahead. Fill the lower in-window nonces first (or fence past them, see below); the floor advances and the window opens.
nonce_replayedNo - pick freshThat nonce is already used or in flight. Pick a different unused in-window value.
nonce_mismatchAfter resyncLegacy/uncategorized reject. Resync from nonceFloor / nextUsableNonce.

Nonce rejects additionally carry nonceHoles — the open (unconsumed) nonces at/above the floor, ascending, capped server-side:

{
"accepted": false,
"code": "nonce_outside_window",
"nonceFloor": 4810,
"nonceWindow": 256,
"nonceHoles": [4810, 4812],
"nextUsableNonce": 4810
}

On production the window-edge reject reads … above replay window (numeric reason 205 on the binary lanes) and its nonceHoles are directly usable — take one of them. The same window view is on GET /api/v1/accounts/{account} as nonceFloor, nonceHoles, nonceUsedInWindow and nextNonce.

Practical rule: on any nonce reject, snap your local counter to max(localCounter, nonceFloor), drop the rejected nonce, and continue. You never need to halt the whole account for one stale nonce.

Inspecting the window: GET /nonce-state​

GET /api/v1/trading/nonce-state/{account} (bearer, read scope)
{
"account": "0x2222…",
"nonceFloor": 4810,
"nextUsableNonce": 4810,
"nonceWindow": 256,
"usedInWindow": 254,
"committedUsedInWindow": 253,
"pendingInWindow": 1,
"availableNonceCount": 2,
"windowUtilizationBps": 9921,
"oldestHole": 4810,
"holes": [4810, 4812],
"holesComplete": true,
"holesLimit": 256,
"holesTotal": 2
}

The same telemetry rides on the private WebSocket account bootstrap as nonceFloor, nonceWindow, nonceUsedInWindow, and nonceHoles, so a long-running market maker can watch window health (e.g. an aging hole) without polling. On the operations side, the non-public metrics listener exposes sequencer_nonce_rejects_total{code="below_floor"|"outside_window"|"replayed"}. Production Edge intentionally returns 404 for public /api/v1/metrics; obtain Prometheus access through the operator monitoring network rather than building a client dependency on that path.

Healing a wedged window in one action: the nonce fence​

Holes shrink your effective pipeline depth until they are consumed. Instead of re-signing every missing nonce individually, submit one owner-signed AdvanceNonceFloor action (the nonce fence):

{
"account": "0x2222…",
"nonce": 4810,
"ts": 1710000000000,
"action": { "AdvanceNonceFloor": { "to": 4816 } }
}
  • Sign it with the floor nonce — the floor is by definition unconsumed, so the fence is always admissible even on a fully wedged window.
  • It advances the floor to to, consuming every open hole below it in one deterministic step. to at or below the floor is a no-op; to beyond floor + 256 is clamped to one window.
  • Everything below the new floor is permanently replay-protected afterwards.
  • The fence is a strict-nonce consensus action like any other: it cannot be replayed, and it is submitted/signed exactly like a withdraw request.

The recommended crash-recovery flow for market makers: bootstrap → cancel-all → fence → resume pipelining from the fresh floor.

Let the SDK do all of this: NonceManager​

The TypeScript SDK ships a NonceManager that encapsulates the whole contract — monotonic allocation, an in-flight cap, reject resync, hole tracking, and fence construction:

import { NonceManager, SenticoreClient } from "@sentico-labs/sdk";

const client = new SenticoreClient({ /* … */ });
const nonces = new NonceManager({
account,
stateSource: client.trading, // GET /nonce-state resync
maxInFlight: 128, // leave head-room below the 256 window
});

await nonces.syncFromServer();

// Pipelined submission — no ack-waiting, cap enforced automatically.
await Promise.all(quotes.map(async (quote) => {
const slot = await nonces.acquire();
let terminal;
try {
const { data } = await client.trading.submitSignedAction(
signAction({ account, nonce: slot.nonce, ts: Date.now(), action: quote }, key),
);
terminal = data;
const observed = nonces.observeSubmission(data); // auto-resync on nonce rejects
if (observed.nonceReject && observed.holes.length > 3) {
// Too many holes: heal them all with one fence instead of re-signing each.
const fence = nonces.buildFencePayload();
await client.trading.submitSignedAction(signAction(fence, key));
await nonces.syncFromServer();
}
} finally {
// Only a definitive nonceConsumed:false terminal result is reusable.
// A timeout or lost response remains uncertain and must not be recycled.
slot.settle(terminal);
}
}));

An ingress acknowledgement with accepted: true is not yet a terminal nonce receipt. Reconcile the corresponding full-mode actionResults entry or durable receipt lookup and call lease.observeTerminal(result) on the lease that allocated the nonce (the numeric nonces.observeTerminal(nonce, result) form is deprecated and is ignored once a nonce has been recycled). Drop-copy may be used as the correlation trigger, but does not by itself prove nonce consumption. The manager recycles only a nonce explicitly reported as nonceConsumed: false. After a timeout or lost response, retry the identical signed action with the same idempotency key until its terminal result is known; never allocate the uncertain nonce to new work.

Which rejects consume the nonce​

OutcomeNonce consumed?How you can tell
Rejected at ingress: BSL compact acceptedActions: 0 with seqs: [] (validation, lifecycle/expiry, signature, nonce window, rate limit, ingress risk pre-check, halt)No, for every action in the requestNo sequence number and no receipt exists. The compact response has no top-level nonceConsumed; where actionResults is present its entries say nonceConsumed: false
Admitted, then dropped at apply (receipt status: dropped)No. The engine rolls the action back, including the nonceTerminal result nonceConsumed: false; the receipt itself has no nonceConsumed field
A QuoteReplace with a failing legNo. The whole action, its one nonce included, is rolled backAs above
Applied (fills, rests, cancels)YesReceipt status: applied / durable; terminal result nonceConsumed: true
No response / timeoutUnknownReconcile by receipt or idempotency key before reusing anything

A nonce that was definitively not consumed can be used again, either for the corrected action or for new work. A non-JSON 403 from Cloudflare or an HTML 429 from the edge proxy means the request never reached the API, so its nonces were not consumed either; if you are unsure whether an earlier attempt got through, reconcile before reusing the nonce.

What you do not need​

  • No nonce reservation. The old nonceReservationId flow is deprecated. New clients should leave it absent in SDK objects; canonical signing bytes still include nonce_reservation_id:null. Production signatures use the v2 chain-bound domain.
  • No ack-then-next loop. Do not serialize on acknowledgements.
  • No strict +1 sequence. Temporary gaps are fine; the floor slides over them as they fill. A definitively unconsumed nonce should be retried or recycled by the SDK. Fence abandoned gaps only after cancel-all during explicit crash/session recovery.

Note on transport vs action nonces​

The windowed nonce described here is the nonce inside the signed ActionPayload - it governs order replay protection and concurrency. It is distinct from the per-request SC-Nonce used on the HMAC API-agent transport, which is replay-checked as a (credentialId, SC-Timestamp, SC-Nonce) tuple. SC-Nonce need not increase monotonically; each request attempt needs a fresh tuple and signature. The concurrency guarantees on this page are about the action-payload nonce.