@oracle-agent/oracle 0.3.5 → 0.3.6

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 (38) hide show
  1. package/README.md +33 -13
  2. package/SETUP.md +59 -1
  3. package/artifacts/specialist-packs/oracle-full-crypto.json +31 -9
  4. package/bin/oracle-data-mcp.mjs +247 -4
  5. package/bin/oracle-init.mjs +100 -27
  6. package/bin/oracle-upgrade.mjs +42 -0
  7. package/docs/profiles.md +29 -7
  8. package/package.json +4 -1
  9. package/plugins/oracle-owner-gate/__init__.py +213 -0
  10. package/plugins/oracle-owner-gate/plugin.yaml +9 -0
  11. package/profiles/_template/SOUL.md +8 -1
  12. package/profiles/oracle/SOUL.md +17 -2
  13. package/profiles/oracle/profile.json +6 -2
  14. package/profiles/protocol-builder/SOUL.md +13 -6
  15. package/profiles/protocol-builder/profile.json +3 -1
  16. package/profiles/robinhood-agent/SOUL.md +9 -3
  17. package/profiles/robinhood-agent/profile.json +1 -0
  18. package/skills/balance/SKILL.md +176 -0
  19. package/skills/oracle-action-semantics/SKILL.md +40 -0
  20. package/skills/oracle-multichain-nft-launch/SKILL.md +338 -0
  21. package/skills/oracle-multichain-token-launch/SKILL.md +300 -0
  22. package/src/action-semantics.mjs +62 -0
  23. package/src/data/catalog.mjs +27 -3
  24. package/src/data/desk-data.mjs +34 -4
  25. package/src/data/providers/magiceden-sol.mjs +21 -2
  26. package/src/data/providers/nft-gallery.mjs +163 -0
  27. package/src/data/providers/nft-portfolio.mjs +494 -0
  28. package/src/data/providers/opensea-nft.mjs +272 -0
  29. package/src/data/providers/portfolio-history.mjs +394 -0
  30. package/src/data/providers/portfolio.mjs +594 -0
  31. package/src/data/providers/satflow.mjs +1 -0
  32. package/src/exec-policy.mjs +5 -0
  33. package/src/gmx-attestation.mjs +1 -0
  34. package/src/index.mjs +9 -0
  35. package/src/profile-upgrade.mjs +277 -0
  36. package/src/scanner/chains.config.mjs +2 -0
  37. package/src/vault-attestation.mjs +1 -0
  38. package/src/cards.mjs +0 -369
