pmxtjs 2.48.5 → 2.49.0
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/dist/esm/generated/src/models/OrderLevel.d.ts +6 -0
- package/dist/esm/generated/src/models/OrderLevel.js +2 -0
- package/dist/esm/generated/src/models/UnifiedMarket.d.ts +2 -2
- package/dist/esm/generated/src/models/UnifiedMarket.js +2 -4
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/pmxt/client.d.ts +106 -5
- package/dist/esm/pmxt/client.js +400 -6
- package/dist/esm/pmxt/constants.d.ts +11 -0
- package/dist/esm/pmxt/constants.js +13 -0
- package/dist/esm/pmxt/errors.d.ts +3 -0
- package/dist/esm/pmxt/errors.js +9 -0
- package/dist/esm/pmxt/escrow.d.ts +39 -0
- package/dist/esm/pmxt/escrow.js +78 -0
- package/dist/esm/pmxt/feed-client.d.ts +3 -0
- package/dist/esm/pmxt/feed-client.js +11 -2
- package/dist/esm/pmxt/hosted-errors.d.ts +84 -0
- package/dist/esm/pmxt/hosted-errors.js +186 -0
- package/dist/esm/pmxt/hosted-mappers.d.ts +45 -0
- package/dist/esm/pmxt/hosted-mappers.js +291 -0
- package/dist/esm/pmxt/hosted-routing.d.ts +69 -0
- package/dist/esm/pmxt/hosted-routing.js +119 -0
- package/dist/esm/pmxt/hosted-typed-data.d.ts +36 -0
- package/dist/esm/pmxt/hosted-typed-data.js +580 -0
- package/dist/esm/pmxt/models.d.ts +46 -8
- package/dist/esm/pmxt/server-manager.d.ts +4 -0
- package/dist/esm/pmxt/server-manager.js +6 -0
- package/dist/esm/pmxt/signers.d.ts +57 -0
- package/dist/esm/pmxt/signers.js +50 -0
- package/dist/esm/pmxt/ws-client.js +2 -1
- package/dist/generated/src/models/OrderLevel.d.ts +6 -0
- package/dist/generated/src/models/OrderLevel.js +2 -0
- package/dist/generated/src/models/UnifiedMarket.d.ts +2 -2
- package/dist/generated/src/models/UnifiedMarket.js +2 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/pmxt/client.d.ts +106 -5
- package/dist/pmxt/client.js +399 -5
- package/dist/pmxt/constants.d.ts +11 -0
- package/dist/pmxt/constants.js +14 -1
- package/dist/pmxt/errors.d.ts +3 -0
- package/dist/pmxt/errors.js +11 -1
- package/dist/pmxt/escrow.d.ts +39 -0
- package/dist/pmxt/escrow.js +82 -0
- package/dist/pmxt/feed-client.d.ts +3 -0
- package/dist/pmxt/feed-client.js +11 -2
- package/dist/pmxt/hosted-errors.d.ts +84 -0
- package/dist/pmxt/hosted-errors.js +201 -0
- package/dist/pmxt/hosted-mappers.d.ts +45 -0
- package/dist/pmxt/hosted-mappers.js +302 -0
- package/dist/pmxt/hosted-routing.d.ts +69 -0
- package/dist/pmxt/hosted-routing.js +126 -0
- package/dist/pmxt/hosted-typed-data.d.ts +36 -0
- package/dist/pmxt/hosted-typed-data.js +619 -0
- package/dist/pmxt/models.d.ts +46 -8
- package/dist/pmxt/server-manager.d.ts +4 -0
- package/dist/pmxt/server-manager.js +6 -0
- package/dist/pmxt/signers.d.ts +57 -0
- package/dist/pmxt/signers.js +55 -0
- package/dist/pmxt/ws-client.js +2 -1
- package/generated/docs/OrderLevel.md +2 -0
- package/generated/package.json +1 -1
- package/generated/src/models/OrderLevel.ts +8 -0
- package/generated/src/models/UnifiedMarket.ts +4 -5
- package/index.ts +1 -0
- package/package.json +11 -2
- package/pmxt/client.ts +495 -9
- package/pmxt/constants.ts +15 -0
- package/pmxt/errors.ts +11 -0
- package/pmxt/escrow.ts +93 -0
- package/pmxt/feed-client.ts +14 -2
- package/pmxt/hosted-errors.ts +216 -0
- package/pmxt/hosted-mappers.ts +312 -0
- package/pmxt/hosted-routing.ts +165 -0
- package/pmxt/hosted-typed-data.ts +767 -0
- package/pmxt/models.ts +65 -8
- package/pmxt/server-manager.ts +7 -0
- package/pmxt/signers.ts +86 -0
- package/pmxt/ws-client.ts +2 -1
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted trading EIP-712 validation guardrails.
|
|
3
|
+
*
|
|
4
|
+
* Three layers (mirrors `sdks/python/pmxt/_hosted_typeddata.py`):
|
|
5
|
+
* 1. Schema validation — per-route shape, domain, types, message keys, deadline
|
|
6
|
+
* 2. Economic match — typed_data economics agree with the user's build request
|
|
7
|
+
* 3. Post-sign — exact 65-byte length, low-s canonical, v ∈ {27, 28}, recovery
|
|
8
|
+
*
|
|
9
|
+
* Layer 3 uses the optional `ethers` peer dependency for typed-data verification.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { InvalidSignature } from "./hosted-errors";
|
|
13
|
+
import { to6dec } from "./hosted-mappers";
|
|
14
|
+
import { TypedData } from "./signers";
|
|
15
|
+
|
|
16
|
+
// The constants module is updated in a parallel-agent change to add these
|
|
17
|
+
// allowlists. We import them at runtime so we don't hard-fail if the change
|
|
18
|
+
// hasn't landed yet (the imports are typed below).
|
|
19
|
+
import * as constants from "./constants";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Schema fixtures
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
type FieldList = ReadonlyArray<{ readonly name: string; readonly type: string }>;
|
|
26
|
+
|
|
27
|
+
const EIP712_DOMAIN_FIELDS: FieldList = [
|
|
28
|
+
{ name: "name", type: "string" },
|
|
29
|
+
{ name: "version", type: "string" },
|
|
30
|
+
{ name: "chainId", type: "uint256" },
|
|
31
|
+
{ name: "verifyingContract", type: "address" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const ORDER_PARAMS_FIELDS: FieldList = [
|
|
35
|
+
{ name: "user", type: "address" },
|
|
36
|
+
{ name: "tokenId", type: "uint256" },
|
|
37
|
+
{ name: "worstPrice", type: "uint256" },
|
|
38
|
+
{ name: "maxCostUsdc", type: "uint256" },
|
|
39
|
+
{ name: "deadline", type: "uint256" },
|
|
40
|
+
{ name: "nonce", type: "uint256" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const SELL_ORDER_PARAMS_FIELDS: FieldList = [
|
|
44
|
+
{ name: "user", type: "address" },
|
|
45
|
+
{ name: "tokenId", type: "uint256" },
|
|
46
|
+
{ name: "tokenAmount", type: "uint256" },
|
|
47
|
+
{ name: "worstPrice", type: "uint256" },
|
|
48
|
+
{ name: "deadline", type: "uint256" },
|
|
49
|
+
{ name: "nonce", type: "uint256" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const CROSS_CHAIN_ORDER_PARAMS_FIELDS: FieldList = [
|
|
53
|
+
{ name: "user", type: "address" },
|
|
54
|
+
{ name: "tokenId", type: "uint256" },
|
|
55
|
+
{ name: "maxCostUsdc", type: "uint256" },
|
|
56
|
+
{ name: "worstPrice", type: "uint256" },
|
|
57
|
+
{ name: "destEscrow", type: "address" },
|
|
58
|
+
{ name: "oracleKey", type: "address" },
|
|
59
|
+
{ name: "deadline", type: "uint256" },
|
|
60
|
+
{ name: "nonce", type: "uint256" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const CROSS_CHAIN_SELL_PAY_PARAMS_FIELDS: FieldList = [
|
|
64
|
+
{ name: "user", type: "address" },
|
|
65
|
+
{ name: "tokenId", type: "uint256" },
|
|
66
|
+
{ name: "tokenAmount", type: "uint256" },
|
|
67
|
+
{ name: "worstPrice", type: "uint256" },
|
|
68
|
+
{ name: "deadline", type: "uint256" },
|
|
69
|
+
{ name: "nonce", type: "uint256" },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const CROSS_CHAIN_SELL_PULL_PARAMS_FIELDS: FieldList = [
|
|
73
|
+
{ name: "user", type: "address" },
|
|
74
|
+
{ name: "tokenId", type: "uint256" },
|
|
75
|
+
{ name: "tokenAmount", type: "uint256" },
|
|
76
|
+
{ name: "deadline", type: "uint256" },
|
|
77
|
+
{ name: "nonce", type: "uint256" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const CANCEL_ORDER_FIELDS: FieldList = [
|
|
81
|
+
{ name: "user", type: "address" },
|
|
82
|
+
{ name: "path", type: "uint8" },
|
|
83
|
+
{ name: "nonce", type: "uint256" },
|
|
84
|
+
{ name: "deadline", type: "uint256" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const CANCEL_PULL_FIELDS: FieldList = [
|
|
88
|
+
{ name: "user", type: "address" },
|
|
89
|
+
{ name: "nonce", type: "uint256" },
|
|
90
|
+
{ name: "deadline", type: "uint256" },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
interface DomainSchema {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly version: string;
|
|
96
|
+
readonly chainId: number;
|
|
97
|
+
/** Allowlist source key — looked up against constants at validation time. */
|
|
98
|
+
readonly allowlistKey: "prefunded" | "venue";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface TypedDataSchema {
|
|
102
|
+
readonly primaryType: string;
|
|
103
|
+
readonly domain: DomainSchema;
|
|
104
|
+
readonly fields: FieldList;
|
|
105
|
+
readonly messageKeys: ReadonlySet<string>;
|
|
106
|
+
readonly walletField: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function messageKeysFromFields(fields: FieldList): ReadonlySet<string> {
|
|
110
|
+
return new Set(fields.map((f) => f.name));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const PREFUNDED_DOMAIN: DomainSchema = {
|
|
114
|
+
name: "PreFundedEscrow",
|
|
115
|
+
version: "1",
|
|
116
|
+
chainId: 137,
|
|
117
|
+
allowlistKey: "prefunded",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const VENUE_DOMAIN: DomainSchema = {
|
|
121
|
+
name: "VenueEscrow",
|
|
122
|
+
version: "1",
|
|
123
|
+
chainId: 56,
|
|
124
|
+
allowlistKey: "venue",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type HostedRoute =
|
|
128
|
+
| "polymarket_buy"
|
|
129
|
+
| "polymarket_sell"
|
|
130
|
+
| "opinion_buy"
|
|
131
|
+
| "opinion_sell_polygon"
|
|
132
|
+
| "opinion_sell_bsc_pull"
|
|
133
|
+
| "cancel_polymarket"
|
|
134
|
+
| "cancel_opinion_polygon"
|
|
135
|
+
| "cancel_opinion_bsc_pull";
|
|
136
|
+
|
|
137
|
+
const SCHEMAS: Readonly<Record<HostedRoute, TypedDataSchema>> = {
|
|
138
|
+
polymarket_buy: {
|
|
139
|
+
primaryType: "OrderParams",
|
|
140
|
+
domain: PREFUNDED_DOMAIN,
|
|
141
|
+
fields: ORDER_PARAMS_FIELDS,
|
|
142
|
+
messageKeys: messageKeysFromFields(ORDER_PARAMS_FIELDS),
|
|
143
|
+
walletField: "user",
|
|
144
|
+
},
|
|
145
|
+
polymarket_sell: {
|
|
146
|
+
primaryType: "SellOrderParams",
|
|
147
|
+
domain: PREFUNDED_DOMAIN,
|
|
148
|
+
fields: SELL_ORDER_PARAMS_FIELDS,
|
|
149
|
+
messageKeys: messageKeysFromFields(SELL_ORDER_PARAMS_FIELDS),
|
|
150
|
+
walletField: "user",
|
|
151
|
+
},
|
|
152
|
+
opinion_buy: {
|
|
153
|
+
primaryType: "CrossChainOrderParams",
|
|
154
|
+
domain: PREFUNDED_DOMAIN,
|
|
155
|
+
fields: CROSS_CHAIN_ORDER_PARAMS_FIELDS,
|
|
156
|
+
messageKeys: messageKeysFromFields(CROSS_CHAIN_ORDER_PARAMS_FIELDS),
|
|
157
|
+
walletField: "user",
|
|
158
|
+
},
|
|
159
|
+
opinion_sell_polygon: {
|
|
160
|
+
primaryType: "CrossChainSellPayParams",
|
|
161
|
+
domain: PREFUNDED_DOMAIN,
|
|
162
|
+
fields: CROSS_CHAIN_SELL_PAY_PARAMS_FIELDS,
|
|
163
|
+
messageKeys: messageKeysFromFields(CROSS_CHAIN_SELL_PAY_PARAMS_FIELDS),
|
|
164
|
+
walletField: "user",
|
|
165
|
+
},
|
|
166
|
+
opinion_sell_bsc_pull: {
|
|
167
|
+
primaryType: "CrossChainSellPullParams",
|
|
168
|
+
domain: VENUE_DOMAIN,
|
|
169
|
+
fields: CROSS_CHAIN_SELL_PULL_PARAMS_FIELDS,
|
|
170
|
+
messageKeys: messageKeysFromFields(CROSS_CHAIN_SELL_PULL_PARAMS_FIELDS),
|
|
171
|
+
walletField: "user",
|
|
172
|
+
},
|
|
173
|
+
cancel_polymarket: {
|
|
174
|
+
primaryType: "CancelOrder",
|
|
175
|
+
domain: PREFUNDED_DOMAIN,
|
|
176
|
+
fields: CANCEL_ORDER_FIELDS,
|
|
177
|
+
messageKeys: messageKeysFromFields(CANCEL_ORDER_FIELDS),
|
|
178
|
+
walletField: "user",
|
|
179
|
+
},
|
|
180
|
+
cancel_opinion_polygon: {
|
|
181
|
+
primaryType: "CancelOrder",
|
|
182
|
+
domain: PREFUNDED_DOMAIN,
|
|
183
|
+
fields: CANCEL_ORDER_FIELDS,
|
|
184
|
+
messageKeys: messageKeysFromFields(CANCEL_ORDER_FIELDS),
|
|
185
|
+
walletField: "user",
|
|
186
|
+
},
|
|
187
|
+
cancel_opinion_bsc_pull: {
|
|
188
|
+
primaryType: "CancelPull",
|
|
189
|
+
domain: VENUE_DOMAIN,
|
|
190
|
+
fields: CANCEL_PULL_FIELDS,
|
|
191
|
+
messageKeys: messageKeysFromFields(CANCEL_PULL_FIELDS),
|
|
192
|
+
walletField: "user",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// secp256k1 group order / 2, for canonical low-s check.
|
|
197
|
+
export const SECP256K1_HALF_N =
|
|
198
|
+
0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n;
|
|
199
|
+
|
|
200
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
201
|
+
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Layer 1 — Schema validation
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate the structural shape of a typed-data payload before signing.
|
|
209
|
+
* Throws {@link InvalidSignature} on any mismatch.
|
|
210
|
+
*/
|
|
211
|
+
export function validateTypedData(
|
|
212
|
+
typedData: TypedData,
|
|
213
|
+
route: string,
|
|
214
|
+
walletAddress: string,
|
|
215
|
+
): void {
|
|
216
|
+
const schema = schemaFor(route);
|
|
217
|
+
if (typedData === null || typeof typedData !== "object") {
|
|
218
|
+
schemaFail("typed_data must be an object");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (typedData.primaryType !== schema.primaryType) {
|
|
222
|
+
schemaFail(
|
|
223
|
+
`primaryType expected '${schema.primaryType}' got '${typedData.primaryType}'`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const types = typedData.types;
|
|
228
|
+
if (types === null || typeof types !== "object") {
|
|
229
|
+
schemaFail("types must be an object");
|
|
230
|
+
}
|
|
231
|
+
const domain = typedData.domain;
|
|
232
|
+
if (domain === null || typeof domain !== "object") {
|
|
233
|
+
schemaFail("domain must be an object");
|
|
234
|
+
}
|
|
235
|
+
const message = typedData.message;
|
|
236
|
+
if (message === null || typeof message !== "object") {
|
|
237
|
+
schemaFail("message must be an object");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
validateDomain(domain, schema.domain);
|
|
241
|
+
validateTypes(types, schema);
|
|
242
|
+
validateMessage(message, schema, walletAddress);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateDomain(
|
|
246
|
+
domain: TypedData["domain"],
|
|
247
|
+
expected: DomainSchema,
|
|
248
|
+
): void {
|
|
249
|
+
const actualKeys = Object.keys(domain).sort();
|
|
250
|
+
const expectedKeys = ["chainId", "name", "verifyingContract", "version"];
|
|
251
|
+
if (
|
|
252
|
+
actualKeys.length !== expectedKeys.length ||
|
|
253
|
+
actualKeys.some((k, i) => k !== expectedKeys[i])
|
|
254
|
+
) {
|
|
255
|
+
schemaFail(
|
|
256
|
+
`domain keys expected ${JSON.stringify(expectedKeys)} got ${JSON.stringify(actualKeys)}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (domain.name !== expected.name) {
|
|
261
|
+
schemaFail(`domain.name expected '${expected.name}' got '${domain.name}'`);
|
|
262
|
+
}
|
|
263
|
+
if (domain.version !== expected.version) {
|
|
264
|
+
schemaFail(
|
|
265
|
+
`domain.version expected '${expected.version}' got '${domain.version}'`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const chainId = asInt(domain.chainId, "domain.chainId");
|
|
269
|
+
if (chainId !== expected.chainId) {
|
|
270
|
+
schemaFail(`domain.chainId expected ${expected.chainId} got ${chainId}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const verifyingContract = normalizeAddress(domain.verifyingContract);
|
|
274
|
+
if (verifyingContract === null) {
|
|
275
|
+
schemaFail("domain.verifyingContract must be an EVM address");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const allowlist = allowedAddresses(expected.allowlistKey, expected.chainId);
|
|
279
|
+
if (allowlist.size === 0) {
|
|
280
|
+
schemaFail(
|
|
281
|
+
`no allowlisted verifyingContract configured for chain ${expected.chainId}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (!allowlist.has(verifyingContract!)) {
|
|
285
|
+
schemaFail("domain.verifyingContract is not allowlisted");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validateTypes(
|
|
290
|
+
types: Record<string, Array<{ name: string; type: string }>>,
|
|
291
|
+
schema: TypedDataSchema,
|
|
292
|
+
): void {
|
|
293
|
+
const typeNames = new Set(Object.keys(types));
|
|
294
|
+
const allowed = new Set([schema.primaryType, "EIP712Domain"]);
|
|
295
|
+
for (const name of typeNames) {
|
|
296
|
+
if (!allowed.has(name)) {
|
|
297
|
+
schemaFail(`unexpected type entry: '${name}'`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!typeNames.has("EIP712Domain")) {
|
|
301
|
+
schemaFail("types.EIP712Domain is required");
|
|
302
|
+
}
|
|
303
|
+
if (!typeNames.has(schema.primaryType)) {
|
|
304
|
+
schemaFail(`types.${schema.primaryType} is required`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!fieldListsEqual(types["EIP712Domain"], EIP712_DOMAIN_FIELDS)) {
|
|
308
|
+
schemaFail("types.EIP712Domain field order mismatch");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!fieldListsEqual(types[schema.primaryType], schema.fields)) {
|
|
312
|
+
schemaFail(`types.${schema.primaryType} fields mismatch`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateMessage(
|
|
317
|
+
message: Record<string, unknown>,
|
|
318
|
+
schema: TypedDataSchema,
|
|
319
|
+
walletAddress: string,
|
|
320
|
+
): void {
|
|
321
|
+
const actualKeys = new Set(Object.keys(message));
|
|
322
|
+
if (
|
|
323
|
+
actualKeys.size !== schema.messageKeys.size ||
|
|
324
|
+
[...actualKeys].some((k) => !schema.messageKeys.has(k))
|
|
325
|
+
) {
|
|
326
|
+
schemaFail(
|
|
327
|
+
`message keys expected ${JSON.stringify([...schema.messageKeys].sort())} got ${JSON.stringify([...actualKeys].sort())}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const walletValue = message[schema.walletField];
|
|
332
|
+
if (!addressesEqual(walletValue, walletAddress)) {
|
|
333
|
+
schemaFail(`message.${schema.walletField} does not match wallet_address`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const deadlineKey =
|
|
337
|
+
"deadline" in message ? "deadline" : "expiry" in message ? "expiry" : null;
|
|
338
|
+
if (deadlineKey === null) {
|
|
339
|
+
schemaFail("message.deadline/expiry is required");
|
|
340
|
+
}
|
|
341
|
+
const deadline = asInt(message[deadlineKey!], `message.${deadlineKey}`);
|
|
342
|
+
if (deadline <= Math.floor(Date.now() / 1000)) {
|
|
343
|
+
schemaFail(`message.${deadlineKey} is expired`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Layer 2 — Economic match
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Reject typed-data whose economics don't agree with the user's original
|
|
353
|
+
* build request / build response. Guards against a compromised server
|
|
354
|
+
* returning valid-shape typed-data with altered amounts or wrong target.
|
|
355
|
+
*/
|
|
356
|
+
export function validateEconomics(
|
|
357
|
+
typedData: TypedData,
|
|
358
|
+
route: string,
|
|
359
|
+
buildRequest: any,
|
|
360
|
+
buildResponse: any,
|
|
361
|
+
): void {
|
|
362
|
+
schemaFor(route); // assert known route
|
|
363
|
+
if (typedData === null || typeof typedData !== "object") {
|
|
364
|
+
economicFail("typed_data must be an object");
|
|
365
|
+
}
|
|
366
|
+
const message = typedData.message;
|
|
367
|
+
if (message === null || typeof message !== "object") {
|
|
368
|
+
economicFail("message must be an object");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (route === "polymarket_buy") {
|
|
372
|
+
validatePolymarketBuyEconomics(message, buildRequest);
|
|
373
|
+
validateWorstPrice(message, route, buildRequest, buildResponse);
|
|
374
|
+
} else if (route === "polymarket_sell") {
|
|
375
|
+
validatePolymarketSellEconomics(message, buildRequest);
|
|
376
|
+
validateWorstPrice(message, route, buildRequest, buildResponse);
|
|
377
|
+
} else if (
|
|
378
|
+
route === "opinion_buy" ||
|
|
379
|
+
route === "opinion_sell_polygon" ||
|
|
380
|
+
route === "opinion_sell_bsc_pull"
|
|
381
|
+
) {
|
|
382
|
+
validateOpinionMarketId(message, buildResponse);
|
|
383
|
+
}
|
|
384
|
+
// cancel_* routes: no economic check — chain enforces nonce.
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validatePolymarketBuyEconomics(
|
|
388
|
+
message: Record<string, unknown>,
|
|
389
|
+
buildRequest: any,
|
|
390
|
+
): void {
|
|
391
|
+
const denom = getField(buildRequest, "denom");
|
|
392
|
+
if (denom !== "usdc") {
|
|
393
|
+
economicFail(`denom expected 'usdc' got ${JSON.stringify(denom)}`);
|
|
394
|
+
}
|
|
395
|
+
const amount = firstPresent(
|
|
396
|
+
getField(buildRequest, "amount"),
|
|
397
|
+
getField(buildRequest, "amount_usdc"),
|
|
398
|
+
getField(buildRequest, "amountUsdc"),
|
|
399
|
+
);
|
|
400
|
+
if (amount === MISSING) economicFail("amount missing");
|
|
401
|
+
|
|
402
|
+
const expected = to6decOrFail(amount, "max_cost_usdc");
|
|
403
|
+
const actual = messageBigInt(message, "max_cost_usdc", "maxCostUsdc");
|
|
404
|
+
if (actual !== expected) {
|
|
405
|
+
economicFail(`max_cost_usdc expected ${expected} got ${actual}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function validatePolymarketSellEconomics(
|
|
410
|
+
message: Record<string, unknown>,
|
|
411
|
+
buildRequest: any,
|
|
412
|
+
): void {
|
|
413
|
+
const denom = getField(buildRequest, "denom");
|
|
414
|
+
if (denom !== "shares") {
|
|
415
|
+
economicFail(`denom expected 'shares' got ${JSON.stringify(denom)}`);
|
|
416
|
+
}
|
|
417
|
+
const amount = firstPresent(
|
|
418
|
+
getField(buildRequest, "amount"),
|
|
419
|
+
getField(buildRequest, "shares"),
|
|
420
|
+
);
|
|
421
|
+
if (amount === MISSING) economicFail("amount missing");
|
|
422
|
+
|
|
423
|
+
const expected = to6decOrFail(amount, "shares_6dec");
|
|
424
|
+
const actual = messageBigInt(
|
|
425
|
+
message,
|
|
426
|
+
"shares_6dec",
|
|
427
|
+
"shares6dec",
|
|
428
|
+
"tokenAmount",
|
|
429
|
+
);
|
|
430
|
+
if (actual !== expected) {
|
|
431
|
+
economicFail(`shares_6dec expected ${expected} got ${actual}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const SIX_DEC_DIVISOR = 1_000_000;
|
|
436
|
+
|
|
437
|
+
function validateWorstPrice(
|
|
438
|
+
message: Record<string, unknown>,
|
|
439
|
+
route: "polymarket_buy" | "polymarket_sell",
|
|
440
|
+
buildRequest: any,
|
|
441
|
+
buildResponse: any,
|
|
442
|
+
): void {
|
|
443
|
+
const worstPriceMicro = messageBigInt(message, "worst_price", "worstPrice");
|
|
444
|
+
const worstPrice = Number(worstPriceMicro) / SIX_DEC_DIVISOR;
|
|
445
|
+
const slippagePctRaw = firstPresent(
|
|
446
|
+
getField(buildRequest, "slippage_pct"),
|
|
447
|
+
getField(buildRequest, "slippagePct"),
|
|
448
|
+
getField(buildResponse, "slippage_pct"),
|
|
449
|
+
getField(buildResponse, "slippagePct"),
|
|
450
|
+
20,
|
|
451
|
+
);
|
|
452
|
+
const slippagePct = toFiniteNumber(slippagePctRaw, "slippage_pct");
|
|
453
|
+
|
|
454
|
+
if (route === "polymarket_buy") {
|
|
455
|
+
const bestPrice = toFiniteNumber(
|
|
456
|
+
firstPresent(
|
|
457
|
+
getPath(buildResponse, "quote", "best_price"),
|
|
458
|
+
getField(buildResponse, "best_price"),
|
|
459
|
+
getField(buildResponse, "best_ask"),
|
|
460
|
+
getField(buildResponse, "bestAsk"),
|
|
461
|
+
),
|
|
462
|
+
"quote.best_price",
|
|
463
|
+
);
|
|
464
|
+
const upper = bestPrice * (1 + slippagePct / 100);
|
|
465
|
+
if (worstPrice > upper) {
|
|
466
|
+
economicFail(`worst_price expected <= ${upper} got ${worstPrice}`);
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
const bestPrice = toFiniteNumber(
|
|
470
|
+
firstPresent(
|
|
471
|
+
getPath(buildResponse, "quote", "best_price"),
|
|
472
|
+
getField(buildResponse, "best_price"),
|
|
473
|
+
getField(buildResponse, "best_bid"),
|
|
474
|
+
getField(buildResponse, "bestBid"),
|
|
475
|
+
),
|
|
476
|
+
"quote.best_price",
|
|
477
|
+
);
|
|
478
|
+
const lower = bestPrice * (1 - slippagePct / 100);
|
|
479
|
+
if (worstPrice < lower) {
|
|
480
|
+
economicFail(`worst_price expected >= ${lower} got ${worstPrice}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function validateOpinionMarketId(
|
|
486
|
+
message: Record<string, unknown>,
|
|
487
|
+
buildResponse: any,
|
|
488
|
+
): void {
|
|
489
|
+
const expected = firstPresent(
|
|
490
|
+
getPath(buildResponse, "resolved", "opinion_market_id"),
|
|
491
|
+
getPath(buildResponse, "resolved", "opinionMarketId"),
|
|
492
|
+
getField(buildResponse, "opinion_market_id"),
|
|
493
|
+
getField(buildResponse, "opinionMarketId"),
|
|
494
|
+
getPath(buildResponse, "params", "opinion_market_id"),
|
|
495
|
+
getPath(buildResponse, "params", "opinionMarketId"),
|
|
496
|
+
);
|
|
497
|
+
if (expected === MISSING) economicFail("resolved.opinion_market_id missing");
|
|
498
|
+
|
|
499
|
+
const actual = firstPresent(
|
|
500
|
+
getField(message, "opinion_market_id"),
|
|
501
|
+
getField(message, "opinionMarketId"),
|
|
502
|
+
);
|
|
503
|
+
if (actual === MISSING) economicFail("message.opinion_market_id missing");
|
|
504
|
+
|
|
505
|
+
if (idValue(actual) !== idValue(expected)) {
|
|
506
|
+
economicFail(`opinion_market_id expected ${expected} got ${actual}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Layer 3 — Post-sign verification
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Verify a signature against typed-data and return the normalized signature.
|
|
516
|
+
*
|
|
517
|
+
* Performs:
|
|
518
|
+
* - exact 65-byte length (0x + 130 hex)
|
|
519
|
+
* - low-s canonical check
|
|
520
|
+
* - v ∈ {27, 28} (normalizes {0,1} → {27,28})
|
|
521
|
+
* - typed-data recovery, asserting recovered address === walletAddress
|
|
522
|
+
*
|
|
523
|
+
* Throws {@link InvalidSignature} on any failure.
|
|
524
|
+
*/
|
|
525
|
+
export function verifySignature(
|
|
526
|
+
typedData: TypedData,
|
|
527
|
+
signature: string,
|
|
528
|
+
walletAddress: string,
|
|
529
|
+
): string {
|
|
530
|
+
if (typeof signature !== "string" || !SIGNATURE_RE.test(signature)) {
|
|
531
|
+
throw new InvalidSignature(
|
|
532
|
+
0,
|
|
533
|
+
"signature must be 0x-prefixed 65-byte hex",
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const hex = signature.slice(2);
|
|
538
|
+
const sHex = hex.slice(64, 128);
|
|
539
|
+
const sValue = BigInt("0x" + sHex);
|
|
540
|
+
if (sValue > SECP256K1_HALF_N) {
|
|
541
|
+
throw new InvalidSignature(0, "non-canonical (high-s)");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let vByte = parseInt(hex.slice(128, 130), 16);
|
|
545
|
+
let normalized = signature;
|
|
546
|
+
if (vByte === 0 || vByte === 1) {
|
|
547
|
+
vByte += 27;
|
|
548
|
+
normalized = "0x" + hex.slice(0, 128) + vByte.toString(16).padStart(2, "0");
|
|
549
|
+
}
|
|
550
|
+
if (vByte !== 27 && vByte !== 28) {
|
|
551
|
+
throw new InvalidSignature(0, `invalid recovery byte: ${vByte}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let ethers: any;
|
|
555
|
+
try {
|
|
556
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
557
|
+
ethers = require("ethers");
|
|
558
|
+
} catch {
|
|
559
|
+
throw new InvalidSignature(
|
|
560
|
+
0,
|
|
561
|
+
"ethers is required for hosted signature verification",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let recovered: string;
|
|
566
|
+
try {
|
|
567
|
+
// ethers expects `types` WITHOUT the EIP712Domain entry.
|
|
568
|
+
const types = { ...typedData.types };
|
|
569
|
+
delete types["EIP712Domain"];
|
|
570
|
+
recovered = ethers.verifyTypedData(
|
|
571
|
+
typedData.domain,
|
|
572
|
+
types,
|
|
573
|
+
typedData.message,
|
|
574
|
+
normalized,
|
|
575
|
+
);
|
|
576
|
+
} catch (exc) {
|
|
577
|
+
throw new InvalidSignature(
|
|
578
|
+
0,
|
|
579
|
+
`signature recovery failed: ${(exc as Error).message}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!addressesEqual(recovered, walletAddress)) {
|
|
584
|
+
throw new InvalidSignature(
|
|
585
|
+
0,
|
|
586
|
+
`signature signer mismatch: expected ${walletAddress} got ${recovered}`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return normalized;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Internal helpers
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
const MISSING: unique symbol = Symbol("missing");
|
|
598
|
+
|
|
599
|
+
function schemaFor(route: string): TypedDataSchema {
|
|
600
|
+
const schema = (SCHEMAS as Record<string, TypedDataSchema | undefined>)[route];
|
|
601
|
+
if (!schema) schemaFail(`unknown typed-data route: '${route}'`);
|
|
602
|
+
return schema!;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function schemaFail(message: string): never {
|
|
606
|
+
throw new InvalidSignature(0, `typed_data schema mismatch: ${message}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function economicFail(message: string): never {
|
|
610
|
+
throw new InvalidSignature(0, `economic mismatch: ${message}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function fieldListsEqual(actual: unknown, expected: FieldList): boolean {
|
|
614
|
+
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
615
|
+
for (let i = 0; i < expected.length; i++) {
|
|
616
|
+
const a = actual[i] as { name?: unknown; type?: unknown };
|
|
617
|
+
const e = expected[i];
|
|
618
|
+
if (a === null || typeof a !== "object") return false;
|
|
619
|
+
if (a.name !== e.name || a.type !== e.type) return false;
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function asInt(value: unknown, label: string): number {
|
|
625
|
+
if (typeof value === "boolean") schemaFail(`${label} must be an integer`);
|
|
626
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
627
|
+
if (typeof value === "bigint") return Number(value);
|
|
628
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
629
|
+
const parsed = Number(value);
|
|
630
|
+
if (Number.isInteger(parsed)) return parsed;
|
|
631
|
+
// Allow big numeric strings that fit safe integer range
|
|
632
|
+
try {
|
|
633
|
+
return Number(BigInt(value));
|
|
634
|
+
} catch {
|
|
635
|
+
schemaFail(`${label} must be an integer`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
schemaFail(`${label} must be an integer`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function normalizeAddress(value: unknown): string | null {
|
|
642
|
+
if (typeof value !== "string") return null;
|
|
643
|
+
const candidate = value.trim();
|
|
644
|
+
if (!ADDRESS_RE.test(candidate)) return null;
|
|
645
|
+
return candidate.toLowerCase();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function addressesEqual(left: unknown, right: unknown): boolean {
|
|
649
|
+
const a = normalizeAddress(left);
|
|
650
|
+
const b = normalizeAddress(right);
|
|
651
|
+
return a !== null && a === b;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function allowedAddresses(
|
|
655
|
+
key: "prefunded" | "venue",
|
|
656
|
+
chainId: number,
|
|
657
|
+
): Set<string> {
|
|
658
|
+
const raw =
|
|
659
|
+
key === "prefunded"
|
|
660
|
+
? (constants as any).PREFUNDED_ESCROW_ADDRESSES
|
|
661
|
+
: (constants as any).VENUE_ESCROW_ADDRESSES;
|
|
662
|
+
const list: unknown[] = [];
|
|
663
|
+
if (raw == null) {
|
|
664
|
+
// empty
|
|
665
|
+
} else if (typeof raw === "string") {
|
|
666
|
+
list.push(raw);
|
|
667
|
+
} else if (Array.isArray(raw)) {
|
|
668
|
+
list.push(...raw);
|
|
669
|
+
} else if (raw instanceof Set) {
|
|
670
|
+
for (const v of raw) list.push(v);
|
|
671
|
+
} else if (typeof raw === "object") {
|
|
672
|
+
const lookup =
|
|
673
|
+
(raw as Record<string, unknown>)[String(chainId)] ??
|
|
674
|
+
(raw as Record<number, unknown>)[chainId];
|
|
675
|
+
if (typeof lookup === "string") list.push(lookup);
|
|
676
|
+
else if (Array.isArray(lookup)) list.push(...lookup);
|
|
677
|
+
}
|
|
678
|
+
const out = new Set<string>();
|
|
679
|
+
for (const v of list) {
|
|
680
|
+
const normalized = normalizeAddress(v);
|
|
681
|
+
if (normalized !== null) out.add(normalized);
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function getField(container: unknown, key: string): unknown {
|
|
687
|
+
if (container === null || container === undefined) return MISSING;
|
|
688
|
+
if (typeof container === "object") {
|
|
689
|
+
const obj = container as Record<string, unknown>;
|
|
690
|
+
if (key in obj) return obj[key];
|
|
691
|
+
return MISSING;
|
|
692
|
+
}
|
|
693
|
+
return MISSING;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function getPath(container: unknown, ...keys: string[]): unknown {
|
|
697
|
+
let current: unknown = container;
|
|
698
|
+
for (const key of keys) {
|
|
699
|
+
current = getField(current, key);
|
|
700
|
+
if (current === MISSING) return MISSING;
|
|
701
|
+
}
|
|
702
|
+
return current;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function firstPresent(...values: unknown[]): unknown {
|
|
706
|
+
for (const v of values) {
|
|
707
|
+
if (v !== MISSING && v !== null && v !== undefined) return v;
|
|
708
|
+
}
|
|
709
|
+
return MISSING;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function to6decOrFail(amount: unknown, label: string): bigint {
|
|
713
|
+
try {
|
|
714
|
+
return to6dec(amount as number | string | bigint);
|
|
715
|
+
} catch (exc) {
|
|
716
|
+
throw new InvalidSignature(
|
|
717
|
+
0,
|
|
718
|
+
`economic mismatch: ${label} must fit the 6-decimal grid (${(exc as Error).message})`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function messageBigInt(
|
|
724
|
+
message: Record<string, unknown>,
|
|
725
|
+
...keys: string[]
|
|
726
|
+
): bigint {
|
|
727
|
+
for (const key of keys) {
|
|
728
|
+
if (key in message) {
|
|
729
|
+
const v = message[key];
|
|
730
|
+
if (typeof v === "bigint") return v;
|
|
731
|
+
if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);
|
|
732
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
733
|
+
try {
|
|
734
|
+
return BigInt(v);
|
|
735
|
+
} catch {
|
|
736
|
+
economicFail(`message.${key} must be an integer`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
economicFail(`message.${key} must be an integer`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
economicFail(`message.${keys[0]} missing`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function toFiniteNumber(value: unknown, label: string): number {
|
|
746
|
+
if (value === MISSING || value === null || value === undefined) {
|
|
747
|
+
economicFail(`${label} missing`);
|
|
748
|
+
}
|
|
749
|
+
if (typeof value === "number") {
|
|
750
|
+
if (!Number.isFinite(value)) economicFail(`${label} must be finite`);
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
753
|
+
if (typeof value === "bigint") return Number(value);
|
|
754
|
+
if (typeof value === "string") {
|
|
755
|
+
const parsed = Number(value);
|
|
756
|
+
if (!Number.isFinite(parsed)) economicFail(`${label} must be a number`);
|
|
757
|
+
return parsed;
|
|
758
|
+
}
|
|
759
|
+
economicFail(`${label} must be a number`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function idValue(value: unknown): string {
|
|
763
|
+
if (typeof value === "string") return value;
|
|
764
|
+
if (typeof value === "number") return String(value);
|
|
765
|
+
if (typeof value === "bigint") return value.toString();
|
|
766
|
+
return JSON.stringify(value);
|
|
767
|
+
}
|