@oracle-agent/oracle 0.1.0 → 0.2.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.
@@ -0,0 +1,460 @@
1
+ import { stampPrepared } from "../../prepare-envelope.mjs";
2
+ // Polymarket CLOB — prepare + local sign helpers for Oracle.
3
+ // Prepare path builds unsigned EIP-712 order envelopes (no keys).
4
+ // Sign/post is for @oracle-agent/operator poly-exec (local only, no VPS).
5
+
6
+ import { createHmac, randomInt } from "node:crypto";
7
+ import {
8
+ Wallet,
9
+ keccak256,
10
+ concat,
11
+ hexlify,
12
+ toUtf8Bytes,
13
+ zeroPadValue,
14
+ getCreate2Address,
15
+ AbiCoder,
16
+ } from "ethers";
17
+
18
+ export const POLY_CHAIN_ID = 137;
19
+ export const POLY_CLOB_REST = "https://clob.polymarket.com";
20
+
21
+ const CTF_EXCHANGE_V2 = "0xE111180000d2663C0091e4f400237545B87B996B";
22
+ const NEG_RISK_CTF_EXCHANGE_V2 = "0xe2222d279d744050d28e00520010520000310F59";
23
+ const BYTES32_ZERO =
24
+ "0x0000000000000000000000000000000000000000000000000000000000000000";
25
+ const COLLATERAL_DECIMALS = 6;
26
+
27
+ // Deposit wallet CREATE2 (Polymarket relayer)
28
+ const DEPOSIT_WALLET_FACTORY = "0x00000000000Fb5C9ADea0298D729A0CB3823Cc07";
29
+ const DEPOSIT_WALLET_IMPLEMENTATION = "0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB";
30
+ const ERC1967_CONST1 = "0xcc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f3";
31
+ const ERC1967_CONST2 = "0x5155f3363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076";
32
+ const ERC1967_PREFIX = 0x61003d3d8160233d3973n;
33
+
34
+ const EIP712_DOMAIN_V2 = {
35
+ name: "Polymarket CTF Exchange",
36
+ version: "2",
37
+ chainId: POLY_CHAIN_ID,
38
+ };
39
+
40
+ const ORDER_TYPES_V2 = {
41
+ Order: [
42
+ { name: "salt", type: "uint256" },
43
+ { name: "maker", type: "address" },
44
+ { name: "signer", type: "address" },
45
+ { name: "tokenId", type: "uint256" },
46
+ { name: "makerAmount", type: "uint256" },
47
+ { name: "takerAmount", type: "uint256" },
48
+ { name: "side", type: "uint8" },
49
+ { name: "signatureType", type: "uint8" },
50
+ { name: "timestamp", type: "uint256" },
51
+ { name: "metadata", type: "bytes32" },
52
+ { name: "builder", type: "bytes32" },
53
+ ],
54
+ };
55
+
56
+ const ROUNDING_CONFIG = {
57
+ "0.1": { price: 1, size: 2, amount: 3 },
58
+ "0.01": { price: 2, size: 2, amount: 4 },
59
+ "0.001": { price: 3, size: 2, amount: 5 },
60
+ "0.0001": { price: 4, size: 2, amount: 6 },
61
+ };
62
+
63
+ function _initCodeHashERC1967(implementation, args) {
64
+ const argHex = args.startsWith("0x") ? args.slice(2) : args;
65
+ const n = BigInt(argHex.length / 2);
66
+ const combined = ERC1967_PREFIX + (n << 56n);
67
+ const prefixHex = "0x" + combined.toString(16).padStart(20, "0");
68
+ return keccak256(concat([prefixHex, implementation, "0x6009", ERC1967_CONST2, ERC1967_CONST1, args]));
69
+ }
70
+
71
+ /** Deterministic Polymarket deposit wallet for an EOA (Polygon). */
72
+ export function deriveDepositWallet(owner) {
73
+ const walletId = zeroPadValue(String(owner).toLowerCase(), 32);
74
+ const args = AbiCoder.defaultAbiCoder().encode(
75
+ ["address", "bytes32"],
76
+ [DEPOSIT_WALLET_FACTORY, walletId]
77
+ );
78
+ const salt = keccak256(args);
79
+ const bytecodeHash = _initCodeHashERC1967(DEPOSIT_WALLET_IMPLEMENTATION, args);
80
+ return getCreate2Address(DEPOSIT_WALLET_FACTORY, salt, bytecodeHash);
81
+ }
82
+
83
+ function decimalPlaces(n) {
84
+ const s = String(n);
85
+ if (!s.includes(".")) return 0;
86
+ return s.split(".")[1].replace(/0+$/, "").length;
87
+ }
88
+
89
+ function roundDown(n, dp) {
90
+ const f = 10 ** dp;
91
+ return Math.floor(n * f + 1e-12) / f;
92
+ }
93
+
94
+ function roundUp(n, dp) {
95
+ const f = 10 ** dp;
96
+ return Math.ceil(n * f - 1e-12) / f;
97
+ }
98
+
99
+ function roundNormal(n, dp) {
100
+ const f = 10 ** dp;
101
+ return Math.round(n * f + Number.EPSILON) / f;
102
+ }
103
+
104
+ /** Official-client-style maker/taker raw amounts (decimal, not fixed-point). */
105
+ export function getOrderRawAmounts(side, size, price, roundConfig) {
106
+ const rawPrice = roundNormal(Number(price), roundConfig.price);
107
+ const isBuy = side === "BUY";
108
+
109
+ if (isBuy) {
110
+ const rawTakerAmt = roundDown(Number(size), roundConfig.size);
111
+ let rawMakerAmt = rawTakerAmt * rawPrice;
112
+ if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
113
+ rawMakerAmt = roundUp(rawMakerAmt, roundConfig.amount + 4);
114
+ if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
115
+ rawMakerAmt = roundDown(rawMakerAmt, roundConfig.amount);
116
+ }
117
+ }
118
+ return { rawMakerAmt, rawTakerAmt };
119
+ }
120
+
121
+ const rawMakerAmt = roundDown(Number(size), roundConfig.size);
122
+ let rawTakerAmt = rawMakerAmt * rawPrice;
123
+ if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
124
+ rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4);
125
+ if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
126
+ rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount);
127
+ }
128
+ }
129
+ return { rawMakerAmt, rawTakerAmt };
130
+ }
131
+
132
+ export function toRawAmountStr(value) {
133
+ const str = Number(value).toFixed(COLLATERAL_DECIMALS);
134
+ const [integer, fraction = ""] = str.split(".");
135
+ const raw = integer + fraction.padEnd(COLLATERAL_DECIMALS, "0").slice(0, COLLATERAL_DECIMALS);
136
+ return raw.replace(/^0+/, "") || "0";
137
+ }
138
+
139
+ export function buildHmacSignature(secret, timestamp, method, requestPath, body) {
140
+ let message = String(timestamp) + method + requestPath;
141
+ if (body) message += body;
142
+ // Polymarket secrets are base64 (sometimes base64url)
143
+ const normalized = String(secret).replace(/-/g, "+").replace(/_/g, "/");
144
+ const secretBuf = Buffer.from(normalized, "base64");
145
+ const sig = createHmac("sha256", secretBuf).update(message).digest("base64");
146
+ return sig.replace(/\+/g, "-").replace(/\//g, "_");
147
+ }
148
+
149
+ export function buildL2Headers(creds, address, method, path, body) {
150
+ const timestamp = Math.floor(Date.now() / 1000).toString();
151
+ const signature = buildHmacSignature(creds.secret, timestamp, method, path, body);
152
+ return {
153
+ POLY_ADDRESS: address,
154
+ POLY_SIGNATURE: signature,
155
+ POLY_TIMESTAMP: timestamp,
156
+ POLY_API_KEY: creds.key,
157
+ POLY_PASSPHRASE: creds.passphrase,
158
+ "content-type": "application/json",
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Build V2 order message + domain (no sign).
164
+ */
165
+ export function buildOrderV2Message(params = {}) {
166
+ const {
167
+ tokenId,
168
+ side,
169
+ price,
170
+ size,
171
+ maker,
172
+ signer,
173
+ negRisk = false,
174
+ tickSize = "0.01",
175
+ orderType = "GTC",
176
+ signatureType = 0,
177
+ metadata = BYTES32_ZERO,
178
+ builder = BYTES32_ZERO,
179
+ } = params;
180
+
181
+ if (!tokenId) throw new Error("tokenId required");
182
+ if (side !== "BUY" && side !== "SELL") throw new Error("side must be BUY or SELL");
183
+ if (!maker || !signer) throw new Error("maker and signer required");
184
+ const px = Number(price);
185
+ const sz = Number(size);
186
+ if (!(px > 0 && px < 1)) throw new Error(`price out of (0,1): ${price}`);
187
+ if (!(sz > 0)) throw new Error(`size must be > 0: ${size}`);
188
+ if (![0, 3].includes(Number(signatureType))) {
189
+ throw new Error(`Desk poly supports signatureType 0 (EOA) or 3 (deposit wallet), got ${signatureType}`);
190
+ }
191
+
192
+ const roundConfig = ROUNDING_CONFIG[String(tickSize)] || ROUNDING_CONFIG["0.01"];
193
+ const { rawMakerAmt, rawTakerAmt } = getOrderRawAmounts(side, sz, px, roundConfig);
194
+ const makerAmount = toRawAmountStr(rawMakerAmt);
195
+ const takerAmount = toRawAmountStr(rawTakerAmt);
196
+ const salt = String(randomInt(1, 2 ** 31 - 1));
197
+ const timestamp = Date.now().toString();
198
+ const sideInt = side === "BUY" ? 0 : 1;
199
+ const exchange = negRisk ? NEG_RISK_CTF_EXCHANGE_V2 : CTF_EXCHANGE_V2;
200
+
201
+ const orderMessage = {
202
+ salt,
203
+ maker,
204
+ signer,
205
+ tokenId: String(tokenId),
206
+ makerAmount,
207
+ takerAmount,
208
+ side: sideInt,
209
+ signatureType: Number(signatureType),
210
+ timestamp,
211
+ metadata,
212
+ builder,
213
+ };
214
+
215
+ const domain = { ...EIP712_DOMAIN_V2, verifyingContract: exchange };
216
+ const notionalUsdc = side === "BUY" ? rawMakerAmt : rawTakerAmt;
217
+
218
+ return {
219
+ domain,
220
+ types: ORDER_TYPES_V2,
221
+ value: orderMessage,
222
+ orderType,
223
+ side,
224
+ notionalUsdc,
225
+ exchange,
226
+ };
227
+ }
228
+
229
+ /** ERC-7739-wrapped POLY_1271 signature for deposit wallets (sigType=3). */
230
+ export async function signOrderV2_1271(wallet, orderMessage, exchangeAddress) {
231
+ const ORDER_TYPE_STRING =
232
+ "Order(uint256 salt,address maker,address signer,uint256 tokenId," +
233
+ "uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType," +
234
+ "uint256 timestamp,bytes32 metadata,bytes32 builder)";
235
+ const ORDER_TYPE_HASH = keccak256(toUtf8Bytes(ORDER_TYPE_STRING));
236
+ const DOMAIN_TYPE_HASH = keccak256(
237
+ toUtf8Bytes("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
238
+ );
239
+ const NAME_HASH = keccak256(toUtf8Bytes(EIP712_DOMAIN_V2.name));
240
+ const VERSION_HASH = keccak256(toUtf8Bytes(EIP712_DOMAIN_V2.version));
241
+
242
+ const appDomainSep = keccak256(
243
+ AbiCoder.defaultAbiCoder().encode(
244
+ ["bytes32", "bytes32", "bytes32", "uint256", "address"],
245
+ [DOMAIN_TYPE_HASH, NAME_HASH, VERSION_HASH, BigInt(POLY_CHAIN_ID), exchangeAddress]
246
+ )
247
+ );
248
+
249
+ const contentsHash = keccak256(
250
+ AbiCoder.defaultAbiCoder().encode(
251
+ [
252
+ "bytes32",
253
+ "uint256",
254
+ "address",
255
+ "address",
256
+ "uint256",
257
+ "uint256",
258
+ "uint256",
259
+ "uint8",
260
+ "uint8",
261
+ "uint256",
262
+ "bytes32",
263
+ "bytes32",
264
+ ],
265
+ [
266
+ ORDER_TYPE_HASH,
267
+ BigInt(orderMessage.salt),
268
+ orderMessage.maker,
269
+ orderMessage.signer,
270
+ BigInt(orderMessage.tokenId),
271
+ BigInt(orderMessage.makerAmount),
272
+ BigInt(orderMessage.takerAmount),
273
+ orderMessage.side,
274
+ orderMessage.signatureType,
275
+ BigInt(orderMessage.timestamp),
276
+ orderMessage.metadata,
277
+ orderMessage.builder,
278
+ ]
279
+ )
280
+ );
281
+
282
+ const innerSig = await wallet.signTypedData(
283
+ { ...EIP712_DOMAIN_V2, verifyingContract: exchangeAddress },
284
+ {
285
+ TypedDataSign: [
286
+ { name: "contents", type: "Order" },
287
+ { name: "name", type: "string" },
288
+ { name: "version", type: "string" },
289
+ { name: "chainId", type: "uint256" },
290
+ { name: "verifyingContract", type: "address" },
291
+ { name: "salt", type: "bytes32" },
292
+ ],
293
+ Order: ORDER_TYPES_V2.Order,
294
+ },
295
+ {
296
+ contents: orderMessage,
297
+ name: "DepositWallet",
298
+ version: "1",
299
+ chainId: POLY_CHAIN_ID,
300
+ verifyingContract: orderMessage.maker, // deposit wallet (ERC-1271)
301
+ salt: BYTES32_ZERO,
302
+ }
303
+ );
304
+
305
+ const orderTypeHex = hexlify(toUtf8Bytes(ORDER_TYPE_STRING));
306
+ const lenHex = "0x" + (186).toString(16).padStart(4, "0");
307
+ return concat([innerSig, appDomainSep, contentsHash, orderTypeHex, lenHex]);
308
+ }
309
+
310
+ /**
311
+ * Build + sign V2 order.
312
+ * mode:
313
+ * - eoa (0): maker=funder or wallet
314
+ * - deposit (3): maker=signer=deriveDepositWallet(eoa) [default auto for desk agent]
315
+ */
316
+ export async function buildSignedOrderV2(wallet, params = {}) {
317
+ const eoa = wallet.address;
318
+ let signatureType = params.signatureType;
319
+ let funder = params.funderAddress || eoa;
320
+
321
+ // Auto deposit-wallet flow when requested or when env forces it
322
+ const preferDeposit =
323
+ params.useDepositWallet === true ||
324
+ process.env.MAD_POLY_USE_DEPOSIT_WALLET === "1" ||
325
+ signatureType === 3;
326
+
327
+ if (preferDeposit || signatureType === undefined) {
328
+ // default path for accounts that reject EOA maker: try deposit wallet
329
+ if (preferDeposit || process.env.MAD_POLY_USE_DEPOSIT_WALLET !== "0") {
330
+ const dw = deriveDepositWallet(eoa);
331
+ // if caller didn't force eoa-only, use deposit when useDepositWallet or auto
332
+ if (preferDeposit || params.autoDeposit !== false) {
333
+ // keep eoa as default unless deposit forced — placeLimitOrder sets useDepositWallet on retry
334
+ }
335
+ }
336
+ }
337
+
338
+ if (params.useDepositWallet || signatureType === 3) {
339
+ funder = deriveDepositWallet(eoa);
340
+ signatureType = 3;
341
+ } else {
342
+ signatureType = signatureType ?? 0;
343
+ }
344
+
345
+ // sigType 3: maker = deposit wallet; signer = EOA (must match API key address)
346
+ // (CLOB: "order signer address has to be the address of the API KEY")
347
+ const orderSigner = eoa;
348
+ const built = buildOrderV2Message({
349
+ ...params,
350
+ maker: funder,
351
+ signer: orderSigner,
352
+ signatureType,
353
+ });
354
+
355
+ const signature =
356
+ signatureType === 3
357
+ ? await signOrderV2_1271(wallet, built.value, built.exchange)
358
+ : await wallet.signTypedData(built.domain, built.types, built.value);
359
+
360
+ return {
361
+ order: {
362
+ salt: parseInt(built.value.salt, 10),
363
+ maker: built.value.maker,
364
+ signer: built.value.signer,
365
+ tokenId: built.value.tokenId,
366
+ makerAmount: built.value.makerAmount,
367
+ takerAmount: built.value.takerAmount,
368
+ side: built.side,
369
+ signatureType: built.value.signatureType,
370
+ timestamp: built.value.timestamp,
371
+ metadata: built.value.metadata,
372
+ builder: built.value.builder,
373
+ expiration: "0",
374
+ signature,
375
+ },
376
+ owner: null,
377
+ orderType: built.orderType,
378
+ _meta: {
379
+ exchange: built.exchange,
380
+ notionalUsdc: built.notionalUsdc,
381
+ domain: built.domain,
382
+ types: built.types,
383
+ value: built.value,
384
+ depositWallet: signatureType === 3 ? funder : null,
385
+ eoa,
386
+ },
387
+ };
388
+ }
389
+
390
+ /**
391
+ * POST signed order to CLOB. Requires L2 creds.
392
+ */
393
+ export async function postClobOrder({
394
+ payload,
395
+ creds,
396
+ address,
397
+ clobRest = POLY_CLOB_REST,
398
+ fetchImpl = globalThis.fetch,
399
+ } = {}) {
400
+ if (!creds?.key || !creds?.secret || !creds?.passphrase) {
401
+ throw new Error("L2 creds required (key/secret/passphrase)");
402
+ }
403
+ const body = JSON.stringify(payload);
404
+ const path = "/order";
405
+ const headers = buildL2Headers(creds, address, "POST", path, body);
406
+ const res = await fetchImpl(`${clobRest.replace(/\/$/, "")}${path}`, {
407
+ method: "POST",
408
+ headers,
409
+ body,
410
+ });
411
+ const text = await res.text();
412
+ let data;
413
+ try {
414
+ data = JSON.parse(text);
415
+ } catch {
416
+ data = { raw: text };
417
+ }
418
+ if (!res.ok) {
419
+ const err = new Error(
420
+ `CLOB POST /order failed (${res.status}): ${text.slice(0, 300)}`
421
+ );
422
+ err.status = res.status;
423
+ err.body = data;
424
+ throw err;
425
+ }
426
+ return data;
427
+ }
428
+
429
+ /** Max notional USDC for a single desk order (env MAD_POLY_MAX_NOTIONAL, default 25). */
430
+ export function polyMaxNotional() {
431
+ const n = Number(process.env.MAD_POLY_MAX_NOTIONAL || "25");
432
+ return Number.isFinite(n) && n > 0 ? n : 25;
433
+ }
434
+
435
+
436
+ /** Prepare-only: unsigned CLOB order envelope for operator to sign locally. */
437
+ export function polyPrepareOrder(args = {}) {
438
+ const built = buildOrderV2Message(args);
439
+ return stampPrepared(
440
+ {
441
+ provider: "poly-clob",
442
+ kind: "poly-order",
443
+ chainId: POLY_CHAIN_ID,
444
+ tokenId: String(args.tokenId),
445
+ side: built.side,
446
+ orderType: built.orderType,
447
+ notionalUsdc: built.notionalUsdc,
448
+ exchange: built.exchange,
449
+ domain: built.domain,
450
+ types: built.types,
451
+ value: built.value,
452
+ tickSize: args.tickSize || "0.01",
453
+ negRisk: Boolean(args.negRisk),
454
+ signatureType: Number(args.signatureType ?? 0),
455
+ // amounts echoed for caps
456
+ maxNotionalUsdc: args.maxNotionalUsdc ?? null,
457
+ },
458
+ { provider: "poly-clob", kind: "poly-order" }
459
+ );
460
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { btcAddress } from "./bitcoin-esplora.mjs";
9
9
  import { resolveProviderEndpoint, credentialedHeaders } from "../provider-endpoint.mjs";
10
+ import { stampPrepared } from "../../prepare-envelope.mjs";
10
11
 
11
12
  export const SATFLOW_BASE = "https://api.satflow.com/v1";
12
13
 
@@ -294,7 +295,7 @@ export async function satflowPreparePurchase(args = {}, opts = {}) {
294
295
  throw new Error(`satflow: purchase price ${settled} sats exceeds maxSats cap ${maxSats} — PSBT withheld`);
295
296
  }
296
297
 
297
- return {
298
+ return stampPrepared({
298
299
  provider: "satflow",
299
300
  kind: "purchase-intent",
300
301
  executionReady: false,
@@ -314,7 +315,7 @@ export async function satflowPreparePurchase(args = {}, opts = {}) {
314
315
  data?.psbt ||
315
316
  null,
316
317
  note: "Sign PSBT in a Bitcoin wallet. Broadcast only through the user's wallet or an explicitly armed Bitcoin execution plane.",
317
- };
318
+ });
318
319
  }
319
320
 
320
321
  /** List intent — unsigned listing PSBTs */
@@ -324,7 +325,7 @@ export async function satflowPrepareList(args = {}, opts = {}) {
324
325
  throw new Error("satflow: list requires inscription_id or runes_output");
325
326
  }
326
327
  const data = await sfFetch("/intent/sell", { method: "POST", body, opts });
327
- return {
328
+ return stampPrepared({
328
329
  provider: "satflow",
329
330
  kind: "list-intent",
330
331
  requiresUserSignature: true,
@@ -332,5 +333,5 @@ export async function satflowPrepareList(args = {}, opts = {}) {
332
333
  intent: data,
333
334
  unsignedPsbt: data?.unsignedListingPSBTBase64 || data?.unsigned_psbt || null,
334
335
  note: "Sign listing PSBTs in a Bitcoin wallet; listing broadcast is outside the data plane.",
335
- };
336
+ });
336
337
  }
@@ -23,6 +23,15 @@ function paramsWithCommitment(params, commitment) {
23
23
  }
24
24
 
25
25
  export async function solanaRpc(method, params = [], opts = {}) {
26
+ const methodName = String(method || "");
27
+ if (
28
+ methodName === "sendTransaction" ||
29
+ methodName === "sendRawTransaction" ||
30
+ methodName === "simulateBundle" && false ||
31
+ /^send/i.test(methodName)
32
+ ) {
33
+ throw new Error(`solana-rpc: ${methodName} refused — @oracle-agent/oracle is prepare-only (no broadcast)`);
34
+ }
26
35
  const result = await httpJson(rpcUrl(opts), {
27
36
  method: "POST",
28
37
  body: {
@@ -4,6 +4,7 @@
4
4
  import { Interface, getAddress, isAddress } from "ethers";
5
5
  import { rpcCall } from "./evm-rpc.mjs";
6
6
  import { attachAutoSlippage, bindAutoSlippageGuardToCall } from "../../auto-slippage.mjs";
7
+ import { stampPrepared } from "../../prepare-envelope.mjs";
7
8
 
8
9
  /** @type {Record<number, { quoter: string, weth: string, usdc: string, name: string }>} */
9
10
  export const UNI_V3_CHAINS = {
@@ -246,7 +247,7 @@ export async function uniV3PrepareExactIn(q = {}, opts = {}) {
246
247
  const nowSeconds = Number(opts.nowSeconds ?? Math.floor(Date.now() / 1000));
247
248
  const deadline = nowSeconds + deadlineWindow;
248
249
  const data = ROUTER_IFACE.encodeFunctionData("multicall", [deadline, [exact]]);
249
- return {
250
+ return stampPrepared({
250
251
  provider: "uniswap-v3",
251
252
  calldataReady: true,
252
253
  // NOT executable authority. The bytes are assembled but nothing is signed:
@@ -273,7 +274,7 @@ export async function uniV3PrepareExactIn(q = {}, opts = {}) {
273
274
  value: nativeInput ? quote.amountIn : "0",
274
275
  slippageGuard: bindAutoSlippageGuardToCall(quote.autoSlippage, { chainId, venue: meta.router, data }),
275
276
  },
276
- };
277
+ });
277
278
  }
278
279
 
279
280
  export async function uniV3Health(opts = {}) {
package/src/index.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // public package entrypoint, so importing the package cannot hand a caller a
8
8
  // signer.
9
9
 
10
+ export { stampPrepared, assertPreparedEnvelope, computePrepareHash } from "./prepare-envelope.mjs";
10
11
  export { data, dataCall, dataHealth, dataCatalog } from "./data/desk-data.mjs";
11
12
  export { registerProvider, getProvider, listProviders } from "./data/catalog.mjs";
12
13
 
@@ -8,6 +8,10 @@ import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { normalizeScopes, EXECUTE_SCOPE, DEFAULT_SCOPES } from "../scopes.mjs";
10
10
 
11
+ // API keys are 32+ bytes of CSPRNG output, not user-chosen passwords. SHA-256
12
+ // is the right lookup hash here (same class as GitHub PATs). Slow password
13
+ // KDFs would only add DoS surface on every authenticated request.
14
+ // codeql[js/insufficient-password-hash]
11
15
  function hashKey(raw) {
12
16
  return createHash("sha256").update(String(raw), "utf8").digest("hex");
13
17
  }
@@ -0,0 +1,108 @@
1
+ // Canonical prepare envelope — stamped by @oracle-agent/oracle prepare helpers,
2
+ // verified by @oracle-agent/operator before any local sign/submit.
3
+ // Keeps agentic UX identical: prepare() → exec(prepared). Blocks handcrafted actions.
4
+
5
+ import { createHash } from "node:crypto";
6
+
7
+ export const PREPARE_VERSION = 1;
8
+ /** Default max age for a prepared envelope at sign time (ms). */
9
+ export const PREPARE_MAX_AGE_MS = 5 * 60 * 1000;
10
+
11
+ function stableStringify(value) {
12
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
13
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
14
+ const keys = Object.keys(value).sort();
15
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
16
+ }
17
+
18
+ /** Fields that bind the envelope (must not include the stamp fields themselves). */
19
+ export function prepareBodyForHash(prepared) {
20
+ const {
21
+ oraclePrepared,
22
+ prepareHash,
23
+ preparedAt,
24
+ prepareVersion,
25
+ note,
26
+ // strip volatile display-only
27
+ ...body
28
+ } = prepared || {};
29
+ return body;
30
+ }
31
+
32
+ export function computePrepareHash(prepared) {
33
+ const body = prepareBodyForHash(prepared);
34
+ return createHash("sha256").update(stableStringify(body)).digest("hex");
35
+ }
36
+
37
+ /**
38
+ * Stamp a prepare result. Call at the end of every prepare helper.
39
+ * @param {object} payload provider-specific prepare fields (action/tx/psbt/...)
40
+ */
41
+ export function stampPrepared(payload, { provider, kind } = {}) {
42
+ if (!payload || typeof payload !== "object") throw new Error("stampPrepared: payload object required");
43
+ const base = {
44
+ ...payload,
45
+ provider: payload.provider || provider,
46
+ kind: payload.kind || kind,
47
+ requiresUserSignature: payload.requiresUserSignature !== false,
48
+ signingReady: false,
49
+ broadcastReady: false,
50
+ executionReady: false,
51
+ };
52
+ if (!base.provider) throw new Error("stampPrepared: provider required");
53
+ if (!base.kind) throw new Error("stampPrepared: kind required");
54
+ const preparedAt = Date.now();
55
+ const prepareVersion = PREPARE_VERSION;
56
+ const withMeta = { ...base, preparedAt, prepareVersion };
57
+ const prepareHash = computePrepareHash(withMeta);
58
+ return {
59
+ ...withMeta,
60
+ oraclePrepared: true,
61
+ prepareHash,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Verify a prepared envelope before operator sign.
67
+ * @throws on mismatch / expiry / missing stamp
68
+ */
69
+ export function assertPreparedEnvelope(prepared, {
70
+ maxAgeMs = PREPARE_MAX_AGE_MS,
71
+ nowMs = Date.now(),
72
+ providers = null,
73
+ kinds = null,
74
+ label = "operator",
75
+ } = {}) {
76
+ if (!prepared || typeof prepared !== "object") {
77
+ throw new Error(`${label}: prepared envelope object required`);
78
+ }
79
+ if (prepared.oraclePrepared !== true) {
80
+ throw new Error(
81
+ `${label}: refused handcrafted action — pass a stamped prepare result from @oracle-agent/oracle (oraclePrepared missing)`
82
+ );
83
+ }
84
+ if (Number(prepared.prepareVersion) !== PREPARE_VERSION) {
85
+ throw new Error(`${label}: unsupported prepareVersion ${prepared.prepareVersion}`);
86
+ }
87
+ const age = nowMs - Number(prepared.preparedAt || 0);
88
+ if (!Number.isFinite(age) || age < 0 || age > maxAgeMs) {
89
+ throw new Error(`${label}: prepare envelope expired or clock-skewed (ageMs=${age}, max=${maxAgeMs})`);
90
+ }
91
+ const expected = computePrepareHash(prepared);
92
+ if (String(prepared.prepareHash || "") !== expected) {
93
+ throw new Error(`${label}: prepareHash mismatch — payload was altered after prepare`);
94
+ }
95
+ if (providers) {
96
+ const allow = new Set(providers);
97
+ if (!allow.has(prepared.provider)) {
98
+ throw new Error(`${label}: provider ${prepared.provider} not allowed here`);
99
+ }
100
+ }
101
+ if (kinds) {
102
+ const allow = new Set(kinds);
103
+ if (!allow.has(prepared.kind)) {
104
+ throw new Error(`${label}: kind ${prepared.kind} not allowed here`);
105
+ }
106
+ }
107
+ return true;
108
+ }