Skip to main content

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() (see @sentico-labs/sdk signing module). This page is the normative spec for any other language.

Hash definition

signing_hash = blake3( "SENTICORE/ACTION_PAYLOAD/v1" || canonical_bytes )
  • blake3 with default parameters, 32-byte output.
  • The domain string is prepended as raw ASCII bytes (no length prefix).
  • canonical_bytes is the canonical JSON encoding of the action payload, UTF-8, defined below.

Sign the 32-byte hash with the wallet key (raw ECDSA over the hash, or personal_sign; both are accepted).

Canonical JSON encoding

  1. No whitespace anywhere.
  2. Field order is the declaration order given in the tables below, NOT alphabetical. This is the single most common integration mistake: the canonicalPayload echoed by POST /actions/hash is alphabetically sorted for display; hashing that JSON produces a different, invalid hash.
  3. Field names are the canonical snake_case wire names.
  4. Optional fields are always present, serialized as null when absent.
  5. Account ids: 0x + 40 lowercase hex chars. Order ids: 0x + 64 lowercase hex chars.
  6. 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.
  7. Enums: side is "Bid"/"Ask", book is "YES"/"NO"; time_in_force (gtc, ioc, fok, post_only), stp_mode (cancel_maker, cancel_taker, reject, skip_self), trigger_direction, conditional_kind are snake_case strings.
  8. The action is externally tagged: {"<Variant>":{...}}. The outcome place-order variant is canonically tagged PlaceOrder (the public request alias OutcomePlaceOrder is input-only and never part of the canonical bytes).

Envelope field order

#FieldType
1accounthex(20)
2nonceu64
3nonce_reservation_idstring | null
4tsu64 (unix ms)
5actiontagged object

Variant field order

VariantFields in canonical order
SpotPlaceOrdermarket, 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
Cancelorder_id
AmendOrderorder_id, new_qty
SpotQuoteReplacemarket, legs[]
QuoteReplacemarket, legs[]
spot legcancel_order_id, side, price, qty, stp_mode, time_in_force, is_market, reduce_only, expires_at
outcome legcancel_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.

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}}}

Signing hash:

0xc8d02209196c492de5b39c90d7efd356548784ddd464603913b59afab911b42f

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, 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

TypeScript example

import { actionSigningHashHex } from "@sentico-labs/sdk";

const hash = actionSigningHashHex({
account: "0x1111111111111111111111111111111111111111",
nonce: 4810,
ts: 1765500000000,
action: {
kind: "SpotPlaceOrder",
market: 7,
side: "Bid",
price: 998400,
qty: 1000,
timeInForce: "post_only",
},
});
// sign `hash` with the wallet, then POST /api/v1/trading/actions directly.

Notes

  • POST /actions/hash stays available as a debugging and reference endpoint; compare its hash against your local result during integration.
  • Nonces: local signing means the server no longer fills nonce: 0 for you. Track the account nonce locally and resynchronize from the structured nextUsableNonce field on nonce rejections (see Error Model), or use nonce range reservations.
  • Any change to the canonical encoding will ship under a new domain string (.../v2); v1 vectors stay valid for the lifetime of the v1 domain.