@metaflux-dex/client 0.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/client.d.ts +32 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +344 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/http.d.ts +17 -0
  8. package/dist/http.d.ts.map +1 -0
  9. package/dist/http.js +106 -0
  10. package/dist/http.js.map +1 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +26 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/info-types.d.ts +380 -0
  16. package/dist/info-types.d.ts.map +1 -0
  17. package/dist/info-types.js +16 -0
  18. package/dist/info-types.js.map +1 -0
  19. package/dist/info.d.ts +65 -0
  20. package/dist/info.d.ts.map +1 -0
  21. package/dist/info.js +252 -0
  22. package/dist/info.js.map +1 -0
  23. package/dist/native.d.ts +10 -0
  24. package/dist/native.d.ts.map +1 -0
  25. package/dist/native.js +252 -0
  26. package/dist/native.js.map +1 -0
  27. package/dist/types.d.ts +143 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +13 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/wasm.d.ts +28 -0
  32. package/dist/wasm.d.ts.map +1 -0
  33. package/dist/wasm.js +279 -0
  34. package/dist/wasm.js.map +1 -0
  35. package/dist/ws.d.ts +43 -0
  36. package/dist/ws.d.ts.map +1 -0
  37. package/dist/ws.js +221 -0
  38. package/dist/ws.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/client.ts +454 -0
  41. package/src/http.ts +153 -0
  42. package/src/index.ts +144 -0
  43. package/src/info-types.ts +783 -0
  44. package/src/info.ts +355 -0
  45. package/src/native.ts +307 -0
  46. package/src/types.ts +305 -0
  47. package/src/wasm.ts +384 -0
  48. package/src/ws.ts +279 -0
