@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +344 -0
- package/dist/client.js.map +1 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +106 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/info-types.d.ts +380 -0
- package/dist/info-types.d.ts.map +1 -0
- package/dist/info-types.js +16 -0
- package/dist/info-types.js.map +1 -0
- package/dist/info.d.ts +65 -0
- package/dist/info.d.ts.map +1 -0
- package/dist/info.js +252 -0
- package/dist/info.js.map +1 -0
- package/dist/native.d.ts +10 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +252 -0
- package/dist/native.js.map +1 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/wasm.d.ts +28 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +279 -0
- package/dist/wasm.js.map +1 -0
- package/dist/ws.d.ts +43 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +221 -0
- package/dist/ws.js.map +1 -0
- package/package.json +65 -0
- package/src/client.ts +454 -0
- package/src/http.ts +153 -0
- package/src/index.ts +144 -0
- package/src/info-types.ts +783 -0
- package/src/info.ts +355 -0
- package/src/native.ts +307 -0
- package/src/types.ts +305 -0
- package/src/wasm.ts +384 -0
- package/src/ws.ts +279 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// `Client` — primary entry point for the @metaflux-dex/client SDK.
|
|
2
|
+
//
|
|
3
|
+
// Heavy lifting: order signing (msgpack encode -> EIP-712 hash ->
|
|
4
|
+
// secp256k1 sign -> address derive) runs through WASM. Pure-TS
|
|
5
|
+
// responsibilities: HTTP plumbing, type coercion, optional JWT
|
|
6
|
+
// session bookkeeping.
|
|
7
|
+
//
|
|
8
|
+
// Naming note: exported as `Client` (NOT `MtfClient`) per session
|
|
9
|
+
// direction. Consumers import as `import { Client } from '@metaflux-dex/client'`.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
deriveAddressFromPubkey,
|
|
13
|
+
eip712TypedDataHash,
|
|
14
|
+
encodeLimitOrder,
|
|
15
|
+
keccak256,
|
|
16
|
+
recoverPubkey,
|
|
17
|
+
signSecp256k1,
|
|
18
|
+
} from './wasm.js';
|
|
19
|
+
import { httpRequest } from './http.js';
|
|
20
|
+
import {
|
|
21
|
+
buildNativeCancelAction,
|
|
22
|
+
buildNativeOrderAction,
|
|
23
|
+
nativeRequestBody,
|
|
24
|
+
nextNonce,
|
|
25
|
+
recoverNativeSigner,
|
|
26
|
+
signNativeAction,
|
|
27
|
+
} from './native.js';
|
|
28
|
+
import { InfoApi } from './info.js';
|
|
29
|
+
import { WsClient, type WsConfig } from './ws.js';
|
|
30
|
+
import type {
|
|
31
|
+
Market,
|
|
32
|
+
NativeCancel,
|
|
33
|
+
NativeExchangeAck,
|
|
34
|
+
NativeOrder,
|
|
35
|
+
Order,
|
|
36
|
+
OrderAck,
|
|
37
|
+
Position,
|
|
38
|
+
SignedOrder,
|
|
39
|
+
} from './types.js';
|
|
40
|
+
|
|
41
|
+
/// Options accepted by the `Client` constructor.
|
|
42
|
+
export interface ClientOpts {
|
|
43
|
+
/// Gateway base URL — e.g. `https://api.metaflux.example`. The Client
|
|
44
|
+
/// appends CCXT-compat paths (`/ccxt/...`) and MTF-native paths
|
|
45
|
+
/// (`/v1/...`) under this root.
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
/// Optional 32-byte ECDSA private key. Required for any signing
|
|
48
|
+
/// operation (`signOrder`); read-only data calls (`getMarkets`,
|
|
49
|
+
/// `getPositions`) work without it.
|
|
50
|
+
privateKey?: Uint8Array;
|
|
51
|
+
/// EVM chain ID used for the EIP-712 domain. Defaults to a
|
|
52
|
+
/// devnet-style placeholder (`31337`); production deployments override.
|
|
53
|
+
chainId?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Default chain ID for the EIP-712 domain. The MetaFlux mainnet ID is
|
|
57
|
+
/// reserved-but-not-yet-assigned (PLAN.md §M.1 — "TBD pre-mainnet, avoid
|
|
58
|
+
/// 999 which is HL"); the devnet default `31337` matches what the
|
|
59
|
+
/// existing core-state tests use.
|
|
60
|
+
const DEFAULT_CHAIN_ID = 31337;
|
|
61
|
+
|
|
62
|
+
/// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`
|
|
63
|
+
/// pre-computed at module load. Static across the SDK lifetime so we
|
|
64
|
+
/// don't re-keccak it for every signing call.
|
|
65
|
+
let cachedDomainTypeHash: Uint8Array | undefined;
|
|
66
|
+
async function domainTypeHash(): Promise<Uint8Array> {
|
|
67
|
+
if (cachedDomainTypeHash === undefined) {
|
|
68
|
+
cachedDomainTypeHash = await keccak256(
|
|
69
|
+
new TextEncoder().encode(
|
|
70
|
+
'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)',
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return cachedDomainTypeHash;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Pre-computed type-hash for the limit-order action. The string mirrors
|
|
78
|
+
/// what the node will register for the `Order` action — locked in by
|
|
79
|
+
/// RFC-001 §D once the on-chain registry lands. The shape here matches
|
|
80
|
+
/// what `encode_limit_order` produces.
|
|
81
|
+
let cachedOrderTypeHash: Uint8Array | undefined;
|
|
82
|
+
async function orderTypeHash(): Promise<Uint8Array> {
|
|
83
|
+
if (cachedOrderTypeHash === undefined) {
|
|
84
|
+
cachedOrderTypeHash = await keccak256(
|
|
85
|
+
new TextEncoder().encode(
|
|
86
|
+
'Order(uint32 asset,uint8 side,uint128 px,uint128 size,uint8 tif)',
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return cachedOrderTypeHash;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Compute the EIP-712 domain separator for a given chain id.
|
|
94
|
+
///
|
|
95
|
+
/// Mirrors `core_state::signing::EipDomain::separator` — the canonical
|
|
96
|
+
/// 5-segment keccak input is
|
|
97
|
+
/// `(domain_type_hash, name_hash, version_hash, chain_id_be32, verifying_contract_padded)`.
|
|
98
|
+
async function computeDomainSeparator(chainId: number): Promise<Uint8Array> {
|
|
99
|
+
const dth = await domainTypeHash();
|
|
100
|
+
const nameHash = await keccak256(new TextEncoder().encode('MetaFlux'));
|
|
101
|
+
const versionHash = await keccak256(new TextEncoder().encode('1'));
|
|
102
|
+
|
|
103
|
+
// uint256 chainId big-endian, 32 bytes.
|
|
104
|
+
const chainIdBe = new Uint8Array(32);
|
|
105
|
+
// JS bitwise ops are signed 32-bit; use BigInt for the conversion.
|
|
106
|
+
const view = new DataView(chainIdBe.buffer);
|
|
107
|
+
view.setBigUint64(24, BigInt(chainId)); // low 8 bytes carry the value.
|
|
108
|
+
|
|
109
|
+
// Verifying contract == Address::ZERO, left-padded to 32 bytes -> all zeros.
|
|
110
|
+
const verifyingPadded = new Uint8Array(32);
|
|
111
|
+
|
|
112
|
+
// Concat + keccak. Allocating one Uint8Array beats 5 hasher.update calls
|
|
113
|
+
// because the WASM keccak primitive takes a single slice — the
|
|
114
|
+
// call-overhead of multiple FFI hops would outweigh the copy.
|
|
115
|
+
const concat = new Uint8Array(5 * 32);
|
|
116
|
+
concat.set(dth, 0);
|
|
117
|
+
concat.set(nameHash, 32);
|
|
118
|
+
concat.set(versionHash, 64);
|
|
119
|
+
concat.set(chainIdBe, 96);
|
|
120
|
+
concat.set(verifyingPadded, 128);
|
|
121
|
+
return keccak256(concat);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Primary client surface. Construct once per session.
|
|
125
|
+
///
|
|
126
|
+
/// Read-only example:
|
|
127
|
+
/// ```ts
|
|
128
|
+
/// const c = new Client({ baseUrl: 'http://localhost:8080' });
|
|
129
|
+
/// const markets = await c.getMarkets();
|
|
130
|
+
/// ```
|
|
131
|
+
///
|
|
132
|
+
/// Signing example:
|
|
133
|
+
/// ```ts
|
|
134
|
+
/// const c = new Client({
|
|
135
|
+
/// baseUrl: 'http://localhost:8080',
|
|
136
|
+
/// privateKey: hexToBytes('...'),
|
|
137
|
+
/// });
|
|
138
|
+
/// const signed = await c.signOrder({ asset: 0, side: 0, sizeE8: 100_000_000n,
|
|
139
|
+
/// priceE8: 50_000_00000000n, tif: 0 });
|
|
140
|
+
/// const ack = await c.submitOrder(signed);
|
|
141
|
+
/// ```
|
|
142
|
+
export class Client {
|
|
143
|
+
private readonly baseUrl: string;
|
|
144
|
+
private readonly privateKey: Uint8Array | undefined;
|
|
145
|
+
private readonly chainId: number;
|
|
146
|
+
/// Cached gateway-issued JWT (`/auth`). The session is established
|
|
147
|
+
/// lazily on the first authenticated call.
|
|
148
|
+
private jwt: string | undefined;
|
|
149
|
+
/// MTF-native read API (`POST /info`). Read-only; no key required.
|
|
150
|
+
readonly info: InfoApi;
|
|
151
|
+
|
|
152
|
+
constructor(opts: ClientOpts) {
|
|
153
|
+
if (opts.baseUrl.length === 0) {
|
|
154
|
+
throw new RangeError('Client baseUrl must be non-empty');
|
|
155
|
+
}
|
|
156
|
+
if (opts.privateKey !== undefined && opts.privateKey.length !== 32) {
|
|
157
|
+
throw new RangeError('Client privateKey must be exactly 32 bytes');
|
|
158
|
+
}
|
|
159
|
+
this.baseUrl = opts.baseUrl;
|
|
160
|
+
this.privateKey = opts.privateKey;
|
|
161
|
+
this.chainId = opts.chainId ?? DEFAULT_CHAIN_ID;
|
|
162
|
+
this.info = new InfoApi(this.baseUrl);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Whether this client has a private key available for signing
|
|
166
|
+
/// operations. Read-only data calls work regardless.
|
|
167
|
+
get canSign(): boolean {
|
|
168
|
+
return this.privateKey !== undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Sign an order body. Returns the `(payload, signature, signer)`
|
|
172
|
+
/// triplet ready for `submitOrder`.
|
|
173
|
+
///
|
|
174
|
+
/// All heavy lifting (msgpack encode, keccak, ECDSA) is in WASM.
|
|
175
|
+
/// The signing flow:
|
|
176
|
+
///
|
|
177
|
+
/// 1. `payload = encode_limit_order(...)` — msgpack body matching the
|
|
178
|
+
/// node's `OrderParams` decoder.
|
|
179
|
+
/// 2. `messageHash = keccak256(orderTypeHash || payload)` — the
|
|
180
|
+
/// EIP-712 "struct hash" of the action.
|
|
181
|
+
/// 3. `domainSeparator = keccak256(EIP712Domain(...))` — cached.
|
|
182
|
+
/// 4. `digest = keccak256(0x1901 || domainSeparator || messageHash)`.
|
|
183
|
+
/// 5. `signature = sign_secp256k1(privateKey, digest)`.
|
|
184
|
+
/// 6. `signer = derive_address_from_pubkey(recover_pubkey(signature, digest))`.
|
|
185
|
+
///
|
|
186
|
+
/// Step 6 derives the signer locally rather than trusting the gateway
|
|
187
|
+
/// to recover it; that way the SDK ships the address upfront and the
|
|
188
|
+
/// gateway can reject obviously-replayed envelopes before doing ECDSA.
|
|
189
|
+
async signOrder(order: Order): Promise<SignedOrder> {
|
|
190
|
+
if (this.privateKey === undefined) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
'signOrder requires a privateKey in ClientOpts (this Client is read-only)',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const payload = await encodeLimitOrder(
|
|
196
|
+
order.asset,
|
|
197
|
+
order.side,
|
|
198
|
+
order.sizeE8,
|
|
199
|
+
order.priceE8,
|
|
200
|
+
order.tif,
|
|
201
|
+
order.stp ?? 0,
|
|
202
|
+
order.cloid,
|
|
203
|
+
order.reduceOnly ?? false,
|
|
204
|
+
order.builder,
|
|
205
|
+
);
|
|
206
|
+
const typeHash = await orderTypeHash();
|
|
207
|
+
// message_hash = keccak256(type_hash || payload).
|
|
208
|
+
const msgBuffer = new Uint8Array(typeHash.length + payload.length);
|
|
209
|
+
msgBuffer.set(typeHash, 0);
|
|
210
|
+
msgBuffer.set(payload, typeHash.length);
|
|
211
|
+
const messageHash = await keccak256(msgBuffer);
|
|
212
|
+
|
|
213
|
+
const domainSeparator = await computeDomainSeparator(this.chainId);
|
|
214
|
+
const digest = await eip712TypedDataHash(domainSeparator, messageHash);
|
|
215
|
+
|
|
216
|
+
const signature = await signSecp256k1(this.privateKey, digest);
|
|
217
|
+
const pubkey = await recoverPubkey(signature, digest);
|
|
218
|
+
const signer = await deriveAddressFromPubkey(pubkey);
|
|
219
|
+
|
|
220
|
+
return { payload, signature, signer };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Submit a pre-signed order to the gateway.
|
|
224
|
+
///
|
|
225
|
+
/// DEPRECATED / LEGACY: this targets the old `{payload,signature,signer}` →
|
|
226
|
+
/// `/v1/orders` envelope with a msgpack body and an `Order(...)` typehash.
|
|
227
|
+
/// The server now accepts the MTF-native `{action,nonce,signature}` →
|
|
228
|
+
/// `/exchange` envelope instead — use `submitOrderNative`. Retained
|
|
229
|
+
/// only for any consumer still on the old gateway adapter.
|
|
230
|
+
///
|
|
231
|
+
/// Wire shape: POSTs the `SignedOrder` as a msgpack-friendly JSON
|
|
232
|
+
/// envelope — `payload` and `signature` go as base64url strings (the
|
|
233
|
+
/// gateway's existing `LoginEnvelope` shape uses base64; we mirror
|
|
234
|
+
/// that), `signer` goes as a 0x-hex address. The gateway adapter
|
|
235
|
+
/// (TODO on the server side) decodes and forwards to the node via
|
|
236
|
+
/// gRPC; for now the SDK targets the CCXT createOrder response shape.
|
|
237
|
+
async submitOrder(signed: SignedOrder): Promise<OrderAck> {
|
|
238
|
+
return httpRequest<OrderAck>(this.baseUrl, '/v1/orders', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
json: {
|
|
241
|
+
payload: bytesToBase64Url(signed.payload),
|
|
242
|
+
signature: bytesToBase64Url(signed.signature),
|
|
243
|
+
signer: bytesToHex(signed.signer, '0x'),
|
|
244
|
+
},
|
|
245
|
+
bearer: this.jwt,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Submit an order via the MTF-native signed-action front door
|
|
250
|
+
/// (`POST /exchange`).
|
|
251
|
+
///
|
|
252
|
+
/// This is the path the server now accepts. It supersedes the legacy
|
|
253
|
+
/// `signOrder` + `submitOrder` flow (msgpack body + `Order(...)` typehash +
|
|
254
|
+
/// `{payload,signature,signer}` → `/v1/orders`), which targeted an
|
|
255
|
+
/// envelope the node no longer recognizes.
|
|
256
|
+
///
|
|
257
|
+
/// Flow:
|
|
258
|
+
/// 1. `buildNativeOrderAction` produces the canonical snake_case action JSON
|
|
259
|
+
/// string (`{"type":"submit_order","order":{...}}`), field order matching
|
|
260
|
+
/// the server `NativeOrder`.
|
|
261
|
+
/// 2. `signNativeAction` computes the native EIP-712 digest over the EXACT
|
|
262
|
+
/// action bytes (`MetaFluxAction(string action,uint64 nonce)` struct hash,
|
|
263
|
+
/// 5-field domain) and signs it.
|
|
264
|
+
/// 3. The action string is POSTed VERBATIM inside `{action, nonce, signature}`
|
|
265
|
+
/// — the server recovers the signer over the raw `action` bytes, so the
|
|
266
|
+
/// signed bytes and the sent bytes are identical.
|
|
267
|
+
///
|
|
268
|
+
/// `order.owner` MUST equal the signing wallet's address; we recover the
|
|
269
|
+
/// signer locally and reject a mismatch before hitting the network (the
|
|
270
|
+
/// server enforces the same).
|
|
271
|
+
///
|
|
272
|
+
/// `nonce` is the per-owner replay nonce bound into the digest. Defaults to
|
|
273
|
+
/// `Date.now()` (unix-ms) — supply an explicit monotonically-increasing
|
|
274
|
+
/// value for back-to-back submissions in the same millisecond.
|
|
275
|
+
///
|
|
276
|
+
/// `chainId` defaults to the MTF-native chain id (998), independent of the
|
|
277
|
+
/// legacy `ClientOpts.chainId` (which is the wrong domain for this path).
|
|
278
|
+
async submitOrderNative(
|
|
279
|
+
order: NativeOrder,
|
|
280
|
+
opts: { nonce?: bigint; chainId?: number } = {},
|
|
281
|
+
): Promise<NativeExchangeAck> {
|
|
282
|
+
if (this.privateKey === undefined) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
'submitOrderNative requires a privateKey in ClientOpts (this Client is read-only)',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const nonce = opts.nonce ?? nextNonce();
|
|
288
|
+
const actionJson = buildNativeOrderAction(order);
|
|
289
|
+
const signed = await signNativeAction(
|
|
290
|
+
this.privateKey,
|
|
291
|
+
actionJson,
|
|
292
|
+
nonce,
|
|
293
|
+
opts.chainId,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Local guard: the recovered signer must equal the claimed owner. The
|
|
297
|
+
// server enforces this too (401 on mismatch), but failing here saves a
|
|
298
|
+
// round-trip and surfaces a key/owner mismatch with a clear message.
|
|
299
|
+
const signer = await recoverNativeSigner(signed, opts.chainId);
|
|
300
|
+
if (signer.toLowerCase() !== order.owner.toLowerCase()) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`order.owner ${order.owner} != recovered signer ${signer}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return httpRequest<NativeExchangeAck>(this.baseUrl, '/exchange', {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
rawJson: nativeRequestBody(signed),
|
|
309
|
+
bearer: this.jwt,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Cancel an order via the MTF-native signed-action front door
|
|
314
|
+
/// (`POST /exchange`).
|
|
315
|
+
///
|
|
316
|
+
/// Same envelope + verification model as `submitOrderNative`: the
|
|
317
|
+
/// `cancel_order` action JSON is built canonically, signed over the EIP-712
|
|
318
|
+
/// native digest, and POSTed verbatim. The server cancels by `oid`, so
|
|
319
|
+
/// `cancel.oid` must be set (a `cloid`-only cancel is rejected at lowering).
|
|
320
|
+
///
|
|
321
|
+
/// `cancel.owner` MUST equal the signing wallet; we recover the signer
|
|
322
|
+
/// locally and reject a mismatch before hitting the network.
|
|
323
|
+
async cancelOrderNative(
|
|
324
|
+
cancel: NativeCancel,
|
|
325
|
+
opts: { nonce?: bigint; chainId?: number } = {},
|
|
326
|
+
): Promise<NativeExchangeAck> {
|
|
327
|
+
if (this.privateKey === undefined) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
'cancelOrderNative requires a privateKey in ClientOpts (this Client is read-only)',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const nonce = opts.nonce ?? nextNonce();
|
|
333
|
+
const actionJson = buildNativeCancelAction(cancel);
|
|
334
|
+
const signed = await signNativeAction(
|
|
335
|
+
this.privateKey,
|
|
336
|
+
actionJson,
|
|
337
|
+
nonce,
|
|
338
|
+
opts.chainId,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const signer = await recoverNativeSigner(signed, opts.chainId);
|
|
342
|
+
if (signer.toLowerCase() !== cancel.owner.toLowerCase()) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`cancel.owner ${cancel.owner} != recovered signer ${signer}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return httpRequest<NativeExchangeAck>(this.baseUrl, '/exchange', {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
rawJson: nativeRequestBody(signed),
|
|
351
|
+
bearer: this.jwt,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/// Open an MTF-native WebSocket connection to `<baseUrl>/ws`.
|
|
356
|
+
///
|
|
357
|
+
/// Derives the `ws(s)://` URL from the client's `http(s)://` base, mounts the
|
|
358
|
+
/// `/ws` path (the node's upgrade route), and returns a connected
|
|
359
|
+
/// [`WsClient`]. Register handlers via `ws.onMessage` and subscribe with
|
|
360
|
+
/// `ws.subscribe({ type: 'l2Book', coin: 'BTC' })`.
|
|
361
|
+
async connectWs(config: Partial<WsConfig> = {}): Promise<WsClient> {
|
|
362
|
+
const ws = new WsClient(httpToWsUrl(this.baseUrl), config);
|
|
363
|
+
await ws.connect();
|
|
364
|
+
return ws;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// `fetchMarkets` — list of all CCXT-compat market descriptors.
|
|
368
|
+
/// Unauthenticated.
|
|
369
|
+
async getMarkets(): Promise<Market[]> {
|
|
370
|
+
return httpRequest<Market[]>(this.baseUrl, '/ccxt/markets');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// `fetchPositions` for a given account. Authenticated. The CCXT
|
|
374
|
+
/// surface defines `fetchPositions(symbols?)`; the MTF gateway
|
|
375
|
+
/// adapter accepts an explicit `account` query parameter so the
|
|
376
|
+
/// caller can fetch positions for sub-accounts they control.
|
|
377
|
+
async getPositions(account: string): Promise<Position[]> {
|
|
378
|
+
if (!isHexAddress(account)) {
|
|
379
|
+
throw new RangeError(
|
|
380
|
+
`getPositions: account must be a 0x-prefixed 20-byte hex string, got '${account}'`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return httpRequest<Position[]>(this.baseUrl, '/ccxt/positions', {
|
|
384
|
+
query: { account },
|
|
385
|
+
bearer: this.jwt,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Internal: set the JWT after a successful `/auth` exchange. Exposed
|
|
390
|
+
/// so an external auth flow (wallet popup, etc.) can plant a token.
|
|
391
|
+
setJwt(token: string): void {
|
|
392
|
+
this.jwt = token;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ============================================================================
|
|
397
|
+
// Encoding helpers — narrow + private
|
|
398
|
+
// ============================================================================
|
|
399
|
+
|
|
400
|
+
/// Encode a `Uint8Array` as a base64url string (no padding).
|
|
401
|
+
///
|
|
402
|
+
/// Matches the encoding the gateway's `LoginEnvelope.signature` field
|
|
403
|
+
/// expects (see `metaflux/crates/api-gateway/src/ccxt/auth.rs`). We
|
|
404
|
+
/// avoid Buffer/global polyfills here because the SDK targets both
|
|
405
|
+
/// node and the browser; manual base64 is small and avoids the
|
|
406
|
+
/// dependency.
|
|
407
|
+
function bytesToBase64Url(bytes: Uint8Array): string {
|
|
408
|
+
// Build a binary string of the bytes. `String.fromCharCode` is
|
|
409
|
+
// bounded to ~65k arguments per call — safe for any signature
|
|
410
|
+
// (65 bytes) or payload (under 1KB).
|
|
411
|
+
let binary = '';
|
|
412
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
413
|
+
const b64 = btoa(binary);
|
|
414
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/// Encode a `Uint8Array` as a hex string with optional `0x` prefix.
|
|
418
|
+
function bytesToHex(bytes: Uint8Array, prefix: string = ''): string {
|
|
419
|
+
let out = prefix;
|
|
420
|
+
for (const b of bytes) {
|
|
421
|
+
out += b.toString(16).padStart(2, '0');
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/// Derive the WS endpoint URL from the client's HTTP base URL: map the scheme
|
|
427
|
+
/// (`http`→`ws`, `https`→`wss`), strip any trailing slash, and append `/ws`
|
|
428
|
+
/// (the node's upgrade route). A base that is already `ws(s)://` is passed
|
|
429
|
+
/// through (only the `/ws` suffix is ensured).
|
|
430
|
+
function httpToWsUrl(baseUrl: string): string {
|
|
431
|
+
let url = baseUrl;
|
|
432
|
+
if (url.startsWith('https://')) {
|
|
433
|
+
url = `wss://${url.slice('https://'.length)}`;
|
|
434
|
+
} else if (url.startsWith('http://')) {
|
|
435
|
+
url = `ws://${url.slice('http://'.length)}`;
|
|
436
|
+
}
|
|
437
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
438
|
+
return url.endsWith('/ws') ? url : `${url}/ws`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/// Validate that a string is a `0x` + 40 hex chars EVM address.
|
|
442
|
+
function isHexAddress(s: string): boolean {
|
|
443
|
+
if (s.length !== 42) return false;
|
|
444
|
+
if (!s.startsWith('0x')) return false;
|
|
445
|
+
for (let i = 2; i < s.length; i++) {
|
|
446
|
+
const c = s.charCodeAt(i);
|
|
447
|
+
const isHex =
|
|
448
|
+
(c >= 0x30 && c <= 0x39) ||
|
|
449
|
+
(c >= 0x41 && c <= 0x46) ||
|
|
450
|
+
(c >= 0x61 && c <= 0x66);
|
|
451
|
+
if (!isHex) return false;
|
|
452
|
+
}
|
|
453
|
+
return true;
|
|
454
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Thin fetch wrapper used by the Client class.
|
|
2
|
+
//
|
|
3
|
+
// All HTTP logic is intentionally pure-TS — the WASM module is for
|
|
4
|
+
// crypto + canonical encoding only. The wrapper centralises three
|
|
5
|
+
// concerns: (a) base-URL composition, (b) JWT bearer header (when the
|
|
6
|
+
// caller has authenticated), and (c) translating the gateway's CCXT-
|
|
7
|
+
// compat error envelope `{ "error": "..." }` into a typed exception.
|
|
8
|
+
|
|
9
|
+
import type { ErrorEnvelope } from './types.js';
|
|
10
|
+
|
|
11
|
+
/// Thrown when the gateway responds with a non-2xx status. Carries the
|
|
12
|
+
/// status code + the message extracted from `{ "error": "..." }` (or
|
|
13
|
+
/// the raw body if the response was not JSON).
|
|
14
|
+
export class MetaFluxApiError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly status: number,
|
|
17
|
+
public readonly bodyText: string,
|
|
18
|
+
message: string,
|
|
19
|
+
) {
|
|
20
|
+
super(`MetaFlux gateway error ${status}: ${message}`);
|
|
21
|
+
this.name = 'MetaFluxApiError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Internal fetch options accepted by `httpRequest`. Mirrors a subset
|
|
26
|
+
/// of the standard `RequestInit` plus auth-aware fields.
|
|
27
|
+
export interface HttpRequestInit {
|
|
28
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
29
|
+
/// Object that will be `JSON.stringify`-d into the body. Sets
|
|
30
|
+
/// `Content-Type: application/json` automatically.
|
|
31
|
+
json?: unknown;
|
|
32
|
+
/// Raw `Uint8Array` body — `Content-Type` defaults to
|
|
33
|
+
/// `application/octet-stream` unless overridden. Used by the
|
|
34
|
+
/// signed-action POST surface that carries msgpack bytes.
|
|
35
|
+
bytes?: Uint8Array;
|
|
36
|
+
/// Pre-serialized JSON STRING body — sent verbatim (not re-stringified).
|
|
37
|
+
/// `Content-Type` defaults to `application/json`. Used by the MTF-native
|
|
38
|
+
/// signed-action path, where the `action` field MUST carry the exact bytes
|
|
39
|
+
/// that were signed (the server verifies over `serde_json::RawValue`); a
|
|
40
|
+
/// `JSON.parse`→`JSON.stringify` round-trip would risk reordering / spacing
|
|
41
|
+
/// drift and break every signature.
|
|
42
|
+
rawJson?: string;
|
|
43
|
+
/// JWT bearer token (gateway-issued; persisted by the Client class
|
|
44
|
+
/// after `/auth`). Adds `Authorization: Bearer <jwt>`.
|
|
45
|
+
bearer?: string;
|
|
46
|
+
/// Override / supplement headers.
|
|
47
|
+
headers?: Record<string, string>;
|
|
48
|
+
/// Query-string params. Strings only (`number` -> `String(n)` at
|
|
49
|
+
/// call site so we don't accidentally serialise NaN).
|
|
50
|
+
query?: Record<string, string>;
|
|
51
|
+
/// AbortSignal for cancellation. Useful for the WebSocket-style
|
|
52
|
+
/// long-polling routes the Client adds in later phases.
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Single fetch wrapper everything routes through.
|
|
57
|
+
///
|
|
58
|
+
/// Concatenates `baseUrl + path`, applies the query string, sets headers
|
|
59
|
+
/// + body, awaits the response, and either:
|
|
60
|
+
/// - returns the parsed JSON (when the response is 2xx + JSON), or
|
|
61
|
+
/// - returns the raw `Response` (when the caller passed `rawResponse`),
|
|
62
|
+
/// or
|
|
63
|
+
/// - throws `MetaFluxApiError` (non-2xx).
|
|
64
|
+
export async function httpRequest<T>(
|
|
65
|
+
baseUrl: string,
|
|
66
|
+
path: string,
|
|
67
|
+
init: HttpRequestInit = {},
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
const url = buildUrl(baseUrl, path, init.query);
|
|
70
|
+
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
|
71
|
+
let body: BodyInit | undefined;
|
|
72
|
+
|
|
73
|
+
if (init.rawJson !== undefined) {
|
|
74
|
+
headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
|
|
75
|
+
body = init.rawJson;
|
|
76
|
+
} else if (init.json !== undefined) {
|
|
77
|
+
headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
|
|
78
|
+
body = JSON.stringify(init.json);
|
|
79
|
+
} else if (init.bytes !== undefined) {
|
|
80
|
+
headers['Content-Type'] =
|
|
81
|
+
headers['Content-Type'] ?? 'application/octet-stream';
|
|
82
|
+
// Re-allocate to a fresh ArrayBuffer slice so a shared/transferred
|
|
83
|
+
// buffer can't be mutated underneath fetch.
|
|
84
|
+
const fresh = new Uint8Array(init.bytes);
|
|
85
|
+
body = fresh;
|
|
86
|
+
}
|
|
87
|
+
if (init.bearer !== undefined) {
|
|
88
|
+
headers['Authorization'] = `Bearer ${init.bearer}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: init.method ?? 'GET',
|
|
93
|
+
headers,
|
|
94
|
+
body,
|
|
95
|
+
signal: init.signal,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Read body once — fetch's body is single-use.
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const msg = extractErrorMessage(text);
|
|
102
|
+
throw new MetaFluxApiError(res.status, text, msg);
|
|
103
|
+
}
|
|
104
|
+
if (text.length === 0) {
|
|
105
|
+
// 204 / empty body — caller asked for a `T`, return undefined cast.
|
|
106
|
+
// The Client never relies on this path; documented here so an
|
|
107
|
+
// accidental schema change surfaces as a runtime cast failure.
|
|
108
|
+
return undefined as unknown as T;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(text) as T;
|
|
112
|
+
} catch {
|
|
113
|
+
throw new MetaFluxApiError(
|
|
114
|
+
res.status,
|
|
115
|
+
text,
|
|
116
|
+
'response was not valid JSON',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// URL builder. Tolerates `baseUrl` with or without a trailing slash and
|
|
122
|
+
/// `path` with or without a leading slash; never doubles either.
|
|
123
|
+
function buildUrl(
|
|
124
|
+
baseUrl: string,
|
|
125
|
+
path: string,
|
|
126
|
+
query: Record<string, string> | undefined,
|
|
127
|
+
): string {
|
|
128
|
+
const trimmedBase = baseUrl.endsWith('/')
|
|
129
|
+
? baseUrl.slice(0, -1)
|
|
130
|
+
: baseUrl;
|
|
131
|
+
const trimmedPath = path.startsWith('/') ? path : `/${path}`;
|
|
132
|
+
const joined = `${trimmedBase}${trimmedPath}`;
|
|
133
|
+
if (query === undefined || Object.keys(query).length === 0) {
|
|
134
|
+
return joined;
|
|
135
|
+
}
|
|
136
|
+
const qs = new URLSearchParams(query).toString();
|
|
137
|
+
return `${joined}?${qs}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Extract the `error` field from a CCXT-compat error envelope. Falls
|
|
141
|
+
/// back to the raw text if parsing fails.
|
|
142
|
+
function extractErrorMessage(text: string): string {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(text) as Partial<ErrorEnvelope>;
|
|
145
|
+
if (typeof parsed.error === 'string' && parsed.error.length > 0) {
|
|
146
|
+
return parsed.error;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Fall through.
|
|
150
|
+
}
|
|
151
|
+
// Truncate to keep stack traces readable.
|
|
152
|
+
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
153
|
+
}
|