@@ -0,0 +1,277 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { createRequire } from "node:module";
5
+
6
+ export const UPGRADE_VERSION = "oracle-profile-upgrade/v1";
7
+
8
+ function exists(file) {
9
+ try { fs.accessSync(file); return true; } catch { return false; }
10
+ }
11
+
12
+ function snapshot(dir) {
13
+ if (!exists(dir)) return [];
14
+ return fs.readdirSync(dir, { recursive: true, withFileTypes: true })
15
+ .filter((entry) => entry.isFile())
16
+ .map((entry) => path.join(entry.parentPath || entry.path, entry.name))
17
+ .sort()
18
+ .map((file) => [path.relative(dir, file), fs.readFileSync(file)]);
19
+ }
20
+
21
+ function desiredTree(source) {
22
+ const files = snapshot(source);
23
+ files.push([".oracle-upgrade-version", Buffer.from(`${UPGRADE_VERSION}\n`)]);
24
+ return files.sort(([a], [b]) => a.localeCompare(b));
25
+ }
26
+
27
+ function treeMatches(dest, files) {
28
+ const actual = snapshot(dest);
29
+ return actual.length === files.length && actual.every(([name, body], i) =>
30
+ name === files[i][0] && body.equals(files[i][1]));
31
+ }
32
+
33
+ function tempSibling(target) {
34
+ return `${target}.tmp-oracle-upgrade-${process.pid}-${randomBytes(6).toString("hex")}`;
35
+ }
36
+
37
+ function backupName(target) {
38
+ let candidate = `${target}.bak-oracle-upgrade`;
39
+ for (let n = 1; exists(candidate); n += 1) candidate = `${target}.bak-oracle-upgrade-${n}`;
40
+ return candidate;
41
+ }
42
+
43
+ function installTree(source, dest, apply, report) {
44
+ if (!exists(source) || !fs.statSync(source).isDirectory()) return;
45
+ const files = desiredTree(source);
46
+ if (treeMatches(dest, files)) {
47
+ report.unchanged.push(dest);
48
+ return;
49
+ }
50
+ const wasPresent = exists(dest);
51
+ (wasPresent ? report.updated : report.created).push(dest);
52
+ if (!apply) return;
53
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
54
+ const tmp = tempSibling(dest);
55
+ let backup = null;
56
+ fs.mkdirSync(tmp);
57
+ try {
58
+ for (const [name, body] of files) {
59
+ const out = path.join(tmp, name);
60
+ fs.mkdirSync(path.dirname(out), { recursive: true });
61
+ fs.writeFileSync(out, body);
62
+ }
63
+ if (wasPresent) {
64
+ backup = backupName(dest);
65
+ fs.renameSync(dest, backup);
66
+ report.backups.push({ path: backup, original: dest });
67
+ }
68
+ fs.renameSync(tmp, dest);
69
+ } catch (error) {
70
+ if (exists(tmp)) fs.rmSync(tmp, { recursive: true, force: true });
71
+ if (backup && !exists(dest) && exists(backup)) fs.renameSync(backup, dest);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ // This deliberately accepts only the ordinary block-style YAML Hermes writes.
77
+ // Unsupported constructs fail closed rather than risking a lossy rewrite.
78
+ function validateYaml(text, file) {
79
+ if (/^mcp_servers:[ \t]*(?!\{\}[ \t]*(?:#.*)?$)\S+/m.test(text)) {
80
+ throw new Error(`${file}: mcp_servers must be a block mapping`);
81
+ }
82
+ const stack = [];
83
+ const keys = new Map();
84
+ const lines = text.split(/\r?\n/);
85
+ let flow = 0;
86
+ for (let index = 0; index < lines.length; index += 1) {
87
+ const raw = lines[index];
88
+ if (/^\s*#/.test(raw)) continue;
89
+ if (/^\s*\t|^ +\t/.test(raw)) throw new Error(`${file}:${index + 1}: tabs are not supported in YAML indentation`);
90
+ const line = raw.replace(/\s+#.*$/, "");
91
+ if (!line.trim() || /^\s*(?:---|\.\.\.)\s*$/.test(line)) continue;
92
+ const quoteFree = line.replace(/'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"/g, "");
93
+ for (const char of quoteFree) {
94
+ if ("[{".includes(char)) flow += 1;
95
+ if ("]}".includes(char)) flow -= 1;
96
+ if (flow < 0) throw new Error(`${file}:${index + 1}: unbalanced flow collection`);
97
+ }
98
+ if (/^\s*[^#\-][^:]*:\s*(?:.*)?$/.test(line)) {
99
+ const indent = line.match(/^ */)[0].length;
100
+ const key = line.trimStart().match(/^([^:]+):/)[1].trim();
101
+ while (stack.length && stack.at(-1).indent >= indent) stack.pop();
102
+ const parent = stack.map((item) => item.key).join("/");
103
+ const id = `${parent}|${indent}|${key}`;
104
+ if (keys.has(id)) throw new Error(`${file}:${index + 1}: duplicate mapping key ${key}`);
105
+ keys.set(id, true);
106
+ if (/^[^:]+:\s*(?:#.*)?$/.test(line.trimStart())) stack.push({ indent, key });
107
+ } else if (!/^\s*-\s+/.test(line) && flow === 0) {
108
+ throw new Error(`${file}:${index + 1}: unsupported or malformed YAML`);
109
+ }
110
+ }
111
+ if (flow !== 0) throw new Error(`${file}: unbalanced flow collection`);
112
+ if (/^\s*[^#\n]*:\s*[>|][+-]?\s*$/m.test(text)) {
113
+ // Block scalars are safe to preserve outside mcp_servers, but editing inside
114
+ // one would require a complete YAML parser.
115
+ const mcp = topLevelBlock(text, "mcp_servers");
116
+ if (mcp && /:\s*[>|][+-]?\s*$/m.test(mcp.body)) throw new Error(`${file}: block scalars inside mcp_servers are unsupported`);
117
+ }
118
+ }
119
+
120
+ function topLevelBlock(text, name) {
121
+ const re = new RegExp(`^${name}:[^\\S\\r\\n]*(?:#.*)?$`, "m");
122
+ const match = re.exec(text);
123
+ if (!match) return null;
124
+ const start = match.index;
125
+ const bodyStart = text.indexOf("\n", start) + 1;
126
+ if (!bodyStart) return { start, end: text.length, bodyStart: text.length, body: "" };
127
+ const rest = text.slice(bodyStart);
128
+ const next = /^(?=\S[^\r\n]*:)/m.exec(rest);
129
+ const end = next ? bodyStart + next.index : text.length;
130
+ return { start, bodyStart, end, body: text.slice(bodyStart, end) };
131
+ }
132
+
133
+ function scalar(value) {
134
+ return JSON.stringify(value);
135
+ }
136
+
137
+ function serverBlock(name, command) {
138
+ const words = command.command === "node" ? [command.script] : command.args;
139
+ const lines = [` ${name}:`, ` command: ${scalar(command.command)}`];
140
+ if (words?.length) lines.push(" args:", ...words.map((arg) => ` - ${scalar(arg)}`));
141
+ lines.push(" enabled: true", ` # ${UPGRADE_VERSION}`);
142
+ return `${lines.join("\n")}\n`;
143
+ }
144
+
145
+ function mergeMcp(text, desired) {
146
+ text = text.replace(
147
+ /^(mcp_servers:)\s*\{\}\s*(#.*)?$/m,
148
+ (_line, head, comment) => `${head}${comment ? ` ${comment}` : ""}`,
149
+ );
150
+ const block = topLevelBlock(text, "mcp_servers");
151
+ if (!block) {
152
+ const prefix = text.length && !text.endsWith("\n") ? "\n" : "";
153
+ return `${text}${prefix}mcp_servers:\n${[...desired].map(([n, c]) => serverBlock(n, c)).join("")}`;
154
+ }
155
+ let body = block.body;
156
+ for (const [name, command] of desired) {
157
+ const child = new RegExp(`^ ${name}:[^\\S\\r\\n]*(?:#.*)?\\r?\\n(?:^(?: {3,}|\\s*$).*\\r?\\n?)*`, "m");
158
+ const replacement = serverBlock(name, command);
159
+ body = child.test(body) ? body.replace(child, replacement) : `${body}${body && !body.endsWith("\n") ? "\n" : ""}${replacement}`;
160
+ }
161
+ return `${text.slice(0, block.bodyStart)}${body}${text.slice(block.end)}`;
162
+ }
163
+
164
+ function mergeEnabledPlugin(text, pluginId) {
165
+ text = text.replace(
166
+ /^(plugins:)\s*\{\}\s*(#.*)?$/m,
167
+ (_line, head, comment) => `${head}${comment ? ` ${comment}` : ""}`,
168
+ );
169
+ const item = ` - ${scalar(pluginId)}\n`;
170
+ const block = topLevelBlock(text, "plugins");
171
+ if (!block) {
172
+ const prefix = text.length && !text.endsWith("\n") ? "\n" : "";
173
+ return `${text}${prefix}plugins:\n enabled:\n${item}`;
174
+ }
175
+ let body = block.body;
176
+ const flow = /^ enabled:\s*\[([^\]]*)\](.*)$/m.exec(body);
177
+ if (flow) {
178
+ const values = flow[1].split(",").map((value) => value.trim().replace(/^["']|["']$/g, ""));
179
+ if (!values.includes(pluginId)) {
180
+ const joined = flow[1].trim() ? `${flow[1].trimEnd()}, ${scalar(pluginId)}` : scalar(pluginId);
181
+ body = body.replace(flow[0], ` enabled: [${joined}]${flow[2]}`);
182
+ }
183
+ } else {
184
+ const sequence = /^ enabled:\s*(?:#.*)?\r?\n((?:^ {4}-.*\r?\n?)*)/m.exec(body);
185
+ if (sequence) {
186
+ const values = sequence[1].split(/\r?\n/).map((line) => line.replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, ""));
187
+ if (!values.includes(pluginId)) body = body.replace(sequence[0], `${sequence[0]}${item}`);
188
+ } else {
189
+ body = `${body}${body && !body.endsWith("\n") ? "\n" : ""} enabled:\n${item}`;
190
+ }
191
+ }
192
+ return `${text.slice(0, block.bodyStart)}${body}${text.slice(block.end)}`;
193
+ }
194
+
195
+ function writeAtomic(file, body, report) {
196
+ const present = exists(file);
197
+ const old = present ? fs.readFileSync(file, "utf8") : null;
198
+ if (old === body) { report.unchanged.push(file); return; }
199
+ (present ? report.updated : report.created).push(file);
200
+ if (!report.applied) return;
201
+ fs.mkdirSync(path.dirname(file), { recursive: true });
202
+ if (present) {
203
+ const backup = backupName(file);
204
+ fs.copyFileSync(file, backup);
205
+ report.backups.push({ path: backup, original: file });
206
+ }
207
+ const tmp = tempSibling(file);
208
+ try {
209
+ fs.writeFileSync(tmp, body, { mode: present ? fs.statSync(file).mode : 0o600 });
210
+ fs.renameSync(tmp, file);
211
+ } catch (error) {
212
+ if (exists(tmp)) fs.rmSync(tmp, { force: true });
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ function resolveControl(packageRoot, explicit) {
218
+ if (explicit) {
219
+ const [command, ...args] = explicit;
220
+ return { command, args };
221
+ }
222
+ const candidates = [
223
+ path.join(packageRoot, "bin", "oracle-control-mcp.mjs"),
224
+ path.join(packageRoot, "plugins", "oracle-owner-gate", "bin", "oracle-control-mcp.mjs"),
225
+ path.join(packageRoot, "plugins", "oracle-owner-gate", "oracle-control-mcp.mjs"),
226
+ // Control MCP ships with the local operator package, not the prepare-only public root.
227
+ path.join(packageRoot, "..", "@oracle-agent", "operator", "bin", "oracle-control-mcp.mjs"),
228
+ path.join(packageRoot, "..", "..", "@oracle-agent", "operator", "bin", "oracle-control-mcp.mjs"),
229
+ ];
230
+ for (const script of candidates) {
231
+ if (exists(script) && fs.statSync(script).isFile()) return { command: "node", script: path.resolve(script) };
232
+ }
233
+ try {
234
+ const require = createRequire(path.join(packageRoot, "package.json"));
235
+ const operatorPkg = path.dirname(require.resolve("@oracle-agent/operator/package.json"));
236
+ const script = path.join(operatorPkg, "bin", "oracle-control-mcp.mjs");
237
+ if (exists(script) && fs.statSync(script).isFile()) return { command: "node", script: path.resolve(script) };
238
+ } catch {}
239
+ return null;
240
+ }
241
+
242
+ export function upgradeProfiles({ hermesHome, packageRoot, only = null, apply = false, controlCommand = null }) {
243
+ const profilesRoot = path.join(hermesHome, "profiles");
244
+ const report = { ok: true, applied: apply, version: UPGRADE_VERSION, hermesHome, packageRoot, profiles: [], created: [], updated: [], unchanged: [], backups: [] };
245
+ let profiles = exists(profilesRoot) ? fs.readdirSync(profilesRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() : [];
246
+ if (only) profiles = profiles.filter((name) => name === only);
247
+ if (only && profiles.length === 0) throw new Error(`profile not found: ${only}`);
248
+ report.profiles = profiles;
249
+
250
+ // Validate every selected config before planning or performing any write.
251
+ const configs = new Map();
252
+ for (const profile of profiles) {
253
+ const file = path.join(profilesRoot, profile, "config.yaml");
254
+ const text = exists(file) ? fs.readFileSync(file, "utf8") : "";
255
+ validateYaml(text, file);
256
+ configs.set(profile, text);
257
+ }
258
+
259
+ const skillSource = path.join(packageRoot, "skills", "oracle-action-semantics");
260
+ if (!exists(skillSource)) throw new Error(`required Oracle skill is missing: ${skillSource}`);
261
+ const pluginSource = path.join(packageRoot, "plugins", "oracle-owner-gate");
262
+ const control = resolveControl(packageRoot, controlCommand);
263
+ const dataScript = path.join(packageRoot, "bin", "oracle-data-mcp.mjs");
264
+ if (!exists(dataScript)) throw new Error(`oracle-data MCP command is missing: ${dataScript}`);
265
+
266
+ for (const profile of profiles) {
267
+ const root = path.join(profilesRoot, profile);
268
+ installTree(skillSource, path.join(root, "skills", "oracle-action-semantics"), apply, report);
269
+ if (exists(pluginSource)) installTree(pluginSource, path.join(root, "plugins", "oracle-owner-gate"), apply, report);
270
+ const desired = new Map([["oracle-data", { command: "node", script: dataScript }]]);
271
+ if (control) desired.set("oracle-control", control);
272
+ let config = mergeMcp(configs.get(profile), desired);
273
+ if (exists(pluginSource)) config = mergeEnabledPlugin(config, "oracle-owner-gate");
274
+ writeAtomic(path.join(root, "config.yaml"), config, report);
275
+ }
276
+ return report;
277
+ }
@@ -220,6 +220,8 @@ export const CHAIN_CONFIGS = Object.freeze([
220
220
  // the slug, resolvePools reported UNAVAILABLE on every RH token even though the
221
221
  // data was there.
222
222
  dexscreenerSlug: "robinhood",
223
+ venueKind: "uniswap-v3",
224
+ wrappedNative: "0x0bd7d308f8e1639fab988df18a8011f41eacad73",
223
225
  venues: [
224
226
  {
225
227
  kind: "quoter",
@@ -129,6 +129,7 @@ export function assertVaultAttestation(attestation, tx = {}, { chainId, nowMs =
129
129
  export function assertVaultApprovalAttestation(attestation, tx = {}, guard = {}, { chainId, nowMs = Date.now(), secret } = {}) {
130
130
  if (!attestation || attestation.mode !== "vault-attestation") throw new Error("vault attestation required");
131
131
  if (String(attestation.action) !== "deposit") throw new Error("vault approval requires deposit attestation");
132
+ assertFreshWindow(attestation, nowMs, "vault approval attestation");
132
133
  if (Number(attestation.expiresAtMs) <= Number(nowMs)) throw new Error("vault attestation expired");
133
134
  const expected = hmac(attestationSecret(secret), canonicalJson(unsigned(attestation)));
134
135
  if (!sameSignature(attestation.signature, expected)) throw new Error("vault attestation signature mismatch");
package/src/cards.mjs DELETED
@@ -1,369 +0,0 @@
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
- }