package/src/info.ts ADDED
@@ -0,0 +1,355 @@
1
+ // MTF-native `/info` read API — typed request builders + envelope unwrap.
2
+ //
3
+ // Byte-for-byte mirror of the server dispatcher
4
+ // (`metaflux/crates/api-node/src/rest/info.rs::handle_info`) and the per-handler
5
+ // shapes in `info/{reads,markets,hl_parity}.rs`. Every request is a
6
+ // `POST /info` whose body is `{"type": "<discriminator>", ...params}` —
7
+ // snake_case field names, the exact convention the node decodes. The node's
8
+ // `/info` surface is MTF-native ONLY; the HL `type` aliases (`meta` etc.) live
9
+ // on the gateway's hl_compat layer, not here.
10
+ //
11
+ // ENVELOPE. Every successful response is `{"type": "<query>", "data": {...}}`.
12
+ // `post` validates the echoed `type` and returns the unwrapped `data` typed —
13
+ // the unwrap lives in exactly one place (`post`). The `raw<T>()` escape hatch
14
+ // returns the unwrapped `data` too (use `rawEnvelope` for the full envelope).
15
+ //
16
+ // KEYING. The node `/info` is keyed by `0x` hex address for account / staking /
17
+ // vault / user reads (`account_state`, `staking_state`, `frontend_open_orders`,
18
+ // every hl_parity user read), by `vault` (0x) for `vault_state`, by `asset_id`
19
+ // or `coin` for `market_info`, and by `market_id` (u32) for the book / trade /
20
+ // funding reads. The account-history reads (`open_orders`, `user_fills`,
21
+ // `agents`, `sub_accounts`) accept EITHER `address` (0x) OR `account_id` (u64).
22
+ // There is NO numeric-id-only keying for accounts and NO gateway id translation
23
+ // on this surface.
24
+ //
25
+ // Money magnitudes that can exceed JS `Number.MAX_SAFE_INTEGER` (2^53) are
26
+ // typed `string` in `./info-types.js` to match the node's decimal-string
27
+ // encoding; ids / counts / bps stay `number`.
28
+
29
+ import { httpRequest } from './http.js';
30
+ import type {
31
+ AccountState,
32
+ ActiveAssetData,
33
+ Agents,
34
+ BlockInfo,
35
+ DelegatorSummary,
36
+ ExchangeStatus,
37
+ FeeSchedule,
38
+ FrontendOpenOrders,
39
+ FundingHistory,
40
+ GossipRootIps,
41
+ L2Book,
42
+ LeadingVaults,
43
+ Liquidatable,
44
+ MarginTable,
45
+ MarketInfo,
46
+ MaxBuilderFee,
47
+ MaxMarketOrderNtls,
48
+ Mip3ActiveBids,
49
+ NodeInfo,
50
+ OpenOrders,
51
+ PerpDexs,
52
+ PerpsAtOpenInterestCap,
53
+ RecentTrades,
54
+ SpotClearinghouseState,
55
+ SpotDeployState,
56
+ SpotMeta,
57
+ StakingState,
58
+ SubAccounts,
59
+ UserFills,
60
+ UserRateLimit,
61
+ UserRole,
62
+ UserToMultiSigSigners,
63
+ UserVaultEquities,
64
+ ValidatorL1Votes,
65
+ ValidatorSummaries,
66
+ VaultState,
67
+ VaultSummaries,
68
+ WebData2,
69
+ } from './info-types.js';
70
+
71
+ /// The committed `{type, data}` response envelope every `/info` query returns.
72
+ interface InfoEnvelope<T> {
73
+ type: string;
74
+ data: T;
75
+ }
76
+
77
+ /// An account-scoped read accepts EITHER a `0x` address OR an internal
78
+ /// `account_id` (u64). Used by `open_orders` / `user_fills` / `agents` /
79
+ /// `sub_accounts`, mirroring the node's `resolve_account`.
80
+ export type AccountRef = { address: string } | { account_id: number };
81
+
82
+ /// `/info` namespace handle. Each method POSTs a typed `{"type": ...}` body to
83
+ /// `POST <baseUrl>/info`, validates the `{type, data}` envelope, and returns
84
+ /// the unwrapped `data`.
85
+ ///
86
+ /// No signing required — these are read-only queries. Construct via
87
+ /// `Client.info` or directly with a base URL.
88
+ export class InfoApi {
89
+ constructor(private readonly baseUrl: string) {}
90
+
91
+ // ── documented core reads ──────────────────────────────────────────────
92
+
93
+ /// `node_info` — static node identity + protocol version.
94
+ async nodeInfo(): Promise<NodeInfo> {
95
+ return this.post<NodeInfo>({ type: 'node_info' });
96
+ }
97
+
98
+ /// `account_state` — rich per-account snapshot keyed by `address` (0x hex).
99
+ async accountState(address: string): Promise<AccountState> {
100
+ return this.post<AccountState>({ type: 'account_state', address });
101
+ }
102
+
103
+ /// `market_info` — rich per-market snapshot by canonical `asset_id` (u32).
104
+ async marketInfo(assetId: number): Promise<MarketInfo> {
105
+ return this.post<MarketInfo>({ type: 'market_info', asset_id: assetId });
106
+ }
107
+
108
+ /// `market_info` — rich per-market snapshot by human-readable `coin`.
109
+ async marketInfoByCoin(coin: string): Promise<MarketInfo> {
110
+ return this.post<MarketInfo>({ type: 'market_info', coin });
111
+ }
112
+
113
+ /// `markets` — every registered MIP-3 perp market (array of `MarketInfo`).
114
+ async markets(): Promise<MarketInfo[]> {
115
+ return this.post<MarketInfo[]>({ type: 'markets' });
116
+ }
117
+
118
+ /// `vault_state` — per-vault snapshot keyed by vault `address` (0x hex).
119
+ async vaultState(vaultAddress: string): Promise<VaultState> {
120
+ return this.post<VaultState>({ type: 'vault_state', vault: vaultAddress });
121
+ }
122
+
123
+ /// `staking_state` — per-account staking snapshot keyed by `address` (0x).
124
+ async stakingState(address: string): Promise<StakingState> {
125
+ return this.post<StakingState>({ type: 'staking_state', address });
126
+ }
127
+
128
+ /// `fee_schedule` — protocol fee schedule.
129
+ async feeSchedule(): Promise<FeeSchedule> {
130
+ return this.post<FeeSchedule>({ type: 'fee_schedule' });
131
+ }
132
+
133
+ // ── book / trade / account-history reads ────────────────────────────────
134
+
135
+ /// `open_orders` — account-scoped resting orders. Keyed by `address` (0x) or
136
+ /// `account_id` (u64).
137
+ async openOrders(ref: AccountRef): Promise<OpenOrders> {
138
+ return this.post<OpenOrders>({ type: 'open_orders', ...ref });
139
+ }
140
+
141
+ /// `l2_book` — market-scoped aggregated bid/ask levels by `market_id` (u32).
142
+ async l2Book(marketId: number): Promise<L2Book> {
143
+ return this.post<L2Book>({ type: 'l2_book', market_id: marketId });
144
+ }
145
+
146
+ /// `recent_trades` — market-scoped trade tape by `market_id` (u32).
147
+ async recentTrades(marketId: number): Promise<RecentTrades> {
148
+ return this.post<RecentTrades>({ type: 'recent_trades', market_id: marketId });
149
+ }
150
+
151
+ /// `user_fills` — account-scoped fill history. Keyed by `address` (0x) or
152
+ /// `account_id` (u64).
153
+ async userFills(ref: AccountRef): Promise<UserFills> {
154
+ return this.post<UserFills>({ type: 'user_fills', ...ref });
155
+ }
156
+
157
+ /// `funding_history` — market-scoped funding premium samples by `market_id`.
158
+ async fundingHistory(marketId: number): Promise<FundingHistory> {
159
+ return this.post<FundingHistory>({ type: 'funding_history', market_id: marketId });
160
+ }
161
+
162
+ /// `block_info` — latest committed block metadata. No parameters.
163
+ async blockInfo(): Promise<BlockInfo> {
164
+ return this.post<BlockInfo>({ type: 'block_info' });
165
+ }
166
+
167
+ /// `agents` — approved agent / API wallets. Keyed by `address` (0x) or
168
+ /// `account_id` (u64).
169
+ async agents(ref: AccountRef): Promise<Agents> {
170
+ return this.post<Agents>({ type: 'agents', ...ref });
171
+ }
172
+
173
+ /// `sub_accounts` — sub-accounts of an account. Keyed by `address` (0x) or
174
+ /// `account_id` (u64).
175
+ async subAccounts(ref: AccountRef): Promise<SubAccounts> {
176
+ return this.post<SubAccounts>({ type: 'sub_accounts', ...ref });
177
+ }
178
+
179
+ /// `mip3_active_bids` — MIP-3 permissionless perp-deploy auction snapshot.
180
+ async mip3ActiveBids(): Promise<Mip3ActiveBids> {
181
+ return this.post<Mip3ActiveBids>({ type: 'mip3_active_bids' });
182
+ }
183
+
184
+ // ── HL-node parity reads ────────────────────────────────────────────────
185
+
186
+ /// `spot_meta` — spot pair universe. No parameters.
187
+ async spotMeta(): Promise<SpotMeta> {
188
+ return this.post<SpotMeta>({ type: 'spot_meta' });
189
+ }
190
+
191
+ /// `spot_clearinghouse_state` — per-account spot token balances by `address`.
192
+ async spotClearinghouseState(address: string): Promise<SpotClearinghouseState> {
193
+ return this.post<SpotClearinghouseState>({
194
+ type: 'spot_clearinghouse_state',
195
+ address,
196
+ });
197
+ }
198
+
199
+ /// `exchange_status` — global trading status. No parameters.
200
+ async exchangeStatus(): Promise<ExchangeStatus> {
201
+ return this.post<ExchangeStatus>({ type: 'exchange_status' });
202
+ }
203
+
204
+ /// `frontend_open_orders` — resting orders + tif / cloid / trigger by
205
+ /// `address` (0x).
206
+ async frontendOpenOrders(address: string): Promise<FrontendOpenOrders> {
207
+ return this.post<FrontendOpenOrders>({ type: 'frontend_open_orders', address });
208
+ }
209
+
210
+ /// `liquidatable` — accounts currently flagged for liquidation. No params.
211
+ async liquidatable(): Promise<Liquidatable> {
212
+ return this.post<Liquidatable>({ type: 'liquidatable' });
213
+ }
214
+
215
+ /// `active_asset_data` — a user's per-asset leverage / margin-mode / max
216
+ /// trade by `address` (0x) + `asset_id` (u32).
217
+ async activeAssetData(address: string, assetId: number): Promise<ActiveAssetData> {
218
+ return this.post<ActiveAssetData>({
219
+ type: 'active_asset_data',
220
+ address,
221
+ asset_id: assetId,
222
+ });
223
+ }
224
+
225
+ /// `max_market_order_ntls` — per-asset max market-order notional. No params.
226
+ async maxMarketOrderNtls(): Promise<MaxMarketOrderNtls> {
227
+ return this.post<MaxMarketOrderNtls>({ type: 'max_market_order_ntls' });
228
+ }
229
+
230
+ /// `vault_summaries` — all vaults summary. No parameters.
231
+ async vaultSummaries(): Promise<VaultSummaries> {
232
+ return this.post<VaultSummaries>({ type: 'vault_summaries' });
233
+ }
234
+
235
+ /// `user_vault_equities` — vaults a user has deposited into by `address` (0x).
236
+ async userVaultEquities(address: string): Promise<UserVaultEquities> {
237
+ return this.post<UserVaultEquities>({ type: 'user_vault_equities', address });
238
+ }
239
+
240
+ /// `leading_vaults` — vaults led by the user by `address` (0x).
241
+ async leadingVaults(address: string): Promise<LeadingVaults> {
242
+ return this.post<LeadingVaults>({ type: 'leading_vaults', address });
243
+ }
244
+
245
+ /// `user_rate_limit` — a user's action stats / rate-limit budget by `address`.
246
+ async userRateLimit(address: string): Promise<UserRateLimit> {
247
+ return this.post<UserRateLimit>({ type: 'user_rate_limit', address });
248
+ }
249
+
250
+ /// `spot_deploy_state` — MIP-1 spot-pair-deploy gas-auction state. No params.
251
+ async spotDeployState(): Promise<SpotDeployState> {
252
+ return this.post<SpotDeployState>({ type: 'spot_deploy_state' });
253
+ }
254
+
255
+ /// `delegator_summary` — staking summary for an `address` (0x).
256
+ async delegatorSummary(address: string): Promise<DelegatorSummary> {
257
+ return this.post<DelegatorSummary>({ type: 'delegator_summary', address });
258
+ }
259
+
260
+ /// `max_builder_fee` — approved builder-fee ceiling for `(address, builder)`,
261
+ /// both 0x.
262
+ async maxBuilderFee(address: string, builder: string): Promise<MaxBuilderFee> {
263
+ return this.post<MaxBuilderFee>({ type: 'max_builder_fee', address, builder });
264
+ }
265
+
266
+ /// `user_to_multi_sig_signers` — multisig config for an `address` (0x).
267
+ async userToMultiSigSigners(address: string): Promise<UserToMultiSigSigners> {
268
+ return this.post<UserToMultiSigSigners>({
269
+ type: 'user_to_multi_sig_signers',
270
+ address,
271
+ });
272
+ }
273
+
274
+ /// `user_role` — derived account role for an `address` (0x).
275
+ async userRole(address: string): Promise<UserRole> {
276
+ return this.post<UserRole>({ type: 'user_role', address });
277
+ }
278
+
279
+ /// `perps_at_open_interest_cap` — assets whose OI is at/over the cap. No params.
280
+ async perpsAtOpenInterestCap(): Promise<PerpsAtOpenInterestCap> {
281
+ return this.post<PerpsAtOpenInterestCap>({ type: 'perps_at_open_interest_cap' });
282
+ }
283
+
284
+ /// `validator_l1_votes` — current validator L1 votes. No parameters.
285
+ async validatorL1Votes(): Promise<ValidatorL1Votes> {
286
+ return this.post<ValidatorL1Votes>({ type: 'validator_l1_votes' });
287
+ }
288
+
289
+ /// `margin_table` — the margin-tier table (one effective tier per market).
290
+ async marginTable(): Promise<MarginTable> {
291
+ return this.post<MarginTable>({ type: 'margin_table' });
292
+ }
293
+
294
+ /// `perp_dexs` — list the perp DEX(es). No parameters.
295
+ async perpDexs(): Promise<PerpDexs> {
296
+ return this.post<PerpDexs>({ type: 'perp_dexs' });
297
+ }
298
+
299
+ /// `validator_summaries` — per-validator snapshot. No parameters.
300
+ async validatorSummaries(): Promise<ValidatorSummaries> {
301
+ return this.post<ValidatorSummaries>({ type: 'validator_summaries' });
302
+ }
303
+
304
+ /// `gossip_root_ips` — configured gossip root/seed peer endpoints. No params.
305
+ async gossipRootIps(): Promise<GossipRootIps> {
306
+ return this.post<GossipRootIps>({ type: 'gossip_root_ips' });
307
+ }
308
+
309
+ /// `web_data2` — composite frontend snapshot by `address` (0x).
310
+ async webData2(address: string): Promise<WebData2> {
311
+ return this.post<WebData2>({ type: 'web_data2', address });
312
+ }
313
+
314
+ // ── escape hatches ──────────────────────────────────────────────────────
315
+
316
+ /// Raw escape hatch — POST an arbitrary `{type, ...}` body to `/info`,
317
+ /// validate the envelope, and return the unwrapped `data` typed. For request
318
+ /// shapes the SDK doesn't yet model.
319
+ async raw<T = unknown>(body: { type: string; [k: string]: unknown }): Promise<T> {
320
+ return this.post<T>(body);
321
+ }
322
+
323
+ /// Like `raw`, but returns the full `{type, data}` envelope rather than just
324
+ /// the unwrapped `data` — for callers that want to inspect the echoed `type`.
325
+ async rawEnvelope<T = unknown>(body: {
326
+ type: string;
327
+ [k: string]: unknown;
328
+ }): Promise<InfoEnvelope<T>> {
329
+ return httpRequest<InfoEnvelope<T>>(this.baseUrl, '/info', {
330
+ method: 'POST',
331
+ json: body,
332
+ });
333
+ }
334
+
335
+ /// POST a typed body, validate the `{type, data}` envelope echoes the request
336
+ /// `type`, and return the unwrapped `data`. The single place the envelope is
337
+ /// peeled — every typed method routes through here.
338
+ private async post<T>(body: { type: string; [k: string]: unknown }): Promise<T> {
339
+ const env = await httpRequest<InfoEnvelope<T>>(this.baseUrl, '/info', {
340
+ method: 'POST',
341
+ json: body,
342
+ });
343
+ if (env === null || typeof env !== 'object' || !('data' in env)) {
344
+ throw new TypeError(
345
+ `/info ${body.type}: response is not a {type, data} envelope`,
346
+ );
347
+ }
348
+ if (env.type !== body.type) {
349
+ throw new TypeError(
350
+ `/info ${body.type}: response type mismatch — got '${env.type}'`,
351
+ );
352
+ }
353
+ return env.data;
354
+ }
355
+ }
package/src/native.ts ADDED
@@ -0,0 +1,307 @@
1
+ // MTF-native signed-action digest + envelope construction.
2
+ //
3
+ // This is the byte-exact TS twin of the Rust SDK reference
4
+ // (`metaflux-client-rust/src/rest/exchange.rs::ActionSignedDigest`) and the
5
+ // server verifier (`metaflux/crates/core-state/src/signing.rs`).
6
+ //
7
+ // digest = keccak256(0x1901 || domainSep5 || structHash)
8
+ // domainSep5 = keccak256(
9
+ // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
10
+ // || keccak256("MetaFlux") || keccak256("1")
11
+ // || chainId_be32 || verifyingContract_padded32 ) // verifyingContract = 0x0
12
+ // structHash = keccak256(
13
+ // keccak256("MetaFluxAction(string action,uint64 nonce)")
14
+ // || keccak256(action_json_bytes) || nonce_be32 )
15
+ //
16
+ // CRITICAL: the signature is verified over the EXACT `action` bytes the server
17
+ // receives (it parses `action` as `serde_json::value::RawValue`). So the same
18
+ // JSON string MUST be both signed and sent verbatim — never re-stringified
19
+ // from a parsed object. `buildNativeOrderAction` is the single source of those
20
+ // bytes.
21
+
22
+ import {
23
+ deriveAddressFromPubkey,
24
+ keccak256,
25
+ recoverPubkey,
26
+ signSecp256k1,
27
+ } from './wasm.js';
28
+ import type {
29
+ NativeBuilder,
30
+ NativeCancel,
31
+ NativeOrder,
32
+ NativeSignedAction,
33
+ } from './types.js';
34
+
35
+ const MTF_DOMAIN_TYPE =
36
+ 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)';
37
+ const MTF_ACTION_TYPE = 'MetaFluxAction(string action,uint64 nonce)';
38
+
39
+ /// Default MTF chain id (matches `MTF_CHAIN_ID` in the Rust SDK + the server
40
+ /// KAT vector). Pinned to 998 provisionally; configurable post-S10.
41
+ export const MTF_CHAIN_ID = 998;
42
+
43
+ const enc = new TextEncoder();
44
+
45
+ let nonceClock = 0n;
46
+
47
+ /// Strictly-increasing replay nonce — at least the current unix-ms, but bumped
48
+ /// past the last issued value so a burst of orders within one millisecond gets
49
+ /// distinct nonces. The server's per-account window tolerates out-of-order
50
+ /// delivery but rejects collisions, so a raw `Date.now()` would drop the
51
+ /// 2nd-and-later order in a same-ms burst.
52
+ export function nextNonce(): bigint {
53
+ const now = BigInt(Date.now());
54
+ nonceClock = now > nonceClock ? now : nonceClock + 1n;
55
+ return nonceClock;
56
+ }
57
+
58
+ /// Encode a `bigint` as a 32-byte big-endian buffer (uint256 / nonce slot).
59
+ /// The value rides in the low bytes; high bytes are zero. Rejects negatives
60
+ /// and values that overflow 256 bits.
61
+ function be32(value: bigint): Uint8Array {
62
+ if (value < 0n) throw new RangeError('be32: value must be non-negative');
63
+ if (value >= 1n << 256n) throw new RangeError('be32: value overflows uint256');
64
+ const out = new Uint8Array(32);
65
+ let v = value;
66
+ for (let i = 31; i >= 0 && v > 0n; i--) {
67
+ out[i] = Number(v & 0xffn);
68
+ v >>= 8n;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /// Concatenate 32-byte chunks into one buffer.
74
+ function concat32(...chunks: Uint8Array[]): Uint8Array {
75
+ const out = new Uint8Array(chunks.length * 32);
76
+ chunks.forEach((c, i) => {
77
+ if (c.length !== 32) {
78
+ throw new RangeError(`concat32: chunk ${i} is ${c.length} bytes, want 32`);
79
+ }
80
+ out.set(c, i * 32);
81
+ });
82
+ return out;
83
+ }
84
+
85
+ /// Compute the 5-field MTF-native EIP-712 domain separator.
86
+ async function domainSeparator(chainId: number): Promise<Uint8Array> {
87
+ const typeHash = await keccak256(enc.encode(MTF_DOMAIN_TYPE));
88
+ const nameHash = await keccak256(enc.encode('MetaFlux'));
89
+ const versionHash = await keccak256(enc.encode('1'));
90
+ const chainIdBe = be32(BigInt(chainId));
91
+ // verifyingContract = 0x0 address, left-padded to 32 bytes => all zeros.
92
+ const verifyingPadded = new Uint8Array(32);
93
+ return keccak256(
94
+ concat32(typeHash, nameHash, versionHash, chainIdBe, verifyingPadded),
95
+ );
96
+ }
97
+
98
+ /// Compute the native action EIP-712 digest over the EXACT `actionJson` bytes.
99
+ ///
100
+ /// Returns the 32-byte digest the wallet signs. `actionJson` MUST be the
101
+ /// identical string later POSTed in the `action` field (raw, not re-parsed).
102
+ export async function nativeActionDigest(
103
+ actionJson: string,
104
+ nonce: bigint,
105
+ chainId: number = MTF_CHAIN_ID,
106
+ ): Promise<Uint8Array> {
107
+ if (nonce < 0n) throw new RangeError('nonce must be non-negative');
108
+ if (nonce >= 1n << 64n) throw new RangeError('nonce overflows u64');
109
+
110
+ const actionTypeHash = await keccak256(enc.encode(MTF_ACTION_TYPE));
111
+ const actionHash = await keccak256(enc.encode(actionJson));
112
+ const nonceBe = be32(nonce);
113
+ const structHash = await keccak256(
114
+ concat32(actionTypeHash, actionHash, nonceBe),
115
+ );
116
+
117
+ const domainSep = await domainSeparator(chainId);
118
+
119
+ // EIP-712 envelope: keccak256(0x19 || 0x01 || domainSep || structHash).
120
+ const envelope = new Uint8Array(2 + 32 + 32);
121
+ envelope[0] = 0x19;
122
+ envelope[1] = 0x01;
123
+ envelope.set(domainSep, 2);
124
+ envelope.set(structHash, 34);
125
+ return keccak256(envelope);
126
+ }
127
+
128
+ /// Lowercase hex (no `0x`) of a byte buffer.
129
+ function toHex(bytes: Uint8Array): string {
130
+ let out = '';
131
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
132
+ return out;
133
+ }
134
+
135
+ /// JSON-escape a string for inclusion in the hand-built action body. We build
136
+ /// the action JSON manually (rather than `JSON.stringify`-ing an object) so
137
+ /// the byte layout is fully under our control and matches the server's
138
+ /// expectation field-for-field. The only string values are `0x`-hex addresses
139
+ /// / cloids and fixed enum tokens, none of which contain characters needing
140
+ /// escaping — but we escape defensively so a malformed input can never inject
141
+ /// raw control bytes into the signed payload.
142
+ function jsonStr(s: string): string {
143
+ return JSON.stringify(s);
144
+ }
145
+
146
+ /// Build the canonical native `submit_order` action JSON string.
147
+ ///
148
+ /// Field order mirrors the server `NativeOrder` exactly. Optional `cloid` /
149
+ /// `builder` are omitted entirely when absent (matching the server's
150
+ /// `#[serde(default)]` + KAT vector, where neither appears). The returned
151
+ /// string is BOTH what gets signed and what gets sent — do not re-serialize.
152
+ export function buildNativeOrderAction(order: NativeOrder): string {
153
+ validateAddress(order.owner, 'owner');
154
+ validateMarket(order.market);
155
+ validateU64(order.size, 'size');
156
+ validateU64(order.limit_px, 'limit_px');
157
+
158
+ const parts: string[] = [
159
+ `${jsonStr('owner')}:${jsonStr(order.owner)}`,
160
+ `${jsonStr('market')}:${order.market}`,
161
+ `${jsonStr('side')}:${jsonStr(order.side)}`,
162
+ `${jsonStr('kind')}:${jsonStr(order.kind)}`,
163
+ `${jsonStr('size')}:${order.size}`,
164
+ `${jsonStr('limit_px')}:${order.limit_px}`,
165
+ `${jsonStr('tif')}:${jsonStr(order.tif)}`,
166
+ `${jsonStr('stp_mode')}:${jsonStr(order.stp_mode)}`,
167
+ `${jsonStr('reduce_only')}:${order.reduce_only ? 'true' : 'false'}`,
168
+ ];
169
+ if (order.cloid !== undefined) {
170
+ validateCloid(order.cloid);
171
+ parts.push(`${jsonStr('cloid')}:${jsonStr(order.cloid)}`);
172
+ }
173
+ if (order.builder !== undefined) {
174
+ parts.push(`${jsonStr('builder')}:${buildBuilder(order.builder)}`);
175
+ }
176
+ const orderJson = `{${parts.join(',')}}`;
177
+ return `{${jsonStr('type')}:${jsonStr('submit_order')},${jsonStr('order')}:${orderJson}}`;
178
+ }
179
+
180
+ /// Serialize a builder carve in the server-expected `{fee, user}` order.
181
+ function buildBuilder(b: NativeBuilder): string {
182
+ if (!Number.isInteger(b.fee) || b.fee < 0 || b.fee > 0xffff) {
183
+ throw new RangeError('builder.fee must be a u16 (0..=65535)');
184
+ }
185
+ validateAddress(b.user, 'builder.user');
186
+ return `{${jsonStr('fee')}:${b.fee},${jsonStr('user')}:${jsonStr(b.user)}}`;
187
+ }
188
+
189
+ /// Build the canonical native `cancel_order` action JSON string.
190
+ ///
191
+ /// Field order mirrors the server `NativeCancel` exactly
192
+ /// (`metaflux/crates/api-node/src/rest/native_action.rs`): `owner`, `market`,
193
+ /// then `oid` / `cloid` when present. The server's `CancelParams` bridge
194
+ /// cancels by `oid`, so an `oid` is REQUIRED for the cancel to lower
195
+ /// successfully (a `cloid`-only cancel is accepted on the wire but rejected at
196
+ /// lowering with `CancelMissingOid`); we still emit either form so the bytes
197
+ /// stay caller-controlled. The returned string is BOTH signed and sent.
198
+ export function buildNativeCancelAction(cancel: NativeCancel): string {
199
+ validateAddress(cancel.owner, 'owner');
200
+ validateMarket(cancel.market);
201
+ if (cancel.oid === undefined && cancel.cloid === undefined) {
202
+ throw new RangeError('cancel requires an oid (server cancels by oid)');
203
+ }
204
+ const parts: string[] = [
205
+ `${jsonStr('owner')}:${jsonStr(cancel.owner)}`,
206
+ `${jsonStr('market')}:${cancel.market}`,
207
+ ];
208
+ if (cancel.oid !== undefined) {
209
+ validateU64(cancel.oid, 'oid');
210
+ parts.push(`${jsonStr('oid')}:${cancel.oid}`);
211
+ }
212
+ if (cancel.cloid !== undefined) {
213
+ validateCloid(cancel.cloid);
214
+ parts.push(`${jsonStr('cloid')}:${jsonStr(cancel.cloid)}`);
215
+ }
216
+ const cancelJson = `{${parts.join(',')}}`;
217
+ return `{${jsonStr('type')}:${jsonStr('cancel_order')},${jsonStr('cancel')}:${cancelJson}}`;
218
+ }
219
+
220
+ /// Sign a pre-built action JSON string with the given private key.
221
+ ///
222
+ /// The returned envelope's `actionJson` is the SAME string passed in — the
223
+ /// caller must POST it verbatim so the server verifies over identical bytes.
224
+ export async function signNativeAction(
225
+ privateKey: Uint8Array,
226
+ actionJson: string,
227
+ nonce: bigint,
228
+ chainId: number = MTF_CHAIN_ID,
229
+ ): Promise<NativeSignedAction> {
230
+ if (privateKey.length !== 32) {
231
+ throw new RangeError('privateKey must be exactly 32 bytes');
232
+ }
233
+ const digest = await nativeActionDigest(actionJson, nonce, chainId);
234
+ const sig = await signSecp256k1(privateKey, digest);
235
+ return { actionJson, nonce, signature: `0x${toHex(sig)}` };
236
+ }
237
+
238
+ /// Recover the 20-byte signer address from a native signed action — handy for
239
+ /// asserting the owner field locally before POSTing.
240
+ export async function recoverNativeSigner(
241
+ signed: NativeSignedAction,
242
+ chainId: number = MTF_CHAIN_ID,
243
+ ): Promise<string> {
244
+ const digest = await nativeActionDigest(
245
+ signed.actionJson,
246
+ signed.nonce,
247
+ chainId,
248
+ );
249
+ const sigHex = signed.signature.startsWith('0x')
250
+ ? signed.signature.slice(2)
251
+ : signed.signature;
252
+ const sig = hexToBytes(sigHex);
253
+ const pubkey = await recoverPubkey(sig, digest);
254
+ const addr = await deriveAddressFromPubkey(pubkey);
255
+ return `0x${toHex(addr)}`;
256
+ }
257
+
258
+ /// Assemble the full `POST /exchange` request body STRING.
259
+ ///
260
+ /// Hand-built so the `action` field carries the exact signed bytes (no
261
+ /// re-stringify of a parsed object). The body shape is
262
+ /// `{"action":<actionJson>,"nonce":<u64>,"signature":"0x.."}`.
263
+ export function nativeRequestBody(signed: NativeSignedAction): string {
264
+ return `{${jsonStr('action')}:${signed.actionJson},${jsonStr('nonce')}:${signed.nonce},${jsonStr('signature')}:${jsonStr(signed.signature)}}`;
265
+ }
266
+
267
+ // ---- validation helpers (fail loud before anything reaches the signed body) ----
268
+
269
+ function validateAddress(addr: string, field: string): void {
270
+ const hex = addr.startsWith('0x') ? addr.slice(2) : addr;
271
+ if (hex.length !== 40 || !/^[0-9a-fA-F]{40}$/.test(hex)) {
272
+ throw new RangeError(`${field} must be a 0x-prefixed 20-byte hex address`);
273
+ }
274
+ }
275
+
276
+ function validateCloid(cloid: string): void {
277
+ const hex = cloid.startsWith('0x') ? cloid.slice(2) : cloid;
278
+ if (hex.length !== 32 || !/^[0-9a-fA-F]{32}$/.test(hex)) {
279
+ throw new RangeError('cloid must be a 0x-prefixed 16-byte hex string');
280
+ }
281
+ }
282
+
283
+ function validateMarket(market: number): void {
284
+ if (!Number.isInteger(market) || market < 0 || market > 0xffffffff) {
285
+ throw new RangeError('market must be a u32');
286
+ }
287
+ }
288
+
289
+ function validateU64(value: number, field: string): void {
290
+ if (!Number.isInteger(value) || value < 0) {
291
+ throw new RangeError(`${field} must be a non-negative integer`);
292
+ }
293
+ if (value > Number.MAX_SAFE_INTEGER) {
294
+ throw new RangeError(
295
+ `${field} exceeds Number.MAX_SAFE_INTEGER; use a value below 2^53`,
296
+ );
297
+ }
298
+ }
299
+
300
+ function hexToBytes(hex: string): Uint8Array {
301
+ if (hex.length % 2 !== 0) throw new RangeError('hex length must be even');
302
+ const out = new Uint8Array(hex.length / 2);
303
+ for (let i = 0; i < out.length; i++) {
304
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
305
+ }
306
+ return out;
307
+ }