@sentico-labs/sdk 0.1.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Local (client-side) canonical encoding and signing-hash computation for
3
+ * Senticore trading actions.
4
+ *
5
+ * This removes the `POST /api/v1/trading/actions/hash` roundtrip from the
6
+ * order hot path: the canonical bytes and the blake3 signing hash are
7
+ * computed locally, signed locally, and submitted in a single HTTP call.
8
+ *
9
+ * CANONICAL ENCODING RULES (must match `senticore-types` byte-for-byte):
10
+ * - JSON with NO whitespace.
11
+ * - Object keys in Rust struct DECLARATION order (NOT alphabetical). The
12
+ * `canonicalPayload` echoed by the server /hash endpoint is alphabetically
13
+ * sorted for display — hashing that JSON gives a DIFFERENT hash. Always
14
+ * hash the declaration-order encoding produced here.
15
+ * - Field names in snake_case (the internal wire names).
16
+ * - Optional fields are ALWAYS serialized (as null when absent).
17
+ * - Account ids are 0x-prefixed lowercase hex (20 bytes), order ids
18
+ * 0x-prefixed lowercase hex (32 bytes).
19
+ * - Enums: Side "Bid"/"Ask"; Book "YES"/"NO"; time_in_force / stp_mode /
20
+ * trigger_direction / conditional_kind in snake_case.
21
+ * - The outcome variant is serialized with its INTERNAL tag "PlaceOrder"
22
+ * (the public alias "OutcomePlaceOrder" is an input-side name only).
23
+ * - hash = blake3("SENTICORE/ACTION_PAYLOAD/v1" || canonical_bytes).
24
+ *
25
+ * Golden vectors pinning compatibility with the backend live in
26
+ * `tests/signing.test.ts` and in `senticore-types::tests::signing_hash_golden_vectors`.
27
+ */
28
+ import { blake3 } from "@noble/hashes/blake3";
29
+ export const ACTION_PAYLOAD_DOMAIN_V1 = "SENTICORE/ACTION_PAYLOAD/v1";
30
+ // -----------------------------
31
+ // Canonical JSON writer
32
+ // -----------------------------
33
+ function writeUint(value, field) {
34
+ if (typeof value === "bigint") {
35
+ if (value < 0n)
36
+ throw new Error(`${field} must be non-negative`);
37
+ return value.toString(10);
38
+ }
39
+ if (!Number.isSafeInteger(value) || value < 0) {
40
+ throw new Error(`${field} must be a non-negative safe integer (use bigint above 2^53-1)`);
41
+ }
42
+ return value.toString(10);
43
+ }
44
+ function writeString(value) {
45
+ return JSON.stringify(value);
46
+ }
47
+ function writeHexBytes(value, bytes, field) {
48
+ const normalized = value.toLowerCase();
49
+ if (!/^0x[0-9a-f]+$/.test(normalized) || normalized.length !== 2 + bytes * 2) {
50
+ throw new Error(`${field} must be 0x-prefixed hex of ${bytes} bytes`);
51
+ }
52
+ return writeString(normalized);
53
+ }
54
+ function writeOptionalUint(value, field) {
55
+ return value === null || value === undefined ? "null" : writeUint(value, field);
56
+ }
57
+ function writeOptionalString(value) {
58
+ return value === null || value === undefined ? "null" : writeString(value);
59
+ }
60
+ function writeOrderFlags(flags) {
61
+ return (`"stp_mode":${writeOptionalString(flags.stpMode)},` +
62
+ `"time_in_force":${writeOptionalString(flags.timeInForce)},` +
63
+ `"is_market":${flags.isMarket === true},` +
64
+ `"reduce_only":${flags.reduceOnly === true},` +
65
+ `"expires_at":${writeOptionalUint(flags.expiresAt, "expiresAt")}`);
66
+ }
67
+ function writeSpotLegBody(leg, withBook) {
68
+ const book = withBook
69
+ ? `"book":${writeString(leg.book)},`
70
+ : "";
71
+ const cancel = leg.cancelOrderId === null || leg.cancelOrderId === undefined
72
+ ? "null"
73
+ : writeHexBytes(leg.cancelOrderId, 32, "cancelOrderId");
74
+ return (`{"cancel_order_id":${cancel},` +
75
+ book +
76
+ `"side":${writeString(leg.side)},` +
77
+ `"price":${writeUint(leg.price, "price")},` +
78
+ `"qty":${writeUint(leg.qty, "qty")},` +
79
+ writeOrderFlags(leg) +
80
+ `}`);
81
+ }
82
+ function writeAction(action) {
83
+ switch (action.kind) {
84
+ case "SpotPlaceOrder":
85
+ return (`{"SpotPlaceOrder":{` +
86
+ `"market":${writeUint(action.market, "market")},` +
87
+ `"side":${writeString(action.side)},` +
88
+ `"price":${writeUint(action.price, "price")},` +
89
+ `"qty":${writeUint(action.qty, "qty")},` +
90
+ writeOrderFlags(action) +
91
+ `}}`);
92
+ case "OutcomePlaceOrder":
93
+ // Internal canonical tag is PlaceOrder; book comes right after market.
94
+ return (`{"PlaceOrder":{` +
95
+ `"market":${writeUint(action.market, "market")},` +
96
+ `"book":${writeString(action.book)},` +
97
+ `"side":${writeString(action.side)},` +
98
+ `"price":${writeUint(action.price, "price")},` +
99
+ `"qty":${writeUint(action.qty, "qty")},` +
100
+ writeOrderFlags(action) +
101
+ `}}`);
102
+ case "Cancel":
103
+ return `{"Cancel":{"order_id":${writeHexBytes(action.orderId, 32, "orderId")}}}`;
104
+ case "AmendOrder":
105
+ return (`{"AmendOrder":{` +
106
+ `"order_id":${writeHexBytes(action.orderId, 32, "orderId")},` +
107
+ `"new_qty":${writeUint(action.newQty, "newQty")}}}`);
108
+ case "SpotQuoteReplace":
109
+ return (`{"SpotQuoteReplace":{` +
110
+ `"market":${writeUint(action.market, "market")},` +
111
+ `"legs":[${action.legs.map((leg) => writeSpotLegBody(leg, false)).join(",")}]}}`);
112
+ case "QuoteReplace":
113
+ return (`{"QuoteReplace":{` +
114
+ `"market":${writeUint(action.market, "market")},` +
115
+ `"legs":[${action.legs.map((leg) => writeSpotLegBody(leg, true)).join(",")}]}}`);
116
+ default: {
117
+ const exhaustive = action;
118
+ throw new Error(`unsupported action ${exhaustive.kind}`);
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Canonical declaration-order JSON for an action payload — the EXACT bytes
124
+ * the backend hashes and persists.
125
+ */
126
+ export function canonicalActionPayloadJson(payload) {
127
+ return (`{"account":${writeHexBytes(payload.account, 20, "account")},` +
128
+ `"nonce":${writeUint(payload.nonce, "nonce")},` +
129
+ `"nonce_reservation_id":${writeOptionalString(payload.nonceReservationId)},` +
130
+ `"ts":${writeUint(payload.ts, "ts")},` +
131
+ `"action":${writeAction(payload.action)}}`);
132
+ }
133
+ /** blake3(domain || canonical_bytes) as raw bytes. */
134
+ export function actionSigningHash(payload) {
135
+ const canonical = new TextEncoder().encode(canonicalActionPayloadJson(payload));
136
+ const domain = new TextEncoder().encode(ACTION_PAYLOAD_DOMAIN_V1);
137
+ const joined = new Uint8Array(domain.length + canonical.length);
138
+ joined.set(domain, 0);
139
+ joined.set(canonical, domain.length);
140
+ return blake3(joined);
141
+ }
142
+ /** blake3 signing hash as 0x-prefixed lowercase hex. */
143
+ export function actionSigningHashHex(payload) {
144
+ const hash = actionSigningHash(payload);
145
+ let hex = "";
146
+ for (const byte of hash)
147
+ hex += byte.toString(16).padStart(2, "0");
148
+ return `0x${hex}`;
149
+ }
@@ -0,0 +1,156 @@
1
+ export type HexAddress = `0x${string}`;
2
+ export type JsonPrimitive = string | number | boolean | null;
3
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
4
+ [key: string]: JsonValue;
5
+ };
6
+ export type JsonRecord = Record<string, JsonValue>;
7
+ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
8
+ export interface SenticoreSdkConfig {
9
+ publicHttpBaseUrl: string;
10
+ tradingHttpBaseUrl: string;
11
+ publicWsUrl: string;
12
+ privateWsUrl: string;
13
+ orderEntryHttpBaseUrl?: string;
14
+ orderEntryBinaryPath?: string;
15
+ orderEntryApiKey?: string;
16
+ /** @deprecated Use orderEntryHttpBaseUrl. */
17
+ mmHttpBaseUrl?: string;
18
+ /** @deprecated Use orderEntryBinaryPath. */
19
+ mmBinaryPath?: string;
20
+ bearerToken?: string;
21
+ /** @deprecated Use orderEntryApiKey. */
22
+ mmApiKey?: string;
23
+ timeoutMs?: number;
24
+ maxRetries?: number;
25
+ retryBackoffMs?: number;
26
+ userAgent?: string;
27
+ headers?: Record<string, string>;
28
+ fetch?: FetchLike;
29
+ WebSocketCtor?: typeof WebSocket;
30
+ }
31
+ export interface NormalizedSenticoreSdkConfig {
32
+ publicHttpBaseUrl: string;
33
+ tradingHttpBaseUrl: string;
34
+ publicWsUrl: string;
35
+ privateWsUrl: string;
36
+ orderEntryHttpBaseUrl: string;
37
+ orderEntryBinaryPath: string;
38
+ orderEntryApiKey?: string;
39
+ mmHttpBaseUrl: string;
40
+ mmBinaryPath: string;
41
+ bearerToken?: string;
42
+ mmApiKey?: string;
43
+ timeoutMs: number;
44
+ maxRetries: number;
45
+ retryBackoffMs: number;
46
+ userAgent: string;
47
+ headers: Record<string, string>;
48
+ fetch: FetchLike;
49
+ WebSocketCtor?: typeof WebSocket;
50
+ }
51
+ export interface RateLimitInfo {
52
+ limit?: string;
53
+ remaining?: string;
54
+ reset?: string;
55
+ retryAfter?: string;
56
+ raw: Record<string, string>;
57
+ }
58
+ export interface ApiResponse<T = unknown> {
59
+ data: T;
60
+ status: number;
61
+ headers: Headers;
62
+ requestId?: string;
63
+ rateLimit?: RateLimitInfo;
64
+ }
65
+ export interface RequestOptions {
66
+ bearerToken?: string;
67
+ idempotencyKey?: string;
68
+ headers?: Record<string, string>;
69
+ retry?: boolean;
70
+ }
71
+ export interface WsSessionTokenResponse {
72
+ ok: boolean;
73
+ account: HexAddress;
74
+ scope: "private_ws:read";
75
+ tokenType: "Bearer";
76
+ token: string;
77
+ issuedAtMs: number;
78
+ expiresAtMs: number;
79
+ ttlMs: number;
80
+ ipBound: boolean;
81
+ clientId?: string;
82
+ }
83
+ export interface SubscribeRequest {
84
+ id?: string | number;
85
+ method: "subscribe" | "unsubscribe";
86
+ subscription: Record<string, unknown>;
87
+ }
88
+ export interface AuthRequest {
89
+ id?: string | number;
90
+ method: "auth";
91
+ authorization: string;
92
+ }
93
+ export interface SenticoreWsFrame<T = unknown> {
94
+ type?: string;
95
+ event?: string;
96
+ channel?: string;
97
+ seq?: number | null;
98
+ prev_seq?: number | null;
99
+ ts?: number;
100
+ data?: T;
101
+ error?: unknown;
102
+ id?: string | number | null;
103
+ [key: string]: unknown;
104
+ }
105
+ export interface Signature {
106
+ scheme: string;
107
+ bytes: number[] | Uint8Array | string;
108
+ }
109
+ export interface SignedAction {
110
+ payload: Record<string, unknown>;
111
+ signature: Signature;
112
+ }
113
+ export interface MmActionBatch {
114
+ version: 1;
115
+ actions: SignedAction[];
116
+ idempotencyKey?: string;
117
+ }
118
+ export interface MmActionBatchResponse {
119
+ ok: boolean;
120
+ seqs: number[];
121
+ derivedOrderIds: Array<string | null>;
122
+ error: string | null;
123
+ responseMode?: string;
124
+ acceptedActions?: number;
125
+ lastSeq?: number;
126
+ seqChecksum?: string;
127
+ ackMode?: string;
128
+ ackClass?: string;
129
+ durableLsn?: number;
130
+ durablePendingSeqs?: number;
131
+ rateLimit?: Record<string, unknown> | null;
132
+ signals?: Record<string, unknown> | null;
133
+ [key: string]: unknown;
134
+ }
135
+ export type OrderEntryActionBatch = MmActionBatch;
136
+ export type OrderEntryActionBatchResponse = MmActionBatchResponse;
137
+ export interface PrivateWsTokenOptions {
138
+ ttlMs?: number;
139
+ clientId?: string;
140
+ }
141
+ export interface MarketOrderbookOptions {
142
+ book?: "YES" | "NO";
143
+ depth?: number;
144
+ }
145
+ export interface MarketTradesOptions {
146
+ limit?: number;
147
+ }
148
+ export interface MarketCandlesOptions {
149
+ tf?: "1m" | "5m" | "15m" | "1h" | "4h" | "1d" | "1D" | "1W";
150
+ beforeTs?: number;
151
+ countBack?: number;
152
+ }
153
+ export interface NonceReservationOptions extends RequestOptions {
154
+ count?: number;
155
+ ttlMs?: number;
156
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { NormalizedSenticoreSdkConfig, SenticoreSdkConfig, SenticoreWsFrame, SubscribeRequest, WsSessionTokenResponse } from "./types.js";
2
+ export declare class SenticoreWsClient {
3
+ private readonly config;
4
+ constructor(config: SenticoreSdkConfig | NormalizedSenticoreSdkConfig);
5
+ connectPublic(onMessage: (frame: SenticoreWsFrame) => void): WebSocket;
6
+ connectPrivate(account: string, token: string | WsSessionTokenResponse, onMessage: (frame: SenticoreWsFrame) => void): WebSocket;
7
+ authenticate(socket: WebSocket, token: string, id?: string | number): void;
8
+ subscribe(socket: WebSocket, subscription: SubscribeRequest["subscription"], id?: string): void;
9
+ unsubscribe(socket: WebSocket, subscription: SubscribeRequest["subscription"], id?: string): void;
10
+ parseFrame(data: string | ArrayBuffer | Blob): SenticoreWsFrame;
11
+ private connect;
12
+ }
13
+ export declare function isReplayGapFrame(frame: SenticoreWsFrame): boolean;
package/dist/src/ws.js ADDED
@@ -0,0 +1,78 @@
1
+ import { normalizeConfig } from "./config.js";
2
+ import { ReplayGapError } from "./errors.js";
3
+ export class SenticoreWsClient {
4
+ config;
5
+ constructor(config) {
6
+ this.config = normalizeConfig(config);
7
+ }
8
+ connectPublic(onMessage) {
9
+ return this.connect(this.config.publicWsUrl, onMessage);
10
+ }
11
+ connectPrivate(account, token, onMessage) {
12
+ const url = this.config.privateWsUrl.replace("{account}", account);
13
+ const socket = this.connect(url, onMessage);
14
+ socket.addEventListener("open", () => {
15
+ this.authenticate(socket, typeof token === "string" ? token : token.token);
16
+ });
17
+ return socket;
18
+ }
19
+ authenticate(socket, token, id = "auth-1") {
20
+ const authorization = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
21
+ const auth = {
22
+ id,
23
+ method: "auth",
24
+ authorization
25
+ };
26
+ socket.send(JSON.stringify(auth));
27
+ }
28
+ subscribe(socket, subscription, id = "sub-1") {
29
+ const payload = {
30
+ id,
31
+ method: "subscribe",
32
+ subscription
33
+ };
34
+ socket.send(JSON.stringify(payload));
35
+ }
36
+ unsubscribe(socket, subscription, id = "unsub-1") {
37
+ const payload = {
38
+ id,
39
+ method: "unsubscribe",
40
+ subscription
41
+ };
42
+ socket.send(JSON.stringify(payload));
43
+ }
44
+ parseFrame(data) {
45
+ if (typeof data !== "string") {
46
+ throw new Error("Senticore WebSocket binary frames are not supported by parseFrame");
47
+ }
48
+ const frame = JSON.parse(data);
49
+ if (isReplayGapFrame(frame)) {
50
+ throw new ReplayGapError("Senticore WebSocket replay gap", frame);
51
+ }
52
+ return frame;
53
+ }
54
+ connect(url, onMessage) {
55
+ const WebSocketCtor = this.config.WebSocketCtor;
56
+ if (!WebSocketCtor) {
57
+ throw new Error("Senticore SDK requires WebSocket or config.WebSocketCtor");
58
+ }
59
+ const socket = new WebSocketCtor(url);
60
+ socket.addEventListener("message", (event) => {
61
+ onMessage(this.parseFrame(event.data));
62
+ });
63
+ return socket;
64
+ }
65
+ }
66
+ export function isReplayGapFrame(frame) {
67
+ const type = String(frame.type ?? frame.event ?? "").toLowerCase();
68
+ if (type.includes("gap"))
69
+ return true;
70
+ if (typeof frame.error === "string")
71
+ return frame.error.toLowerCase().includes("gap");
72
+ if (frame.error && typeof frame.error === "object") {
73
+ const record = frame.error;
74
+ return String(record.code ?? "").toLowerCase().includes("gap") ||
75
+ String(record.message ?? "").toLowerCase().includes("gap");
76
+ }
77
+ return false;
78
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@sentico-labs/sdk",
3
+ "version": "0.1.0-preview.1",
4
+ "private": false,
5
+ "description": "Institutional TypeScript SDK for Senticore HTTP, WebSocket, and Order Entry.",
6
+ "type": "module",
7
+ "main": "./dist/src/index.js",
8
+ "types": "./dist/src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/src/index.d.ts",
12
+ "import": "./dist/src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/src",
17
+ "dist/examples",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "check": "tsc -p tsconfig.json --noEmit",
24
+ "test": "npm run build && node --test dist/tests",
25
+ "prepack": "npm run build",
26
+ "pack:check": "npm pack --dry-run",
27
+ "verify": "npm run check && npm run test && npm run pack:check",
28
+ "verify:ci": "npm run verify"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^24.10.1",
32
+ "typescript": "^5.6.3"
33
+ },
34
+ "dependencies": {
35
+ "@noble/hashes": "^1.8.0"
36
+ }
37
+ }