Local Action Signing
The two-step wallet flow (POST /actions/hash, then POST /actions) costs one
extra HTTP round trip per action. The submit endpoint recomputes the signing
hash from the submitted payload; there is no server-side state between the
two calls. A client that reproduces the canonical bytes locally can therefore
skip /actions/hash entirely and submit signed actions in one round trip.
The TypeScript SDK ships this as canonicalActionPayloadJson(),
actionSigningHashHex(payload, { chainBinding }), deriveOrderIdHex(), and
signAction(payload, key, { chainBinding }) (see the @sentico-labs/sdk
signing module). Fetch chainBinding once at startup from
client.orderEntry.getActionChainBinding() and cache it; do not call
POST /actions/hash per order.
Latency model
POST /api/v1/trading/actions/hash is a compatibility and debugging endpoint,
not a hot-path dependency. The old two-call flow serialized every order behind a
server hash round trip:
old hot path:
client -> server: compute signing hash
client: sign returned hash
client -> server: submit signed action
Production clients should instead fetch the chain-binding data once at startup, then hash and sign locally for every order:
startup:
client -> server: GET /api/v1/bsl/connectivity
client: cache actionSigning.chainId and actionSigning.verifyingContract
steady-state hot path:
client: actionSigningHashHex(payload, { chainBinding })
client: signAction(payload, key, { chainBinding })
client -> server: submit signed action
This removes one serial network round trip from every order. Local canonical JSON encoding, blake3 hashing, and secp256k1 signing are CPU-local work; for MM quote loops they are far cheaper than waiting on another HTTP request. The server still recomputes the same v2 hash from the submitted payload and verifies the signature. Local signing reduces latency without asking the server to trust a client-supplied hash.
Hash definition
Production uses the v2 chain-bound domain:
signing_hash = blake3(
"SENTICORE/ACTION_PAYLOAD/v2"
|| chain_id as 8-byte big-endian
|| verifying_contract as 20 raw address bytes
|| canonical_bytes
)
blake3with default parameters, 32-byte output.- The domain string is prepended as raw ASCII bytes (no length prefix).
chain_idis the EVM chain id.verifying_contractis the live StateCommitment address.canonical_bytesis the canonical JSON encoding of the action payload, UTF-8, defined below.
Legacy v1 was blake3("SENTICORE/ACTION_PAYLOAD/v1" || canonical_bytes).
Keep v1 vectors for regression tests only; production signs v2.
Sign the 32-byte hash with the wallet key. The backend accepts:
- raw ECDSA over the 32-byte hash, and
- EIP-191
personal_signover the same 32-byte hash.
The submitted signature is 65 bytes in EVM order: r || s || v. Recovery byte
v may be 0/1 or 27/28.
Canonical JSON encoding
- No whitespace anywhere.
- Field order is the declaration order given in the tables below, NOT
alphabetical. This is the single most common integration mistake: the
canonicalPayloadechoed byPOST /actions/hashis alphabetically sorted for display; hashing that JSON produces a different, invalid hash. - Field names are the canonical snake_case wire names.
- Optional fields are always present, serialized as
nullwhen absent - with one exception:client_order_idis omitted entirely when absent (it is never emitted asnull). It appears in the canonical bytes only when you set it. - Account ids:
0x+ 40 lowercase hex chars. Order ids:0x+ 64 lowercase hex chars. - Integers are serialized as bare JSON numbers (no quotes). Quantities and prices are u64 atomic units; use a big-integer type in languages where 2^53 is a concern.
- Enums:
sideis"Bid"/"Ask",bookis"YES"/"NO";time_in_force(gtc,ioc,fok,post_only),stp_mode(cancel_maker,cancel_taker,reject,skip_self),trigger_direction,conditional_kindare snake_case strings. - The action is externally tagged:
{"<Variant>":{...}}. The outcome place-order variant is canonically taggedPlaceOrder(the public request aliasOutcomePlaceOrderis input-only and never part of the canonical bytes).
Envelope field order
| # | Field | Type |
|---|---|---|
| 1 | account | hex(20) |
| 2 | nonce | u64 |
| 3 | nonce_reservation_id | string | null |
| 4 | client_order_id | string (omitted when absent) |
| 5 | ts | u64 (unix ms) |
| 6 | action | tagged object |
client_order_id is optional. When present it appears here, between
nonce_reservation_id and ts, and changes both the signing hash and the
derived OrderId. When absent it is omitted from the bytes entirely (it is the
one optional field that is not serialized as null), so the golden vectors
below - none of which set it - remain byte-identical. A client that never sets
client_order_id does not need to change anything.
Variant field order
| Variant | Fields in canonical order |
|---|---|
SpotPlaceOrder | market, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
PlaceOrder (outcome) | market, book, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
Cancel | order_id |
AmendOrder | order_id, new_qty |
SpotQuoteReplace | market, legs[] |
QuoteReplace | market, legs[] |
Split | market, amount_usdc |
Merge | market, qty |
Claim | market, qty_yes, qty_no |
AdvanceNonceFloor | to |
WithdrawRequest | amount_usdc, to (null for the same account) |
WithdrawAssetRequest | asset, amount, to |
| spot leg | cancel_order_id, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
| outcome leg | cancel_order_id, book, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at |
Golden vectors
These vectors are asserted byte-for-byte by the backend test suite
(senticore-types) and the TypeScript SDK test suite. Validate your
implementation against all three before going live.
The vectors intentionally pin market 7 to preserve their canonical bytes and
expected hashes. Market 7 is not currently listed in production. Never submit
these payloads as orders; discover a live market through
GET /api/v1/public/exchange-info and build a new payload after the vector
check.
Vector 1: SpotPlaceOrder
Canonical bytes:
{"account":"0x1111111111111111111111111111111111111111","nonce":4810,"nonce_reservation_id":null,"ts":1765500000000,"action":{"SpotPlaceOrder":{"market":7,"side":"Bid","price":998400,"qty":1000,"stp_mode":null,"time_in_force":"post_only","is_market":false,"reduce_only":false,"expires_at":null}}}
Legacy v1 signing hash:
0xc8d02209196c492de5b39c90d7efd356548784ddd464603913b59afab911b42f
v2 signing hash for chainId=8453,
verifyingContract=0xcccccccccccccccccccccccccccccccccccccccc:
0x26d8533a5150e7a8bf6537824715562eddb55a0c019a0b23635533a1d4e7e084
Vector 2: Cancel
Canonical bytes:
{"account":"0x1111111111111111111111111111111111111111","nonce":4811,"nonce_reservation_id":null,"ts":1765500000001,"action":{"Cancel":{"order_id":"0x2222222222222222222222222222222222222222222222222222222222222222"}}}
Signing hash:
0xaecabe7c50eaa0a1a6f59b75687b64dce6f96fcaef509319051baff0e78eb38a
Vector 3: SpotQuoteReplace (one replace leg, legacy reserved nonce)
Canonical bytes:
{"account":"0x1111111111111111111111111111111111111111","nonce":4812,"nonce_reservation_id":"res-1","ts":1765500000002,"action":{"SpotQuoteReplace":{"market":7,"legs":[{"cancel_order_id":"0x2222222222222222222222222222222222222222222222222222222222222222","side":"Bid","price":998500,"qty":1189,"stp_mode":null,"time_in_force":"post_only","is_market":false,"reduce_only":false,"expires_at":null}]}}}
Signing hash:
0x0b635be460cf6d9ae3a9fe11c1b5d5176c942e9b6139f88dac142baa1818584c
This vector intentionally pins the legacy nonce_reservation_id:"res-1" bytes
because old clients and backend fixtures must remain byte-identical. New
production clients should omit nonceReservationId in SDK objects, which
serializes canonically as nonce_reservation_id:null.
TypeScript example
import {
actionSigningHashHex,
deriveOrderIdHex,
signAction,
type LocalActionPayload
} from "@sentico-labs/sdk";
const payload: LocalActionPayload = {
account: "0x1111111111111111111111111111111111111111",
nonce: 4810,
ts: 1765500000000,
action: {
kind: "SpotPlaceOrder",
market: BigInt(process.env.MARKET_ID!),
side: "Bid",
price: 998400,
qty: 1000,
timeInForce: "post_only",
},
};
const chainBinding = (await client.orderEntry.getActionChainBinding()).data;
const signedAction = signAction(payload, process.env.SENTICORE_PRIVATE_KEY!, {
chainBinding,
});
console.log({
signingHash: actionSigningHashHex(payload, { chainBinding }),
derivedOrderId: deriveOrderIdHex(payload),
});
await client.trading.submitSignedAction(signedAction, {
idempotencyKey: "client-order-4810",
});
Nonce model (windowed replay protection)
For locally signed actions, the signed nonce is part of the hash. The server cannot rewrite it after signing. The canonical JSON bytes are unchanged between v1 and v2; production additionally binds chain id and StateCommitment address in the v2 hash preimage.
A signed nonce n is accepted if and only if it is inside the account's replay
window and has not already been used:
nonceFloor <= n < nonceFloor + nonceWindow(currentlynonceWindow = 256),- and
nhas not already been consumed or is not already in-flight.
nonceFloor is the lowest still-acceptable nonce (the next strict nonce). This
means gaps and out-of-order submission are allowed: you may pipeline any unused
nonces in the open window in any order, without waiting for an ack or pre-reserving
a range. A strict, sequential client (nonceFloor, nonceFloor+1, …) is just the
simplest valid case.
Reject classes (machine-readable code, with discovery fields):
nonce_below_floor-n < nonceFloor: permanently stale/consumed. Pick a fresh in-window nonce; do not retry the same value.nonce_outside_window-n >= nonceFloor + nonceWindow: too far ahead. Retry after the floor advances (fill your lower nonces first).nonce_replayed-nis in-window but already used/in-flight. Pick a different unused in-window nonce.nonce_mismatch- legacy/uncategorized nonce reject.
Every nonce reject carries nonceFloor and nonceWindow so you can recompute the
valid open interval, plus nextUsableNonce (= nonceFloor) as a convenience hint.
Example reject:
{
"accepted": false,
"ok": false,
"seq": null,
"derivedOrderId": null,
"error": "nonce 4600 is below replay window; nonceFloor=4810 nonceWindow=256",
"code": "nonce_below_floor",
"nextUsableNonce": 4810,
"nonceFloor": 4810,
"nonceWindow": 256
}
Reservations are deprecated
Nonce reservations existed only to escape the old strict-contiguous rule. Under the
windowed model any unused in-window nonce is accepted, so reservations are no
longer required. The reserve endpoint and nonceReservationId remain accepted
for backward compatibility but are deprecated - new integrations should omit
nonceReservationId (keep it absent or null) and simply pick in-window nonces.
Notes
POST /actions/hashstays available as a debugging and reference endpoint; compare itshashagainst your local v2 result during integration. Do not keep it in the order hot path.POST /actions/hashcan fillnonce: 0for its returned reference payload. That convenience does not apply to locally signed actions.- The SDK can hash BigInt values above
2^53-1, but submit-ready JSON objects reject values that JavaScript would round. Keep public-beta price/quantity atoms inside JS safe integers for the JSON path, or use a raw encoder that preserves the decimal bytes exactly. deriveOrderIdHex(payload)usesblake3("SENTICORE/ORDER_ID/v1" || canonical_bytes)for place orders. For quote-replace place legs, usederiveQuoteReplaceOrderIdHex(payload, legIndex). Becauseclient_order_idis part ofcanonical_byteswhen present, setting it changes the derivedOrderId. It influences the derived id only; it is not stored in order state or thestate_root.- Any future change to the canonical encoding will ship under a new domain string; v1 and v2 vectors stay valid for regression tests.