@oracle-agent/oracle 0.3.4 → 0.3.5
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/bin/oracle-data-mcp.mjs +70 -0
- package/package.json +1 -1
- package/src/cards.mjs +369 -0
- package/src/scanner/chains.config.mjs +48 -1
package/bin/oracle-data-mcp.mjs
CHANGED
|
@@ -304,6 +304,43 @@ const tools = [
|
|
|
304
304
|
properties: { collectionIds: { type: "string" }, limit: { type: "number" } },
|
|
305
305
|
},
|
|
306
306
|
},
|
|
307
|
+
{
|
|
308
|
+
name: "scanner_coverage",
|
|
309
|
+
description:
|
|
310
|
+
"Which chains the on-chain scanner supports and which capabilities each one has " +
|
|
311
|
+
"(blockNumber, nativeBalance, tokenBalance, resolveToken, resolvePools, scanBlocks, " +
|
|
312
|
+
"scoreRisk, quote, sellSimulation, prepareUnsignedTx). Read-only.",
|
|
313
|
+
inputSchema: { type: "object", properties: {} },
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "scan_token",
|
|
317
|
+
description:
|
|
318
|
+
"Resolve a token on an EVM chain: metadata, its pools, and a risk score. Works on " +
|
|
319
|
+
"chains no indexer covers because it reads the chain directly. Read-only.",
|
|
320
|
+
inputSchema: {
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: {
|
|
323
|
+
chainId: { type: "number", description: "EVM chain id, e.g. 4663 for Robinhood Chain" },
|
|
324
|
+
token: { type: "string", description: "token contract address" },
|
|
325
|
+
},
|
|
326
|
+
required: ["chainId", "token"],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "wallet_balances",
|
|
331
|
+
description:
|
|
332
|
+
"Native balance for an address on an EVM chain, plus one token balance when a token " +
|
|
333
|
+
"address is supplied. Read-only; never signs.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
chainId: { type: "number" },
|
|
338
|
+
address: { type: "string", description: "wallet address to inspect" },
|
|
339
|
+
token: { type: "string", description: "optional token contract for a token balance" },
|
|
340
|
+
},
|
|
341
|
+
required: ["chainId", "address"],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
307
344
|
{
|
|
308
345
|
name: "best_swap_route",
|
|
309
346
|
description:
|
|
@@ -531,6 +568,39 @@ async function callTool(name, args = {}) {
|
|
|
531
568
|
const { prepareBestBridgeRoute } = await import("../src/router/prepare-bridge.mjs");
|
|
532
569
|
return prepareBestBridgeRoute(args);
|
|
533
570
|
}
|
|
571
|
+
// Smart-wallet / token scanner surface.
|
|
572
|
+
//
|
|
573
|
+
// The scanner engine already covered 11 chains and every capability below, but no
|
|
574
|
+
// MCP tool exposed it -- so an agent could scan nothing on EVM while the code sat
|
|
575
|
+
// there working. Same class of gap as a route source with no preparer: the
|
|
576
|
+
// capability existed, the handle did not.
|
|
577
|
+
//
|
|
578
|
+
// In-process for the same reason routing is: no desk server required.
|
|
579
|
+
// Read-only. scoreRisk and sellSimulation inspect; they never sign.
|
|
580
|
+
if (name === "scanner_coverage") {
|
|
581
|
+
const s = await import("../src/scanner/index.mjs");
|
|
582
|
+
s.registerBuiltinScanners?.();
|
|
583
|
+
return s.scannerCoverage();
|
|
584
|
+
}
|
|
585
|
+
if (name === "scan_token") {
|
|
586
|
+
const s = await import("../src/scanner/index.mjs");
|
|
587
|
+
s.registerBuiltinScanners?.();
|
|
588
|
+
const sc = s.getScanner(Number(args.chainId));
|
|
589
|
+
if (!sc) throw new Error(`no scanner registered for chainId ${args.chainId}`);
|
|
590
|
+
const token = await sc.resolveToken(args.token);
|
|
591
|
+
const pools = sc.supports?.("resolvePools") ? await sc.resolvePools(args.token).catch(() => null) : null;
|
|
592
|
+
const risk = sc.supports?.("scoreRisk") ? await sc.scoreRisk({ token: args.token }).catch(() => null) : null;
|
|
593
|
+
return { chainId: Number(args.chainId), token, pools, risk };
|
|
594
|
+
}
|
|
595
|
+
if (name === "wallet_balances") {
|
|
596
|
+
const s = await import("../src/scanner/index.mjs");
|
|
597
|
+
s.registerBuiltinScanners?.();
|
|
598
|
+
const sc = s.getScanner(Number(args.chainId));
|
|
599
|
+
if (!sc) throw new Error(`no scanner registered for chainId ${args.chainId}`);
|
|
600
|
+
const native = await sc.nativeBalance(args.address);
|
|
601
|
+
const token = args.token ? await sc.tokenBalance(args.address, args.token).catch(() => null) : null;
|
|
602
|
+
return { chainId: Number(args.chainId), address: args.address, native, token };
|
|
603
|
+
}
|
|
534
604
|
if (name === "data_catalog") return httpJson(`${DATA_URL}/data/catalog`);
|
|
535
605
|
if (name === "data_health") return httpJson(`${DATA_URL}/data/health`);
|
|
536
606
|
if (name === "data_call") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oracle-agent/oracle",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Oracle: prepare-only multichain agent control plane. Policy-bounded intents for a user-signed wallet. Self-custody by default — the public package never takes your key. Built for Hermes; no model key required.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/cards.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Telegram card TEXT renderers.
|
|
2
|
+
//
|
|
3
|
+
// Implements skills/oracle-chain-graphs-telegram-cards/SKILL.md. That spec has
|
|
4
|
+
// existed with no code behind it, so Oracle users were getting raw JSON instead
|
|
5
|
+
// of readable alert cards. This module is the text half only: chart/image
|
|
6
|
+
// rendering lives elsewhere and MUST NOT be required for a card to send.
|
|
7
|
+
//
|
|
8
|
+
// Contract for every exported renderer:
|
|
9
|
+
// - pure, synchronous, string in / string out
|
|
10
|
+
// - NO network, NO signing, NO filesystem, NO env reads, NO mutation of input
|
|
11
|
+
// - unknown data renders as the literal string UNKNOWN — never blank, never
|
|
12
|
+
// guessed, never interpolated from a neighbouring field
|
|
13
|
+
// - the only identifier (contract address / mint / market id) is NEVER
|
|
14
|
+
// truncated: a half-address is worse than no address because it still looks
|
|
15
|
+
// actionable
|
|
16
|
+
// - chart failure is cosmetic: the text card always returns
|
|
17
|
+
// - buy/sell affordances only when a valid local grant/session is supplied
|
|
18
|
+
//
|
|
19
|
+
// Markdown dialect: Telegram *legacy* Markdown. Values are escaped for `_`,
|
|
20
|
+
// `*`, `[`, `]` and backtick; card chrome supplies its own markers. We never
|
|
21
|
+
// emit `$` at all (the spec calls out repeated `$` spans as a formatting trap)
|
|
22
|
+
// — amounts are suffixed with USD instead.
|
|
23
|
+
|
|
24
|
+
import { chainById } from "./chains.mjs";
|
|
25
|
+
|
|
26
|
+
/** The one and only stand-in for missing data. */
|
|
27
|
+
export const UNKNOWN = "UNKNOWN";
|
|
28
|
+
|
|
29
|
+
export const CARD_KINDS = Object.freeze(["token", "launch", "hip3", "hip4", "polymarket"]);
|
|
30
|
+
|
|
31
|
+
const LEGACY_MD_SPECIALS = /[_*[\]`]/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escape Telegram legacy-Markdown control characters in a *value*.
|
|
35
|
+
* Card chrome (bold headers) is written unescaped by the renderers themselves.
|
|
36
|
+
* @param {unknown} value
|
|
37
|
+
* @returns {string} escaped text, or UNKNOWN when there is nothing to show
|
|
38
|
+
*/
|
|
39
|
+
export function escapeMd(value) {
|
|
40
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
41
|
+
return String(value).replace(LEGACY_MD_SPECIALS, (c) => `\\${c}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPresent(value) {
|
|
45
|
+
if (value === null || value === undefined) return false;
|
|
46
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
47
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A value that must survive verbatim (addresses, mints, market ids). */
|
|
52
|
+
function code(value) {
|
|
53
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
54
|
+
// Backticks would close the span; strip rather than truncate the identifier.
|
|
55
|
+
const raw = String(value).replace(/`/g, "");
|
|
56
|
+
return raw === "" ? UNKNOWN : `\`${raw}\``;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function num(value, { decimals = 2 } = {}) {
|
|
60
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
61
|
+
const n = typeof value === "bigint" ? Number(value) : Number(value);
|
|
62
|
+
if (!Number.isFinite(n)) return UNKNOWN;
|
|
63
|
+
return n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: decimals });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Money. Deliberately no `$` — repeated dollar spans break Telegram parsing. */
|
|
67
|
+
function usd(value, { decimals = 2 } = {}) {
|
|
68
|
+
const n = num(value, { decimals });
|
|
69
|
+
return n === UNKNOWN ? UNKNOWN : `${n} USD`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pct(value, { decimals = 2 } = {}) {
|
|
73
|
+
const n = num(value, { decimals });
|
|
74
|
+
return n === UNKNOWN ? UNKNOWN : `${n}%`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bps(value) {
|
|
78
|
+
const n = num(value, { decimals: 0 });
|
|
79
|
+
return n === UNKNOWN ? UNKNOWN : `${n} bps`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Exact base-unit -> decimal string. BigInt only: a raw quote like
|
|
84
|
+
* 24325001237995579150138 loses precision the moment it touches a float.
|
|
85
|
+
*/
|
|
86
|
+
export function formatUnits(raw, decimals) {
|
|
87
|
+
if (!isPresent(raw)) return UNKNOWN;
|
|
88
|
+
const d = Number(decimals);
|
|
89
|
+
if (!Number.isInteger(d) || d < 0 || d > 77) return UNKNOWN;
|
|
90
|
+
let value;
|
|
91
|
+
try {
|
|
92
|
+
value = BigInt(typeof raw === "string" ? raw.trim() : raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return UNKNOWN;
|
|
95
|
+
}
|
|
96
|
+
const neg = value < 0n;
|
|
97
|
+
const abs = neg ? -value : value;
|
|
98
|
+
const base = 10n ** BigInt(d);
|
|
99
|
+
const whole = (abs / base).toString();
|
|
100
|
+
const frac = (abs % base).toString().padStart(d, "0").replace(/0+$/, "");
|
|
101
|
+
return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Normalize confidence to a stated band. Every card must state one. */
|
|
105
|
+
export function normalizeConfidence(value) {
|
|
106
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
107
|
+
if (value < 0 || value > 1) return UNKNOWN;
|
|
108
|
+
if (value >= 0.75) return "HIGH";
|
|
109
|
+
if (value >= 0.4) return "MEDIUM";
|
|
110
|
+
return "LOW";
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
const v = value.trim().toUpperCase();
|
|
114
|
+
if (v === "HIGH" || v === "MEDIUM" || v === "LOW") return v;
|
|
115
|
+
}
|
|
116
|
+
return UNKNOWN;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function chainLine(data = {}) {
|
|
120
|
+
const id = data.chainId;
|
|
121
|
+
if (!isPresent(id) || !Number.isFinite(Number(id))) {
|
|
122
|
+
return isPresent(data.chain) ? escapeMd(data.chain) : UNKNOWN;
|
|
123
|
+
}
|
|
124
|
+
const known = chainById(id);
|
|
125
|
+
const name = known?.name || (isPresent(data.chain) ? String(data.chain) : UNKNOWN);
|
|
126
|
+
return `${escapeMd(name)} (chainId ${Number(id)})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function venueLine(data = {}) {
|
|
130
|
+
return escapeMd(data.venue ?? data.dex ?? data.pool?.venue);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Chart status. A graph is evidence, not a dependency — if the image failed we
|
|
135
|
+
* say so and the text card still stands.
|
|
136
|
+
*/
|
|
137
|
+
function chartLine(chart) {
|
|
138
|
+
if (chart === null || chart === undefined) return "NONE (text card only)";
|
|
139
|
+
if (typeof chart === "string") return chart.trim() ? code(chart) : "NONE (text card only)";
|
|
140
|
+
if (chart.error || chart.ok === false || chart.available === false) {
|
|
141
|
+
const why = isPresent(chart.error) ? ` — ${escapeMd(chart.error)}` : "";
|
|
142
|
+
return `UNAVAILABLE${why} (text card stands)`;
|
|
143
|
+
}
|
|
144
|
+
if (isPresent(chart.url)) return code(chart.url);
|
|
145
|
+
return "NONE (text card only)";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Buy/sell affordances are gated on a valid LOCAL grant or session. Absent or
|
|
150
|
+
* expired grant => prepare-only. This function is the single gate; renderers
|
|
151
|
+
* never decide on their own.
|
|
152
|
+
* @returns {{ allowed: boolean, reason: string, actions: string[] }}
|
|
153
|
+
*/
|
|
154
|
+
export function cardActions(data = {}, { now = Date.now() } = {}) {
|
|
155
|
+
const grant = data.grant ?? data.session ?? null;
|
|
156
|
+
if (!grant || typeof grant !== "object") {
|
|
157
|
+
return { allowed: false, reason: "no local grant/session", actions: [] };
|
|
158
|
+
}
|
|
159
|
+
if (grant.local === false) {
|
|
160
|
+
return { allowed: false, reason: "grant is not local", actions: [] };
|
|
161
|
+
}
|
|
162
|
+
if (grant.revoked === true) {
|
|
163
|
+
return { allowed: false, reason: "grant revoked", actions: [] };
|
|
164
|
+
}
|
|
165
|
+
const expiry = grant.expiresAt ?? grant.expiry;
|
|
166
|
+
if (isPresent(expiry)) {
|
|
167
|
+
const at = typeof expiry === "number" ? expiry : Date.parse(expiry);
|
|
168
|
+
if (!Number.isFinite(at)) return { allowed: false, reason: "grant expiry unreadable", actions: [] };
|
|
169
|
+
if (at <= now) return { allowed: false, reason: "grant expired", actions: [] };
|
|
170
|
+
}
|
|
171
|
+
const actions = Array.isArray(grant.actions) && grant.actions.length ? grant.actions.slice() : ["BUY", "SELL"];
|
|
172
|
+
return { allowed: true, reason: "valid local grant", actions };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function actionsLine(data) {
|
|
176
|
+
const gate = cardActions(data);
|
|
177
|
+
return gate.allowed
|
|
178
|
+
? `${gate.actions.map((a) => escapeMd(a)).join(" / ")} (${escapeMd(gate.reason)})`
|
|
179
|
+
: `prepare-only — ${escapeMd(gate.reason)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function build(title, rows, data = {}) {
|
|
183
|
+
const lines = [`*${title}*`];
|
|
184
|
+
for (const [label, value] of rows) {
|
|
185
|
+
// Labels are authored here and contain no markdown specials by construction.
|
|
186
|
+
lines.push(`${label}: ${value === undefined || value === null || value === "" ? UNKNOWN : value}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push(`Chart: ${chartLine(data.chart)}`);
|
|
189
|
+
lines.push(`Actions: ${actionsLine(data)}`);
|
|
190
|
+
if (Array.isArray(data.warnings) && data.warnings.length) {
|
|
191
|
+
lines.push(`Warnings: ${data.warnings.map((w) => escapeMd(w)).join("; ")}`);
|
|
192
|
+
}
|
|
193
|
+
if (isPresent(data.source) || isPresent(data.fetchedAt)) {
|
|
194
|
+
lines.push(`Source: ${escapeMd(data.source)} at ${escapeMd(data.fetchedAt)}`);
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function confidenceRow(data) {
|
|
200
|
+
return ["Confidence", normalizeConfidence(data.confidence)];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function quoteRows(data) {
|
|
204
|
+
const q = data.quote;
|
|
205
|
+
if (!q || typeof q !== "object") return [];
|
|
206
|
+
const human = isPresent(q.decimals) ? formatUnits(q.amountOutRaw ?? q.out ?? q.raw, q.decimals) : UNKNOWN;
|
|
207
|
+
return [
|
|
208
|
+
["Quote out (raw)", code(q.amountOutRaw ?? q.out ?? q.raw)],
|
|
209
|
+
["Quote out", human === UNKNOWN ? UNKNOWN : `${escapeMd(human)} ${escapeMd(q.symbol ?? "")}`.trim()],
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function slippageRow(data) {
|
|
214
|
+
const s = data.autoSlippage;
|
|
215
|
+
if (!s || typeof s !== "object") return ["Auto-slippage", UNKNOWN];
|
|
216
|
+
const sel = bps(s.selectedBps);
|
|
217
|
+
const cap = bps(s.capBps);
|
|
218
|
+
return ["Auto-slippage", sel === UNKNOWN && cap === UNKNOWN ? UNKNOWN : `${sel} selected, cap ${cap}`];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Per-chain token card: price, volume, liquidity, market cap, age, venue. */
|
|
222
|
+
export function renderTokenCard(data = {}) {
|
|
223
|
+
const d = data || {};
|
|
224
|
+
return build(
|
|
225
|
+
`ORACLE TOKEN — ${escapeMd(d.symbol ?? d.token ?? d.name)}`,
|
|
226
|
+
[
|
|
227
|
+
["Chain", chainLine(d)],
|
|
228
|
+
["Venue", venueLine(d)],
|
|
229
|
+
["Name", escapeMd(d.name)],
|
|
230
|
+
["Address", code(d.address ?? d.mint ?? d.contract)],
|
|
231
|
+
["Price", usd(d.priceUsd, { decimals: 8 })],
|
|
232
|
+
["Market cap", usd(d.marketCapUsd)],
|
|
233
|
+
["Liquidity", usd(d.liquidityUsd)],
|
|
234
|
+
["Volume 24h", usd(d.volume24hUsd)],
|
|
235
|
+
["Change 24h", pct(d.priceChange24h)],
|
|
236
|
+
["Fee tier", isPresent(d.feeTier) ? `${bps(Number(d.feeTier) / 100)} (${num(d.feeTier, { decimals: 0 })})` : UNKNOWN],
|
|
237
|
+
["Age", escapeMd(d.age)],
|
|
238
|
+
...quoteRows(d),
|
|
239
|
+
slippageRow(d),
|
|
240
|
+
confidenceRow(d),
|
|
241
|
+
],
|
|
242
|
+
d,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Launch/sniper card: route readiness, sellability, overlap, risk, ticket. */
|
|
247
|
+
export function renderLaunchCard(data = {}) {
|
|
248
|
+
const d = data || {};
|
|
249
|
+
return build(
|
|
250
|
+
`ORACLE LAUNCH — ${escapeMd(d.symbol ?? d.token ?? d.name)}`,
|
|
251
|
+
[
|
|
252
|
+
["Chain", chainLine(d)],
|
|
253
|
+
["Venue", venueLine(d)],
|
|
254
|
+
["Address", code(d.address ?? d.mint ?? d.contract)],
|
|
255
|
+
["Pool", code(d.pool?.address ?? d.poolAddress ?? d.pool)],
|
|
256
|
+
["Liquidity", usd(d.liquidityUsd)],
|
|
257
|
+
["Route ready", escapeMd(d.routeReady)],
|
|
258
|
+
["Sellable", escapeMd(d.sellable)],
|
|
259
|
+
["Sell sim", escapeMd(d.sellSimulation ?? d.sellSim)],
|
|
260
|
+
["Smart wallets", escapeMd(d.smartWalletOverlap)],
|
|
261
|
+
["Risk", escapeMd(d.risk ?? d.riskStatus)],
|
|
262
|
+
["Prepared ticket", escapeMd(d.preparedTicket ?? d.ticketStatus)],
|
|
263
|
+
slippageRow(d),
|
|
264
|
+
confidenceRow(d),
|
|
265
|
+
],
|
|
266
|
+
d,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Hyperliquid HIP-3 builder-dex card. Requires perpDexs + metaAndAssetCtxs. */
|
|
271
|
+
export function renderHip3Card(data = {}) {
|
|
272
|
+
const d = data || {};
|
|
273
|
+
return build(
|
|
274
|
+
`ORACLE HIP-3 — ${escapeMd(d.market ?? d.coin ?? d.name)}`,
|
|
275
|
+
[
|
|
276
|
+
["Venue", `Hyperliquid builder-dex ${escapeMd(d.dex)}`],
|
|
277
|
+
["Market", escapeMd(d.market ?? d.coin)],
|
|
278
|
+
["Mark", usd(d.markPx, { decimals: 6 })],
|
|
279
|
+
["Oracle", usd(d.oraclePx, { decimals: 6 })],
|
|
280
|
+
["Funding", pct(d.funding, { decimals: 6 })],
|
|
281
|
+
["Open interest", usd(d.openInterestUsd)],
|
|
282
|
+
["Depth", escapeMd(d.depth)],
|
|
283
|
+
["Liquidation notes", escapeMd(d.liquidationNotes ?? d.riskNotes)],
|
|
284
|
+
["Account context", escapeMd(d.accountContext)],
|
|
285
|
+
confidenceRow(d),
|
|
286
|
+
],
|
|
287
|
+
d,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Hyperliquid HIP-4 outcome-market card. Public reads are keyless. */
|
|
292
|
+
export function renderHip4Card(data = {}) {
|
|
293
|
+
const d = data || {};
|
|
294
|
+
return build(
|
|
295
|
+
`ORACLE HIP-4 — ${escapeMd(d.event ?? d.market ?? d.name)}`,
|
|
296
|
+
[
|
|
297
|
+
["Venue", `Hyperliquid HIP-4 ${escapeMd(d.dex ?? "outcome")}`],
|
|
298
|
+
["Event", escapeMd(d.event)],
|
|
299
|
+
["Outcome", escapeMd(d.outcome)],
|
|
300
|
+
["Market id", code(d.marketId ?? d.market)],
|
|
301
|
+
["Bid", usd(d.bid, { decimals: 6 })],
|
|
302
|
+
["Ask", usd(d.ask, { decimals: 6 })],
|
|
303
|
+
["Depth", escapeMd(d.depth)],
|
|
304
|
+
["Edge", pct(d.edge)],
|
|
305
|
+
["Position", escapeMd(d.position)],
|
|
306
|
+
confidenceRow(d),
|
|
307
|
+
],
|
|
308
|
+
d,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Polymarket card. Public reads keyless; orders stay prepared/user-signed. */
|
|
313
|
+
export function renderPolymarketCard(data = {}) {
|
|
314
|
+
const d = data || {};
|
|
315
|
+
return build(
|
|
316
|
+
`ORACLE POLYMARKET — ${escapeMd(d.event ?? d.market ?? d.name)}`,
|
|
317
|
+
[
|
|
318
|
+
["Venue", "Polymarket CLOB"],
|
|
319
|
+
["Event", escapeMd(d.event)],
|
|
320
|
+
["Market", escapeMd(d.market)],
|
|
321
|
+
["Market id", code(d.marketId ?? d.conditionId ?? d.tokenId)],
|
|
322
|
+
["Yes", usd(d.yesPrice, { decimals: 4 })],
|
|
323
|
+
["No", usd(d.noPrice, { decimals: 4 })],
|
|
324
|
+
["Best bid / ask", `${usd(d.bestBid, { decimals: 4 })} / ${usd(d.bestAsk, { decimals: 4 })}`],
|
|
325
|
+
["Volume", usd(d.volumeUsd)],
|
|
326
|
+
["Resolution risk", escapeMd(d.resolutionRisk)],
|
|
327
|
+
["Order intent", escapeMd(d.orderIntent)],
|
|
328
|
+
confidenceRow(d),
|
|
329
|
+
],
|
|
330
|
+
d,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const RENDERERS = Object.freeze({
|
|
335
|
+
token: renderTokenCard,
|
|
336
|
+
launch: renderLaunchCard,
|
|
337
|
+
hip3: renderHip3Card,
|
|
338
|
+
hip4: renderHip4Card,
|
|
339
|
+
polymarket: renderPolymarketCard,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Dispatch by surface kind.
|
|
344
|
+
* @param {"token"|"launch"|"hip3"|"hip4"|"polymarket"} kind
|
|
345
|
+
*/
|
|
346
|
+
export function renderCard(kind, data = {}) {
|
|
347
|
+
const fn = RENDERERS[String(kind)];
|
|
348
|
+
if (!fn) throw new Error(`unknown card kind: ${kind} (expected one of ${CARD_KINDS.join(", ")})`);
|
|
349
|
+
return fn(data);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Total soft-fail wrapper. An alert that cannot render is still an alert the
|
|
354
|
+
* user needs to see, so a malformed payload degrades to a minimal card rather
|
|
355
|
+
* than throwing and dropping the notification.
|
|
356
|
+
*/
|
|
357
|
+
export function safeRenderCard(kind, data = {}) {
|
|
358
|
+
try {
|
|
359
|
+
return renderCard(kind, data);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return [
|
|
362
|
+
`*ORACLE CARD — DEGRADED*`,
|
|
363
|
+
`Kind: ${escapeMd(kind)}`,
|
|
364
|
+
`Chain: ${UNKNOWN}`,
|
|
365
|
+
`Confidence: ${UNKNOWN}`,
|
|
366
|
+
`Render error: ${escapeMd(error?.message)}`,
|
|
367
|
+
].join("\n");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -215,7 +215,54 @@ export const CHAIN_CONFIGS = Object.freeze([
|
|
|
215
215
|
name: "Robinhood Chain",
|
|
216
216
|
rpcEnv: ["RH_CHAIN_RPC", "ROBINHOOD_RPC_URL"],
|
|
217
217
|
nativeCurrency: { symbol: "ETH", decimals: 18 },
|
|
218
|
-
|
|
218
|
+
// DexScreener does index this chain under the slug "robinhood" (verified live
|
|
219
|
+
// 2026-07-31: a CASHCAT search returns pairs tagged chainId "robinhood"). Without
|
|
220
|
+
// the slug, resolvePools reported UNAVAILABLE on every RH token even though the
|
|
221
|
+
// data was there.
|
|
222
|
+
dexscreenerSlug: "robinhood",
|
|
223
|
+
venues: [
|
|
224
|
+
{
|
|
225
|
+
kind: "quoter",
|
|
226
|
+
address: "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7",
|
|
227
|
+
label: "Uniswap V3 QuoterV2",
|
|
228
|
+
verified: {
|
|
229
|
+
method:
|
|
230
|
+
"functional probe, not a codesize check: quoteExactInputSingle returned a " +
|
|
231
|
+
"live sane price (WETH->USDG fee 100 quoted 1866.58 USDG, and USDG->CASHCAT " +
|
|
232
|
+
"fee 10000 quoted a live amount). A contract that correctly prices known pairs " +
|
|
233
|
+
"IS a working V3 quoter",
|
|
234
|
+
source: "live eth_call against rpc.mainnet.chain.robinhood.com",
|
|
235
|
+
date: "2026-07-31",
|
|
236
|
+
chainId: 4663,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
kind: "router",
|
|
241
|
+
address: "0xcaf681a66d020601342297493863e78c959e5cb2",
|
|
242
|
+
label: "Uniswap V3 SwapRouter02",
|
|
243
|
+
verified: {
|
|
244
|
+
method:
|
|
245
|
+
"eth_getCode returned real bytecode (24497 bytes) on this chain and the paired " +
|
|
246
|
+
"quoter at the same deployment passed a live functional quote",
|
|
247
|
+
source: "live eth_getCode against rpc.mainnet.chain.robinhood.com",
|
|
248
|
+
date: "2026-07-31",
|
|
249
|
+
chainId: 4663,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
kind: "factory",
|
|
254
|
+
address: "0x1f7d7550b1b028f7571e69a784071f0205fd2efa",
|
|
255
|
+
label: "Uniswap V3 Factory",
|
|
256
|
+
verified: {
|
|
257
|
+
method:
|
|
258
|
+
"eth_getCode returned real bytecode (24535 bytes) and a PoolCreated log scan " +
|
|
259
|
+
"over this factory returned 76 pools in ~9000 recent blocks",
|
|
260
|
+
source: "live eth_getLogs against rpc.mainnet.chain.robinhood.com",
|
|
261
|
+
date: "2026-07-31",
|
|
262
|
+
chainId: 4663,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
],
|
|
219
266
|
},
|
|
220
267
|
{
|
|
221
268
|
key: "base",
|