@oracle-agent/oracle 0.6.0 → 0.8.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.
Files changed (43) hide show
  1. package/bin/desk-server.mjs +2 -2
  2. package/bin/oracle-data-mcp.mjs +32 -0
  3. package/package.json +1 -1
  4. package/profiles/oracle/SOUL.md +2 -2
  5. package/public/oracle-splash/assets/llms/claude.svg +1 -1
  6. package/public/oracle-splash/assets/llms/codex-icon.svg +1 -0
  7. package/public/oracle-splash/assets/llms/codex.svg +1 -1
  8. package/public/oracle-splash/assets/llms/cursor-icon.svg +12 -0
  9. package/public/oracle-splash/assets/llms/cursor.svg +1 -1
  10. package/public/oracle-splash/assets/llms/deepseek.svg +1 -1
  11. package/public/oracle-splash/assets/llms/gemini.svg +1 -1
  12. package/public/oracle-splash/assets/llms/kimi-icon.svg +1 -0
  13. package/public/oracle-splash/assets/llms/kimi.svg +1 -1
  14. package/public/oracle-splash/assets/llms/openclaw.svg +1 -1
  15. package/public/oracle-splash/assets/llms/perplexity.svg +1 -1
  16. package/public/oracle-splash/assets/llms/qwen.svg +1 -1
  17. package/public/oracle-splash/assets/wordmarks/across-icon.webp +0 -0
  18. package/public/oracle-splash/assets/wordmarks/cowswap.svg +1 -1
  19. package/public/oracle-splash/assets/wordmarks/curve.png +0 -0
  20. package/public/oracle-splash/assets/wordmarks/gmx.svg +1 -1
  21. package/public/oracle-splash/assets/wordmarks/kinetiq.svg +1 -0
  22. package/public/oracle-splash/assets/wordmarks/lfj.webp +0 -0
  23. package/public/oracle-splash/assets/wordmarks/magic-eden.svg +1 -1
  24. package/public/oracle-splash/assets/wordmarks/markets.svg +1 -0
  25. package/public/oracle-splash/assets/wordmarks/morpho.svg +1 -1
  26. package/public/oracle-splash/assets/wordmarks/odos.svg +1 -1
  27. package/public/oracle-splash/assets/wordmarks/oneinch-icon.webp +0 -0
  28. package/public/oracle-splash/assets/wordmarks/opensea-icon.svg +17 -0
  29. package/public/oracle-splash/assets/wordmarks/opensea.svg +1 -1
  30. package/public/oracle-splash/assets/wordmarks/paragon.svg +9 -0
  31. package/public/oracle-splash/assets/wordmarks/paraswap.svg +1 -1
  32. package/public/oracle-splash/assets/wordmarks/stargate-icon.webp +0 -0
  33. package/public/oracle-splash/index.html +129 -118
  34. package/scripts/check-doc-drift.mjs +1 -0
  35. package/src/data/catalog.mjs +28 -2
  36. package/src/data/desk-data.mjs +6 -0
  37. package/src/data/providers/rfq.mjs +15 -0
  38. package/src/index.mjs +7 -0
  39. package/src/public-api/http.mjs +16 -1
  40. package/src/public-control/runtime-config.mjs +11 -1
  41. package/src/rfq/intent.mjs +180 -0
  42. package/src/rfq/sources.mjs +181 -0
  43. package/protocols/templates/safe-erc20/README.md +0 -9
@@ -41,6 +41,8 @@ export const DEFAULT_HOST = "127.0.0.1";
41
41
  export const DEFAULT_PORT = 8799;
42
42
  export const VERSION = pkg.version;
43
43
 
44
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
45
+
44
46
  const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), "../..");
45
47
  const CONSOLE_DIR = join(ROOT_DIR, "public", "oracle-console");
46
48
 
@@ -136,6 +138,18 @@ function asPlainObject(v, code) {
136
138
  return v;
137
139
  }
138
140
 
141
+ function normalizeHost(host) {
142
+ return String(host || "").trim().toLowerCase().replace(/^\[|\]$/g, "");
143
+ }
144
+
145
+ function assertLoopbackBindHost(host) {
146
+ const normalized = normalizeHost(host);
147
+ if (!LOOPBACK_HOSTS.has(normalized)) {
148
+ throw new TypeError(`oracle-public only binds loopback hosts; got ${String(host)}`);
149
+ }
150
+ return normalized === "::1" ? "::1" : String(host).trim();
151
+ }
152
+
139
153
  // ---------------------------------------------------------------------------
140
154
  // Route handlers — each returns { status, body }; errors are thrown.
141
155
  // ---------------------------------------------------------------------------
@@ -354,9 +368,10 @@ export function createPublicServer() {
354
368
  /**
355
369
  * Resolve host/port: explicit args > ORACLE_PUBLIC_HOST/PORT env (via
356
370
  * oracle-env's env() helper) > loopback 127.0.0.1:8799 default.
371
+ * Non-loopback binds fail closed; this plane has no remote peer/origin guard.
357
372
  */
358
373
  export function resolvePublicBind({ host, port } = {}) {
359
- const h = host ?? env("ORACLE_PUBLIC_HOST", "MAD_PUBLIC_HOST", DEFAULT_HOST);
374
+ const h = assertLoopbackBindHost(host ?? env("ORACLE_PUBLIC_HOST", "MAD_PUBLIC_HOST", DEFAULT_HOST));
360
375
  const rawPort = port ?? env("ORACLE_PUBLIC_PORT", "MAD_PUBLIC_PORT", String(DEFAULT_PORT));
361
376
  const p = Number(rawPort);
362
377
  if (!Number.isInteger(p) || p < 0 || p > 65535) {
@@ -107,6 +107,7 @@ const CHAIN_SEEDS = Object.freeze([
107
107
  export const DEFAULT_CHAIN_ID = 8453;
108
108
  export const DEFAULT_PUBLIC_HOST = "127.0.0.1";
109
109
  export const DEFAULT_PUBLIC_PORT = 8799;
110
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
110
111
 
111
112
  function buildChainEntry(seed, env) {
112
113
  const entry = {
@@ -143,6 +144,15 @@ function parsePort(raw, label) {
143
144
  return n;
144
145
  }
145
146
 
147
+ function parsePublicHost(raw, label) {
148
+ const host = String(raw).trim();
149
+ const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
150
+ if (!LOOPBACK_HOSTS.has(normalized)) {
151
+ fail(`${label} must be a loopback host, got ${JSON.stringify(raw)}`);
152
+ }
153
+ return normalized === "::1" ? "::1" : host;
154
+ }
155
+
146
156
  /**
147
157
  * Parse the public runtime config (chain/bundler/paymaster registry + public
148
158
  * HTTP bind) from an env-like object. Pure function, no I/O, no network.
@@ -182,7 +192,7 @@ export function loadPublicConfig(env = process.env) {
182
192
 
183
193
  const publicHost = (() => {
184
194
  const v = env.ORACLE_PUBLIC_HOST ?? env.MAD_PUBLIC_HOST;
185
- return v != null && String(v).trim() !== "" ? String(v) : DEFAULT_PUBLIC_HOST;
195
+ return v != null && String(v).trim() !== "" ? parsePublicHost(v, "ORACLE_PUBLIC_HOST") : DEFAULT_PUBLIC_HOST;
186
196
  })();
187
197
 
188
198
  const publicPort = (() => {
@@ -0,0 +1,180 @@
1
+ import { createHash } from "node:crypto";
2
+ import { getAddress, isAddress } from "ethers";
3
+
4
+ export const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
5
+ export const DEFAULT_SAME_CHAIN_SOURCES = Object.freeze(["lifi", "paraswap", "0x", "cow", "uniswap-v3"]);
6
+ export const DEFAULT_CROSS_CHAIN_SOURCES = Object.freeze(["lifi"]);
7
+ const HEX32 = /^0x[0-9a-fA-F]{64}$/;
8
+ const INT = /^\d+$/;
9
+
10
+ function fail(label, message = "invalid") {
11
+ throw new Error(`rfq: ${label} ${message}`);
12
+ }
13
+
14
+ function chainId(value, label) {
15
+ const n = Number(value);
16
+ if (!Number.isSafeInteger(n) || n <= 0) fail(label, "must be a positive chainId");
17
+ return n;
18
+ }
19
+
20
+ function amount(value, label) {
21
+ const s = String(value ?? "").trim();
22
+ if (!INT.test(s) || BigInt(s) <= 0n) fail(label, "must be a positive integer string");
23
+ return s;
24
+ }
25
+
26
+ function optionalAmount(value, label) {
27
+ if (value == null || value === "") return null;
28
+ return amount(value, label);
29
+ }
30
+
31
+ function token(value, label) {
32
+ const s = String(value ?? "").trim();
33
+ if (s.toLowerCase() === NATIVE_TOKEN.toLowerCase()) return NATIVE_TOKEN;
34
+ if (!isAddress(s)) fail(label, "must be an EVM address or native token sentinel");
35
+ return getAddress(s);
36
+ }
37
+
38
+ function address(value, label) {
39
+ const s = String(value ?? "").trim();
40
+ if (!isAddress(s)) fail(label, "must be an EVM address");
41
+ return getAddress(s);
42
+ }
43
+
44
+ function optionalAddress(value, label) {
45
+ if (value == null || value === "") return null;
46
+ return address(value, label);
47
+ }
48
+
49
+ function stringList(value, label, defaults) {
50
+ if (value === undefined) return [...defaults];
51
+ if (!Array.isArray(value)) fail(label, "must be an array");
52
+ return value.map((x) => String(x).trim()).filter(Boolean);
53
+ }
54
+
55
+ function deadline(value, { nowMs, maxDeadlineMs }, label) {
56
+ const n = Number(value);
57
+ if (!Number.isFinite(n)) fail(label, "must be a finite millisecond timestamp");
58
+ if (n <= nowMs) fail(label, "expired");
59
+ if (n > nowMs + maxDeadlineMs) fail(label, "exceeds maxDeadlineMs");
60
+ return Math.floor(n);
61
+ }
62
+
63
+ function plain(value) {
64
+ if (Array.isArray(value)) return value.map(plain);
65
+ if (value && typeof value === "object") {
66
+ const out = {};
67
+ for (const key of Object.keys(value).sort()) {
68
+ if (value[key] !== undefined) out[key] = plain(value[key]);
69
+ }
70
+ return out;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ export function canonicalJson(value) {
76
+ return JSON.stringify(plain(value));
77
+ }
78
+
79
+ export function sha256Hex(value) {
80
+ return `0x${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
81
+ }
82
+
83
+ function withoutHash(value, keys) {
84
+ const out = { ...value };
85
+ for (const key of keys) delete out[key];
86
+ return out;
87
+ }
88
+
89
+ export function hashRfqIntent(intent) {
90
+ return sha256Hex(withoutHash(intent, ["intentHash"]));
91
+ }
92
+
93
+ export function hashFirmQuote(quote) {
94
+ return sha256Hex(withoutHash(quote, ["firmQuoteHash"]));
95
+ }
96
+
97
+ export function normalizeRfqIntent(input = {}, opts = {}) {
98
+ const nowMs = Number(opts.nowMs ?? Date.now());
99
+ const maxDeadlineMs = Number(opts.maxDeadlineMs ?? 30 * 60 * 1000);
100
+ if (!Number.isFinite(nowMs) || !Number.isFinite(maxDeadlineMs) || maxDeadlineMs <= 0) fail("time", "is invalid");
101
+ const fromChainId = chainId(input.fromChainId ?? input.fromChain, "fromChainId");
102
+ const toChainId = chainId(input.toChainId ?? input.toChain, "toChainId");
103
+ const sameChain = fromChainId === toChainId;
104
+ const defaults = sameChain ? DEFAULT_SAME_CHAIN_SOURCES : DEFAULT_CROSS_CHAIN_SOURCES;
105
+ const intent = {
106
+ kind: "rfq-intent",
107
+ version: 1,
108
+ fromChainId,
109
+ toChainId,
110
+ sellToken: token(input.sellToken ?? input.tokenIn ?? input.fromToken, "sellToken"),
111
+ buyToken: token(input.buyToken ?? input.tokenOut ?? input.toToken, "buyToken"),
112
+ sellAmount: amount(input.sellAmount ?? input.amountIn ?? input.fromAmount, "sellAmount"),
113
+ receiver: address(input.receiver ?? input.recipient ?? input.to, "receiver"),
114
+ deadlineMs: deadline(input.deadlineMs ?? input.deadline ?? input.expiresAtMs, { nowMs, maxDeadlineMs }, "deadline"),
115
+ minBuyAmount: optionalAmount(input.minBuyAmount ?? input.minOut ?? input.amountOutMin, "minBuyAmount"),
116
+ allowedSources: stringList(input.allowedSources, "allowedSources", defaults),
117
+ allowedRouters: stringList(input.allowedRouters, "allowedRouters", []),
118
+ slippageBps: Number.isFinite(Number(input.slippageBps)) ? Number(input.slippageBps) : null,
119
+ partialFill: input.partialFill === true,
120
+ metadata: input.metadata && typeof input.metadata === "object" && !Array.isArray(input.metadata) ? plain(input.metadata) : {},
121
+ };
122
+ if (intent.slippageBps != null && (!Number.isInteger(intent.slippageBps) || intent.slippageBps < 0 || intent.slippageBps > 10_000)) fail("slippageBps", "must be 0 to 10000");
123
+ intent.intentId = input.intentId ? String(input.intentId) : sha256Hex({ seed: "rfq-intent", intent }).slice(0, 34);
124
+ intent.intentHash = hashRfqIntent(intent);
125
+ return Object.freeze(intent);
126
+ }
127
+
128
+ function artifact(value) {
129
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("artifact", "required");
130
+ if (value.typedDataHash != null && !HEX32.test(String(value.typedDataHash))) fail("artifact.typedDataHash", "must be bytes32");
131
+ if (value.calldataHash != null && !HEX32.test(String(value.calldataHash))) fail("artifact.calldataHash", "must be bytes32");
132
+ if (value.to != null && !isAddress(String(value.to))) fail("artifact.to", "must be an address");
133
+ if (value.data != null && !/^0x(?:[0-9a-fA-F]{2})*$/.test(String(value.data))) fail("artifact.data", "must be hex");
134
+ return plain(value);
135
+ }
136
+
137
+ export function normalizeFirmQuote(intent, quote = {}, opts = {}) {
138
+ if (!intent || intent.kind !== "rfq-intent") fail("intent", "required");
139
+ const nowMs = Number(opts.nowMs ?? Date.now());
140
+ const expiryMs = Number(quote.expiryMs ?? quote.expiresAtMs ?? quote.validUntilMs);
141
+ if (!Number.isFinite(expiryMs)) fail("quote expiry", "required");
142
+ if (expiryMs <= nowMs) fail("quote", "expired");
143
+ if (expiryMs > intent.deadlineMs) fail("quote expiry", "exceeds intent deadline");
144
+ const source = String(quote.source ?? quote.provider ?? "").trim();
145
+ if (!source) fail("source", "required");
146
+ if (!intent.allowedSources.includes(source)) fail("source", "not allowed by intent");
147
+ const amountOut = amount(quote.amountOut ?? quote.buyAmount, "amountOut");
148
+ const minBuyAmount = amount(quote.minBuyAmount ?? quote.minOut ?? amountOut, "minBuyAmount");
149
+ if (BigInt(minBuyAmount) > BigInt(amountOut)) fail("minBuyAmount", "exceeds amountOut");
150
+ const artifactValue = artifact(quote.artifact ?? quote.executableArtifact ?? quote.transaction ?? quote.typedData);
151
+ const quotedAtMs = Number(quote.quotedAtMs ?? quote.timestampMs ?? opts.nowMs ?? Date.now());
152
+ if (!Number.isFinite(quotedAtMs)) fail("quotedAtMs", "must be finite");
153
+ const router = optionalAddress(quote.router ?? quote.settlementRouter ?? artifactValue.to, "router");
154
+ const out = {
155
+ kind: "rfq-firm-quote",
156
+ provider: "rfq",
157
+ surface: "rfq",
158
+ version: 1,
159
+ quoteId: String(quote.quoteId ?? quote.id ?? "").trim(),
160
+ source,
161
+ intentHash: intent.intentHash,
162
+ fromChainId: intent.fromChainId,
163
+ toChainId: intent.toChainId,
164
+ sellToken: intent.sellToken,
165
+ buyToken: intent.buyToken,
166
+ sellAmount: intent.sellAmount,
167
+ receiver: intent.receiver,
168
+ router,
169
+ amountOut,
170
+ minBuyAmount,
171
+ quotedAtMs: Math.floor(quotedAtMs),
172
+ expiryMs: Math.floor(expiryMs),
173
+ artifact: artifactValue,
174
+ maker: quote.maker ? String(quote.maker) : null,
175
+ metadata: quote.metadata && typeof quote.metadata === "object" && !Array.isArray(quote.metadata) ? plain(quote.metadata) : {},
176
+ };
177
+ if (!out.quoteId) out.quoteId = sha256Hex({ seed: "rfq-quote", out }).slice(0, 34);
178
+ out.firmQuoteHash = hashFirmQuote(out);
179
+ return Object.freeze(out);
180
+ }
@@ -0,0 +1,181 @@
1
+ import { lifiQuote } from "../data/providers/lifi.mjs";
2
+ import { paraswapPrice } from "../data/providers/paraswap.mjs";
3
+ import { zeroxQuote } from "../data/providers/zerox.mjs";
4
+ import { cowQuote } from "../data/providers/cowswap.mjs";
5
+ import { uniV3QuoteExactIn, UNI_V3_CHAINS } from "../data/providers/uniswap-v3.mjs";
6
+ import { normalizeFirmQuote } from "./intent.mjs";
7
+
8
+ function allowed(intent, source) {
9
+ return Array.isArray(intent?.allowedSources) && intent.allowedSources.includes(source);
10
+ }
11
+
12
+ function envValue(env, key) {
13
+ return String(env?.[key] ?? "").trim();
14
+ }
15
+
16
+ function candidate(source, run) {
17
+ return Object.freeze({ source, run });
18
+ }
19
+
20
+ export function sourceCandidates(intent, opts = {}) {
21
+ if (!intent || intent.kind !== "rfq-intent") throw new Error("rfq: intent required");
22
+ const env = opts.env ?? process.env;
23
+ const providers = opts.providers ?? {};
24
+ const uniChains = opts.supportedUniV3Chains ?? UNI_V3_CHAINS;
25
+ const sameChain = Number(intent.fromChainId) === Number(intent.toChainId);
26
+ const out = [];
27
+ if (allowed(intent, "lifi")) {
28
+ out.push(candidate("lifi", async () => {
29
+ const fn = providers.lifiQuote ?? lifiQuote;
30
+ const q = await fn({
31
+ fromChain: intent.fromChainId,
32
+ toChain: intent.toChainId,
33
+ fromToken: intent.sellToken,
34
+ toToken: intent.buyToken,
35
+ fromAmount: intent.sellAmount,
36
+ fromAddress: intent.receiver,
37
+ }, opts);
38
+ const est = q?.estimate ?? q?.[0]?.estimate ?? q;
39
+ return {
40
+ quoteId: q?.id ?? q?.routeId ?? null,
41
+ amountOut: est?.toAmount ?? q?.toAmount,
42
+ minBuyAmount: est?.toAmountMin ?? q?.toAmountMin ?? est?.toAmount ?? q?.toAmount,
43
+ expiryMs: Date.now() + Number(opts.defaultQuoteTtlMs ?? 20_000),
44
+ artifact: { type: "lifi-route", calldataHash: q?.transactionRequest?.data ? await hashMaybe(opts, q.transactionRequest.data) : undefined, route: q },
45
+ metadata: { tool: q?.tool ?? q?.toolDetails?.name ?? null },
46
+ };
47
+ }));
48
+ }
49
+ if (!sameChain) return out;
50
+ if (allowed(intent, "paraswap")) {
51
+ out.push(candidate("paraswap", async () => {
52
+ const fn = providers.paraswapPrice ?? paraswapPrice;
53
+ const q = await fn({
54
+ chainId: intent.fromChainId,
55
+ srcToken: intent.sellToken,
56
+ destToken: intent.buyToken,
57
+ amount: intent.sellAmount,
58
+ srcDecimals: opts.decimalsIn,
59
+ destDecimals: opts.decimalsOut,
60
+ }, opts);
61
+ const pr = q?.priceRoute ?? q;
62
+ return {
63
+ quoteId: pr?.id ?? null,
64
+ amountOut: pr?.destAmount,
65
+ minBuyAmount: q?.amountOutMinimum ?? pr?.destAmount,
66
+ expiryMs: Date.now() + Number(opts.defaultQuoteTtlMs ?? 20_000),
67
+ artifact: { type: "paraswap-price-route", route: pr },
68
+ metadata: { contractAddress: pr?.contractAddress ?? null },
69
+ };
70
+ }));
71
+ }
72
+ if (allowed(intent, "0x") && (envValue(env, "ZEROX_API_KEY") || envValue(env, "0X_API_KEY") || providers.zeroxQuote)) {
73
+ out.push(candidate("0x", async () => {
74
+ const fn = providers.zeroxQuote ?? zeroxQuote;
75
+ const q = await fn({ chainId: intent.fromChainId, sellToken: intent.sellToken, buyToken: intent.buyToken, sellAmount: intent.sellAmount, taker: intent.receiver }, opts);
76
+ return {
77
+ quoteId: q?.id ?? null,
78
+ amountOut: q?.buyAmount ?? q?.grossBuyAmount,
79
+ minBuyAmount: q?.minBuyAmount ?? q?.buyAmount,
80
+ expiryMs: Date.now() + Number(opts.defaultQuoteTtlMs ?? 20_000),
81
+ artifact: { type: "0x-transaction", to: q?.transaction?.to, data: q?.transaction?.data, value: q?.transaction?.value ?? "0" },
82
+ metadata: { route: q?.route ?? null },
83
+ };
84
+ }));
85
+ }
86
+ if (allowed(intent, "cow")) {
87
+ out.push(candidate("cow", async () => {
88
+ const fn = providers.cowQuote ?? cowQuote;
89
+ const q = await fn({ chainId: intent.fromChainId, sellToken: intent.sellToken, buyToken: intent.buyToken, sellAmountBeforeFee: intent.sellAmount, from: intent.receiver, signingScheme: "eip712" }, opts);
90
+ const quote = q?.quote ?? q;
91
+ return {
92
+ quoteId: q?.id ?? null,
93
+ amountOut: quote?.buyAmount,
94
+ minBuyAmount: quote?.buyAmount,
95
+ expiryMs: Math.min(Number(quote?.validTo ?? 0) * 1000 || Date.now() + Number(opts.defaultQuoteTtlMs ?? 20_000), intent.deadlineMs),
96
+ artifact: { type: "cow-order", order: quote },
97
+ metadata: { solver: true },
98
+ };
99
+ }));
100
+ }
101
+ if (allowed(intent, "uniswap-v3") && uniChains[Number(intent.fromChainId)]) {
102
+ out.push(candidate("uniswap-v3", async () => {
103
+ const fn = providers.uniV3QuoteExactIn ?? uniV3QuoteExactIn;
104
+ const q = await fn({ chainId: intent.fromChainId, tokenIn: intent.sellToken, tokenOut: intent.buyToken, amountIn: intent.sellAmount }, opts);
105
+ return {
106
+ quoteId: q?.id ?? null,
107
+ amountOut: q?.amountOut,
108
+ minBuyAmount: q?.minOut ?? q?.amountOutMinimum ?? q?.amountOut,
109
+ expiryMs: Date.now() + Number(opts.defaultQuoteTtlMs ?? 20_000),
110
+ artifact: { type: "uniswap-v3-quote", quoter: q?.quoter, fee: q?.fee },
111
+ metadata: { onchain: true },
112
+ };
113
+ }));
114
+ }
115
+ return out;
116
+ }
117
+
118
+ async function hashMaybe(opts, value) {
119
+ const crypto = await import("node:crypto");
120
+ return `0x${crypto.createHash("sha256").update(String(value)).digest("hex")}`;
121
+ }
122
+
123
+ async function runWithTimeout(c, timeoutMs) {
124
+ return Promise.race([
125
+ Promise.resolve().then(c.run),
126
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${c.source}: timeout`)), timeoutMs)),
127
+ ]);
128
+ }
129
+
130
+ export async function executeRfqCandidates(intent, candidates, opts = {}) {
131
+ const timeoutMs = Number(opts.timeoutMs ?? 12_000);
132
+ const settled = await Promise.allSettled(candidates.map((c) => runWithTimeout(c, timeoutMs)));
133
+ const quotes = [];
134
+ const failed = [];
135
+ for (let i = 0; i < settled.length; i++) {
136
+ const c = candidates[i];
137
+ const r = settled[i];
138
+ if (r.status === "rejected") {
139
+ failed.push({ source: c.source, error: String(r.reason?.message || r.reason) });
140
+ continue;
141
+ }
142
+ try {
143
+ quotes.push(normalizeFirmQuote(intent, { ...r.value, source: c.source }, opts));
144
+ } catch (e) {
145
+ failed.push({ source: c.source, error: String(e?.message || e) });
146
+ }
147
+ }
148
+ return { quotes, failed };
149
+ }
150
+
151
+ export function rankRfqQuotes(quotes = [], opts = {}) {
152
+ const nowMs = Number(opts.nowMs ?? Date.now());
153
+ const usable = quotes.filter((q) => Number(q.expiryMs ?? 0) > nowMs);
154
+ usable.sort((a, b) => {
155
+ const floor = BigInt(b.minBuyAmount ?? 0) - BigInt(a.minBuyAmount ?? 0);
156
+ if (floor !== 0n) return floor > 0n ? 1 : -1;
157
+ const gross = BigInt(b.amountOut ?? 0) - BigInt(a.amountOut ?? 0);
158
+ return gross > 0n ? 1 : gross < 0n ? -1 : 0;
159
+ });
160
+ const ranked = usable.map((q) => ({ ...q, scoreBasis: "minBuyAmount" }));
161
+ const warnings = [];
162
+ if (!ranked.length) warnings.push("no RFQ source returned a usable firm quote");
163
+ if (quotes.length !== ranked.length) warnings.push("expired RFQ quotes were excluded from ranking");
164
+ return { best: ranked[0] ?? null, quotes: ranked, failed: [], warnings };
165
+ }
166
+
167
+ export async function requestRfqQuotes(intent, opts = {}) {
168
+ const candidates = sourceCandidates(intent, opts);
169
+ const { quotes, failed } = await executeRfqCandidates(intent, candidates, opts);
170
+ const ranked = rankRfqQuotes(quotes, opts);
171
+ return {
172
+ kind: "rfq-result",
173
+ intent,
174
+ best: ranked.best,
175
+ quotes: ranked.quotes,
176
+ failed,
177
+ warnings: [...ranked.warnings],
178
+ sourcesTried: candidates.length,
179
+ sourcesAnswered: quotes.length,
180
+ };
181
+ }
@@ -1,9 +0,0 @@
1
- # safe-erc20
2
-
3
- ```bash
4
- forge install foundry-rs/forge-std --no-git
5
- forge install OpenZeppelin/openzeppelin-contracts@v5.0.2 --no-git
6
- forge test
7
- ```
8
-
9
- Libs are not vendored. Gate auto-installs when missing.