@oracle-agent/oracle 0.1.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 (169) hide show
  1. package/CONTRIBUTING.md +98 -0
  2. package/LICENSE +202 -0
  3. package/README.md +384 -0
  4. package/SECURITY.md +89 -0
  5. package/SETUP.md +235 -0
  6. package/artifacts/inscription/oracle-was-here-preview.png +0 -0
  7. package/artifacts/inscription/oracle-was-here.svg +1 -0
  8. package/artifacts/specialist-packs/oracle-full-crypto.json +273 -0
  9. package/bin/desk-server.mjs +438 -0
  10. package/bin/oracle-data-mcp.mjs +603 -0
  11. package/bin/oracle-init.mjs +423 -0
  12. package/bin/oracle-public-server.mjs +36 -0
  13. package/bin/oracle-route.mjs +254 -0
  14. package/bin/oracle-scan.mjs +192 -0
  15. package/docs/adding-a-chain.md +229 -0
  16. package/docs/architecture.md +135 -0
  17. package/docs/profiles.md +132 -0
  18. package/examples/add-a-chain.mjs +65 -0
  19. package/examples/research-a-token.mjs +70 -0
  20. package/package.json +90 -0
  21. package/profiles/_template/SOUL.md +47 -0
  22. package/profiles/_template/profile.json +22 -0
  23. package/profiles/bitcoin-agent/SOUL.md +31 -0
  24. package/profiles/bitcoin-agent/profile.json +32 -0
  25. package/profiles/hyperliquid-agent/SOUL.md +34 -0
  26. package/profiles/hyperliquid-agent/profile.json +37 -0
  27. package/profiles/oracle/SOUL.md +65 -0
  28. package/profiles/oracle/profile.json +35 -0
  29. package/profiles/polymarket-agent/SOUL.md +35 -0
  30. package/profiles/polymarket-agent/profile.json +34 -0
  31. package/profiles/profile.schema.json +90 -0
  32. package/profiles/protocol-builder/SOUL.md +50 -0
  33. package/profiles/protocol-builder/profile.json +37 -0
  34. package/profiles/robinhood-agent/SOUL.md +45 -0
  35. package/profiles/robinhood-agent/profile.json +39 -0
  36. package/profiles/solana-agent/SOUL.md +37 -0
  37. package/profiles/solana-agent/profile.json +37 -0
  38. package/profiles/stable-agent/SOUL.md +43 -0
  39. package/profiles/stable-agent/profile.json +37 -0
  40. package/public/oracle-console/app.js +272 -0
  41. package/public/oracle-console/bitcoin-wallets.js +206 -0
  42. package/public/oracle-console/index.html +91 -0
  43. package/public/oracle-console/styles.css +239 -0
  44. package/public/oracle-splash/index.html +931 -0
  45. package/scripts/build-inscription.py +230 -0
  46. package/scripts/check-test-count.mjs +105 -0
  47. package/scripts/e2e-hl-markets.mjs +21 -0
  48. package/scripts/e2e-hl-perps.mjs +48 -0
  49. package/scripts/e2e-hypercore-staking.mjs +128 -0
  50. package/scripts/e2e-solana-bitcoin.mjs +183 -0
  51. package/scripts/public-api-scan.mjs +23 -0
  52. package/scripts/secret-scan.mjs +181 -0
  53. package/scripts/verify-v3-venues.mjs +192 -0
  54. package/skills/oracle-best-execution/SKILL.md +127 -0
  55. package/skills/oracle-bitcoin/SKILL.md +53 -0
  56. package/skills/oracle-chain-graphs-telegram-cards/SKILL.md +59 -0
  57. package/skills/oracle-circuit-breaker/SKILL.md +51 -0
  58. package/skills/oracle-contract-research/SKILL.md +55 -0
  59. package/skills/oracle-desk/SKILL.md +58 -0
  60. package/skills/oracle-dex-launch/SKILL.md +38 -0
  61. package/skills/oracle-grants/SKILL.md +69 -0
  62. package/skills/oracle-hypercore-staking/SKILL.md +57 -0
  63. package/skills/oracle-hyperliquid/SKILL.md +56 -0
  64. package/skills/oracle-meme-token-sniper/SKILL.md +73 -0
  65. package/skills/oracle-nft-gacha-launch/SKILL.md +48 -0
  66. package/skills/oracle-nft-mint-gas-war/SKILL.md +63 -0
  67. package/skills/oracle-polymarket/SKILL.md +60 -0
  68. package/skills/oracle-protocol-builder/SKILL.md +38 -0
  69. package/skills/oracle-protocol-security/SKILL.md +60 -0
  70. package/skills/oracle-public-product/SKILL.md +44 -0
  71. package/skills/oracle-receipts/SKILL.md +52 -0
  72. package/skills/oracle-rfq-tokenized-assets/SKILL.md +69 -0
  73. package/skills/oracle-smart-wallet-scanner/SKILL.md +49 -0
  74. package/skills/oracle-solana/SKILL.md +65 -0
  75. package/skills/oracle-solana-nft/SKILL.md +54 -0
  76. package/skills/oracle-token-research/SKILL.md +67 -0
  77. package/src/agent-auth.mjs +191 -0
  78. package/src/approval-guard.mjs +282 -0
  79. package/src/attestation-secret.mjs +88 -0
  80. package/src/audit-log.mjs +196 -0
  81. package/src/auto-slippage.mjs +378 -0
  82. package/src/capability-posture.mjs +125 -0
  83. package/src/chains.mjs +62 -0
  84. package/src/data/catalog.mjs +495 -0
  85. package/src/data/desk-data.mjs +623 -0
  86. package/src/data/http.mjs +200 -0
  87. package/src/data/provider-endpoint.mjs +94 -0
  88. package/src/data/providers/aerodrome.mjs +244 -0
  89. package/src/data/providers/balancer.mjs +208 -0
  90. package/src/data/providers/bitcoin-esplora.mjs +230 -0
  91. package/src/data/providers/bitcoin-meta.mjs +378 -0
  92. package/src/data/providers/blockscout.mjs +14 -0
  93. package/src/data/providers/bridges.mjs +241 -0
  94. package/src/data/providers/cowswap.mjs +501 -0
  95. package/src/data/providers/curve.mjs +200 -0
  96. package/src/data/providers/defillama.mjs +88 -0
  97. package/src/data/providers/dexscreener.mjs +43 -0
  98. package/src/data/providers/evm-rpc.mjs +203 -0
  99. package/src/data/providers/geckoterminal.mjs +34 -0
  100. package/src/data/providers/gmx.mjs +495 -0
  101. package/src/data/providers/hl-info.mjs +102 -0
  102. package/src/data/providers/hl-markets.mjs +210 -0
  103. package/src/data/providers/hl-perps.mjs +382 -0
  104. package/src/data/providers/hl-staking.mjs +352 -0
  105. package/src/data/providers/hl-ws.mjs +119 -0
  106. package/src/data/providers/hyperevm-dex.mjs +49 -0
  107. package/src/data/providers/jupiter.mjs +182 -0
  108. package/src/data/providers/lifi.mjs +150 -0
  109. package/src/data/providers/magiceden-sol.mjs +355 -0
  110. package/src/data/providers/morpho.mjs +173 -0
  111. package/src/data/providers/odos.mjs +155 -0
  112. package/src/data/providers/oneinch.mjs +173 -0
  113. package/src/data/providers/opensea-multichain.mjs +136 -0
  114. package/src/data/providers/opensea-nft.mjs +99 -0
  115. package/src/data/providers/paraswap.mjs +117 -0
  116. package/src/data/providers/pendle.mjs +187 -0
  117. package/src/data/providers/poly-public.mjs +96 -0
  118. package/src/data/providers/poly-ws.mjs +103 -0
  119. package/src/data/providers/rh-agent.mjs +59 -0
  120. package/src/data/providers/satflow.mjs +336 -0
  121. package/src/data/providers/solana-rpc.mjs +186 -0
  122. package/src/data/providers/uniswap-v3.mjs +303 -0
  123. package/src/data/providers/zerox.mjs +166 -0
  124. package/src/data/public-api-scan.mjs +61 -0
  125. package/src/data/quote-placeholder.mjs +31 -0
  126. package/src/exact-integer.mjs +72 -0
  127. package/src/exec-policy.mjs +444 -0
  128. package/src/flags.mjs +15 -0
  129. package/src/fresh-window.mjs +76 -0
  130. package/src/gmx-attestation.mjs +175 -0
  131. package/src/index.mjs +50 -0
  132. package/src/nft-gas-war-guard.mjs +139 -0
  133. package/src/onboarding/agent-keys.mjs +157 -0
  134. package/src/onboarding/index.mjs +18 -0
  135. package/src/onboarding/tiers.mjs +139 -0
  136. package/src/oracle-env.mjs +38 -0
  137. package/src/protocol-execution.mjs +84 -0
  138. package/src/public-api/buzz-integration.mjs +256 -0
  139. package/src/public-api/connect-agent.mjs +397 -0
  140. package/src/public-api/grants.mjs +142 -0
  141. package/src/public-api/http.mjs +374 -0
  142. package/src/public-control/aa-adapter.mjs +402 -0
  143. package/src/public-control/build-registry.mjs +227 -0
  144. package/src/public-control/bundler-client.mjs +372 -0
  145. package/src/public-control/grant-indexer.mjs +296 -0
  146. package/src/public-control/policy-render.mjs +69 -0
  147. package/src/public-control/policy-schema.mjs +318 -0
  148. package/src/public-control/runtime-config.mjs +265 -0
  149. package/src/public-control/session-key-model.mjs +374 -0
  150. package/src/public-control/session-orchestrator.mjs +412 -0
  151. package/src/route-attestation.mjs +132 -0
  152. package/src/router/best-execution.mjs +221 -0
  153. package/src/router/index.mjs +185 -0
  154. package/src/router/prepare-bridge.mjs +288 -0
  155. package/src/router/prepare-route.mjs +341 -0
  156. package/src/router/proposal.mjs +311 -0
  157. package/src/router/risk-classifier.mjs +119 -0
  158. package/src/router/route-sources.mjs +292 -0
  159. package/src/scanner/chains.config.mjs +381 -0
  160. package/src/scanner/contract.mjs +270 -0
  161. package/src/scanner/evm-scanner.mjs +394 -0
  162. package/src/scanner/index.mjs +9 -0
  163. package/src/scanner/v2-venue.mjs +335 -0
  164. package/src/scanner/v3-venue.mjs +290 -0
  165. package/src/scopes.mjs +44 -0
  166. package/src/sell-simulation.mjs +167 -0
  167. package/src/token-transfer-guard.mjs +188 -0
  168. package/src/vault-attestation.mjs +145 -0
  169. package/src/venues.mjs +206 -0
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ // oracle-route -- compare swap and bridge routes across every available source.
3
+ //
4
+ // Usage:
5
+ // oracle-route swap <chain> <tokenIn> <tokenOut> [amountIn]
6
+ // oracle-route bridge <fromChain> <toChain> <token> [amountIn]
7
+ //
8
+ // <chain> is an id (8453) or key (base). --json for machine output.
9
+
10
+ import { bestSwapRoute, bestBridgeRoute } from "../src/router/index.mjs";
11
+ import { prepareBestRoute } from "../src/router/prepare-route.mjs";
12
+ import { prepareBestBridgeRoute } from "../src/router/prepare-bridge.mjs";
13
+ import { registerBuiltinScanners } from "../src/scanner/chains.config.mjs";
14
+ import { getScanner, listScanners } from "../src/scanner/contract.mjs";
15
+
16
+ registerBuiltinScanners();
17
+
18
+ const [cmd, ...rest] = process.argv.slice(2);
19
+ const JSON_OUT = rest.includes("--json");
20
+ const args = rest.filter((a) => a !== "--json");
21
+ const NATIVE = "0x0000000000000000000000000000000000000000";
22
+
23
+ function chainId(ref) {
24
+ if (!ref) die("missing <chain>");
25
+ const n = Number(ref);
26
+ if (getScanner(n)) return n;
27
+ const s = listScanners().find((x) => x.key === String(ref).toLowerCase());
28
+ if (s) return s.chainId;
29
+ die(`unknown chain "${ref}". Known: ${listScanners().map((x) => x.key).join(", ")}`);
30
+ }
31
+
32
+ function die(msg) {
33
+ console.error(`error: ${msg}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ function fmt(raw, decimals) {
38
+ const n = Number(raw) / 10 ** decimals;
39
+ return n.toLocaleString(undefined, { maximumFractionDigits: 6 });
40
+ }
41
+
42
+ function render(r, decimals) {
43
+ console.log(`sources: ${r.sourcesAnswered}/${r.sourcesTried} answered`);
44
+ console.log(`ranked on: ${r.rankedOn}\n`);
45
+
46
+ if (!r.routes.length) {
47
+ console.log("no source returned a usable route.");
48
+ for (const f of r.failed) console.log(` ${f.source}: ${f.error}`);
49
+ return;
50
+ }
51
+
52
+ const w = Math.max(...r.routes.map((x) => x.source.length), 6);
53
+ console.log(` ${"source".padEnd(w)} ${"net out".padStart(14)} ${"gross".padStart(14)} cost`);
54
+ for (const [i, x] of r.routes.entries()) {
55
+ const mark = i === 0 ? "*" : " ";
56
+ const cost =
57
+ x.gasUsd == null
58
+ ? "gas unknown"
59
+ : x.gasUsd === 0
60
+ ? "gasless (solver)"
61
+ : `$${Number(x.gasUsd + (x.feeUsd ?? 0)).toFixed(2)}`;
62
+ const dur = x.meta?.durationSeconds != null ? ` ~${x.meta.durationSeconds}s` : "";
63
+ console.log(
64
+ `${mark} ${x.source.padEnd(w)} ${fmt(x.netOut, decimals).padStart(14)} ` +
65
+ `${fmt(x.grossOut, decimals).padStart(14)} ${cost}${dur}`,
66
+ );
67
+ }
68
+
69
+ for (const f of r.failed) console.log(` ${f.source.padEnd(w)} FAILED: ${String(f.error).slice(0, 60)}`);
70
+
71
+ console.log(`\n${r.note}`);
72
+ for (const wn of r.warnings) console.log(`\nWARNING: ${wn}`);
73
+ }
74
+
75
+ try {
76
+ switch (cmd) {
77
+ case "swap": {
78
+ const cid = chainId(args[0]);
79
+ if (!args[1] || !args[2]) die("usage: oracle-route swap <chain> <tokenIn> <tokenOut> [amountIn]");
80
+ const amountIn = args[3] || (10n ** 18n).toString();
81
+ const scanner = getScanner(cid);
82
+
83
+ // Decimals matter twice: ParaSwap needs them to quote at all, and the ranker
84
+ // needs the destination's to convert gas into output units.
85
+ const [dIn, dOut] = await Promise.all([
86
+ scanner.resolveToken(args[1]).then((t) => t.decimals).catch(() => null),
87
+ scanner.resolveToken(args[2]).then((t) => t.decimals).catch(() => null),
88
+ ]);
89
+
90
+ const r = await bestSwapRoute({
91
+ chainId: cid,
92
+ tokenIn: args[1],
93
+ tokenOut: args[2],
94
+ amountIn,
95
+ taker: args[4] || "0x000000000000000000000000000000000000dEaD",
96
+ decimalsIn: dIn,
97
+ decimalsOut: dOut,
98
+ });
99
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
100
+ else render(r, dOut ?? 18);
101
+ break;
102
+ }
103
+
104
+ case "bridge": {
105
+ const from = chainId(args[0]);
106
+ const to = chainId(args[1]);
107
+ const token = args[2] || NATIVE;
108
+ const amountIn = args[3] || (10n ** 18n).toString();
109
+
110
+ const r = await bestBridgeRoute({
111
+ fromChainId: from,
112
+ toChainId: to,
113
+ tokenIn: token,
114
+ tokenOut: token,
115
+ amountIn,
116
+ taker: args[4] || "0x000000000000000000000000000000000000dEaD",
117
+ decimalsOut: 18,
118
+ });
119
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
120
+ else render(r, 18);
121
+ break;
122
+ }
123
+
124
+ case "prepare": {
125
+ const cid = chainId(args[0]);
126
+ if (!args[1] || !args[2] || !args[3]) {
127
+ die("usage: oracle-route prepare <chain> <tokenIn> <tokenOut> <taker> [amountIn] [--source=X]");
128
+ }
129
+ // Argument order puts taker BEFORE amount deliberately: preparing without a
130
+ // real taker is the one mistake that cannot be recovered from downstream.
131
+ const taker = args[3];
132
+ const amountIn = args[4] || (10n ** 18n).toString();
133
+ const srcArg = rest.find((a) => a.startsWith("--source="));
134
+ const scanner = getScanner(cid);
135
+
136
+ const [dIn, dOut] = await Promise.all([
137
+ scanner.resolveToken(args[1]).then((t) => t.decimals).catch(() => null),
138
+ scanner.resolveToken(args[2]).then((t) => t.decimals).catch(() => null),
139
+ ]);
140
+
141
+ const r = await prepareBestRoute({
142
+ chainId: cid,
143
+ tokenIn: args[1],
144
+ tokenOut: args[2],
145
+ amountIn,
146
+ taker,
147
+ decimalsIn: dIn,
148
+ decimalsOut: dOut,
149
+ source: srcArg ? srcArg.split("=")[1] : undefined,
150
+ });
151
+
152
+ if (JSON_OUT) {
153
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
154
+ break;
155
+ }
156
+
157
+ if (!r.ok) {
158
+ console.log(`could not prepare: ${r.reason}`);
159
+ if (r.failureKind) console.log(`kind: ${r.failureKind}`);
160
+ if (r.alternative) console.log(`try: --source=${r.alternative}`);
161
+ process.exit(1);
162
+ }
163
+
164
+ console.log(`source: ${r.chosen.source}${r.chosen.wasWinner ? " (winner)" : " (override -- NOT the winner)"}`);
165
+ console.log(`artifact: ${r.artifactKind}`);
166
+ console.log(`destination: ${r.destination}`);
167
+ if (r.minOut) console.log(`minOut: ${r.minOut}`);
168
+ if (r.driftBps != null) console.log(`drift: ${(r.driftBps / 100).toFixed(3)}% vs comparison`);
169
+ if (r.requiresApproval) {
170
+ console.log(`\napproval required first:`);
171
+ console.log(` token ${r.requiresApproval.token}`);
172
+ console.log(` spender ${r.requiresApproval.spender}`);
173
+ if (r.requiresApproval.note) console.log(` note ${r.requiresApproval.note}`);
174
+ }
175
+ if (r.runnersUp?.length) {
176
+ console.log(`\nrunners-up: ${r.runnersUp.map((x) => x.source).join(", ")}`);
177
+ }
178
+ for (const w of r.warnings) console.log(`\nWARNING: ${w}`);
179
+ console.log(`\n${r.note}`);
180
+ break;
181
+ }
182
+
183
+ case "prepare-bridge": {
184
+ const from = chainId(args[0]);
185
+ const to = chainId(args[1]);
186
+ if (!args[2]) die("usage: oracle-route prepare-bridge <fromChain> <toChain> <taker> [token] [amountIn] [--source=X]");
187
+ const taker = args[2];
188
+ const token = args[3] || NATIVE;
189
+ const amountIn = args[4] || (10n ** 17n).toString();
190
+ const srcArg = rest.find((a) => a.startsWith("--source="));
191
+
192
+ const r = await prepareBestBridgeRoute({
193
+ fromChainId: from,
194
+ toChainId: to,
195
+ tokenIn: token,
196
+ tokenOut: token,
197
+ amountIn,
198
+ taker,
199
+ decimalsOut: 18,
200
+ source: srcArg ? srcArg.split("=")[1] : undefined,
201
+ });
202
+
203
+ if (JSON_OUT) {
204
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
205
+ break;
206
+ }
207
+ if (!r.ok) {
208
+ console.log(`could not prepare: ${r.reason}`);
209
+ if (r.failureKind) console.log(`kind: ${r.failureKind}`);
210
+ if (r.alternative) console.log(`try: --source=${r.alternative}`);
211
+ process.exit(1);
212
+ }
213
+
214
+ console.log(`source: ${r.chosen.source}${r.chosen.wasWinner ? " (winner)" : " (override -- NOT the winner)"}`);
215
+ console.log(`artifact: ${r.artifactKind}`);
216
+ console.log(`route: chain ${r.fromChainId} -> chain ${r.toChainId}`);
217
+ console.log(`signs: ${r.transactionCount} transaction(s) on chain ${r.fromChainId}`);
218
+ console.log(`destination: ${r.destination}`);
219
+ if (r.minOut) console.log(`minOut: ${r.minOut}`);
220
+ if (r.durationSeconds != null) console.log(`eta: ~${r.durationSeconds}s to credit`);
221
+ if (r.driftBps != null) console.log(`drift: ${(r.driftBps / 100).toFixed(3)}% vs comparison`);
222
+ if (r.requiresApproval) {
223
+ console.log(`\napproval required first:`);
224
+ console.log(` token ${r.requiresApproval.token}`);
225
+ console.log(` spender ${r.requiresApproval.spender}`);
226
+ }
227
+ r.transactions.forEach((t, i) => {
228
+ console.log(`\ntx ${i + 1}/${r.transactionCount}: chain ${t.chainId} -> ${t.to}`);
229
+ });
230
+ if (r.runnersUp?.length) console.log(`\nrunners-up: ${r.runnersUp.map((x) => x.source).join(", ")}`);
231
+ for (const w of r.warnings) console.log(`\nWARNING: ${w}`);
232
+ console.log(`\n${r.note}`);
233
+ break;
234
+ }
235
+
236
+ default:
237
+ console.log(
238
+ [
239
+ "oracle-route -- best-execution routing",
240
+ "",
241
+ " oracle-route swap <chain> <tokenIn> <tokenOut> [amountIn] [taker]",
242
+ " oracle-route bridge <fromChain> <toChain> [token] [amountIn] [taker]",
243
+ " oracle-route prepare <chain> <tokenIn> <tokenOut> <taker> [amountIn] [--source=X]",
244
+ " oracle-route prepare-bridge <fromChain> <toChain> <taker> [token] [amt] [--source=X]",
245
+ "",
246
+ " Ranks by NET output (gross minus gas and fees), not headline quote.",
247
+ " A route quoting more but costing more gas can lose. --json for detail.",
248
+ ].join("\n"),
249
+ );
250
+ process.exit(cmd ? 1 : 0);
251
+ }
252
+ } catch (err) {
253
+ die(String(err?.message || err));
254
+ }
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // oracle-scan -- exercise the chain-scanner framework from the shell.
3
+ //
4
+ // Useful for two things: checking a chain is actually reachable with your RPC
5
+ // config, and researching a token without wiring up an agent first.
6
+ //
7
+ // Usage:
8
+ // oracle-scan chains coverage matrix for every chain
9
+ // oracle-scan head <chain> current block
10
+ // oracle-scan token <chain> <address> on-chain ERC-20 identity
11
+ // oracle-scan pools <chain> <address> tradeable pools by liquidity
12
+ // oracle-scan risk <chain> <address> structural risk checks
13
+ //
14
+ // <chain> is a chain id (8453) or key (base).
15
+
16
+ import { registerBuiltinScanners } from "../src/scanner/chains.config.mjs";
17
+ import { getScanner, listScanners, scannerCoverage } from "../src/scanner/contract.mjs";
18
+
19
+ registerBuiltinScanners();
20
+
21
+ const [cmd, ...rest] = process.argv.slice(2);
22
+ const JSON_OUT = rest.includes("--json");
23
+ const args = rest.filter((a) => a !== "--json");
24
+
25
+ function resolve(ref) {
26
+ if (!ref) die("missing <chain>");
27
+ const byId = getScanner(Number(ref));
28
+ if (byId) return byId;
29
+ const byKey = listScanners().find((s) => s.key === String(ref).toLowerCase());
30
+ if (byKey) return byKey;
31
+ die(
32
+ `unknown chain "${ref}". Known: ${listScanners().map((s) => `${s.key}(${s.chainId})`).join(", ")}`,
33
+ );
34
+ }
35
+
36
+ function die(msg) {
37
+ console.error(`error: ${msg}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ function out(obj) {
42
+ process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
43
+ }
44
+
45
+ // A missing RPC is the most common reason a scan fails. Say so precisely instead of
46
+ // surfacing a generic fetch error.
47
+ function rpcHint(scanner, err) {
48
+ const msg = String(err?.message || err);
49
+ if (/rpc|fetch|ECONN|timeout|provider/i.test(msg)) {
50
+ return (
51
+ `${msg}\n\nThis chain reads its RPC from: ${scanner.rpcEnv.join(" or ")}\n` +
52
+ `Set one, e.g.: export ${scanner.rpcEnv[0]}=https://...`
53
+ );
54
+ }
55
+ return msg;
56
+ }
57
+
58
+ try {
59
+ switch (cmd) {
60
+ case "chains": {
61
+ const cov = scannerCoverage();
62
+ if (JSON_OUT) {
63
+ out(cov);
64
+ break;
65
+ }
66
+ console.log(`${cov.chainCount} chains registered\n`);
67
+ const rows = Object.entries(cov.chains).map(([id, c]) => ({
68
+ chain: `${c.key} (${id})`,
69
+ caps: c.supported.length,
70
+ routing: c.failClosed ? "fail-closed (no verified venue)" : `${c.venueCount} venue(s)`,
71
+ }));
72
+ const w = Math.max(...rows.map((r) => r.chain.length));
73
+ for (const r of rows) {
74
+ console.log(` ${r.chain.padEnd(w)} ${String(r.caps).padStart(2)} caps ${r.routing}`);
75
+ }
76
+ console.log(
77
+ "\nEvery chain is read/research capable. Routing value requires a verified\n" +
78
+ "venue -- see CONTRIBUTING.md for the verification rule.",
79
+ );
80
+ break;
81
+ }
82
+
83
+ case "head": {
84
+ const s = resolve(args[0]);
85
+ out(await s.blockNumber());
86
+ break;
87
+ }
88
+
89
+ case "token": {
90
+ const s = resolve(args[0]);
91
+ if (!args[1]) die("usage: oracle-scan token <chain> <address>");
92
+ out(await s.resolveToken(args[1]));
93
+ break;
94
+ }
95
+
96
+ case "pools": {
97
+ const s = resolve(args[0]);
98
+ if (!args[1]) die("usage: oracle-scan pools <chain> <address>");
99
+ const r = await s.resolvePools(args[1]);
100
+ if (JSON_OUT) {
101
+ out(r);
102
+ break;
103
+ }
104
+ console.log(`evidence: ${r.evidence}`);
105
+ if (r.reason) console.log(`reason: ${r.reason}`);
106
+ for (const p of r.pools.slice(0, 10)) {
107
+ const liq = p.liquidityUsd == null ? "?" : `$${Math.round(p.liquidityUsd).toLocaleString()}`;
108
+ console.log(` ${p.dex.padEnd(12)} ${liq.padStart(14)} ${p.pair}`);
109
+ }
110
+ if (!r.pools.length) console.log(" (no pools with liquidity found)");
111
+ break;
112
+ }
113
+
114
+ case "risk": {
115
+ const s = resolve(args[0]);
116
+ if (!args[1]) die("usage: oracle-scan risk <chain> <address>");
117
+ const r = await s.scoreRisk(args[1]);
118
+ if (JSON_OUT) {
119
+ out(r);
120
+ break;
121
+ }
122
+ console.log(`verdict: ${r.verdict}\n`);
123
+ for (const c of r.checks) {
124
+ console.log(` ${c.result.padEnd(8)} ${c.check.padEnd(16)} ${c.detail ?? ""}`);
125
+ }
126
+ console.log(`\n${r.note}`);
127
+ break;
128
+ }
129
+
130
+ case "quote": {
131
+ const s = resolve(args[0]);
132
+ if (!args[1] || !args[2]) die("usage: oracle-scan quote <chain> <tokenIn> <tokenOut> [amountIn]");
133
+ if (!s.supports("quote")) {
134
+ die(
135
+ `${s.key} has no verified router, so quoting stays fail-closed.\n` +
136
+ "Add a venue with recorded provenance -- see docs/adding-a-chain.md.",
137
+ );
138
+ }
139
+ const amountIn = args[3] || (10n ** 18n).toString();
140
+ out(await s.quote({ tokenIn: args[1], tokenOut: args[2], amountIn }));
141
+ break;
142
+ }
143
+
144
+ case "sell": {
145
+ // The single most valuable check on a low-cap venue: can you get OUT?
146
+ const s = resolve(args[0]);
147
+ if (!args[1]) die("usage: oracle-scan sell <chain> <token> [amountIn]");
148
+ if (!s.supports("sellSimulation")) {
149
+ die(
150
+ `${s.key} has no verified router, so sell simulation is unavailable.\n` +
151
+ "Without it, treat every token on this chain as unproven for exit.",
152
+ );
153
+ }
154
+ const amountIn = args[2] || (10n ** 16n).toString();
155
+ const r = await s.sellSimulation({ token: args[1], amountIn });
156
+ if (JSON_OUT) {
157
+ out(r);
158
+ break;
159
+ }
160
+ console.log(`verdict: ${r.verdict}`);
161
+ console.log(`reason: ${r.reason ?? ""}`);
162
+ if (r.retentionBps != null) {
163
+ console.log(`retention: ${(r.retentionBps / 100).toFixed(2)}%`);
164
+ }
165
+ if (r.leg) console.log(`failed at: ${r.leg} leg`);
166
+ if (r.note) console.log(`\n${r.note}`);
167
+ break;
168
+ }
169
+
170
+ default:
171
+ console.log(
172
+ [
173
+ "oracle-scan -- chain scanner CLI",
174
+ "",
175
+ " oracle-scan chains coverage matrix",
176
+ " oracle-scan head <chain> current block",
177
+ " oracle-scan token <chain> <address> ERC-20 identity",
178
+ " oracle-scan pools <chain> <address> pools by liquidity",
179
+ " oracle-scan risk <chain> <address> structural risk checks",
180
+ " oracle-scan quote <chain> <tIn> <tOut> [amt] live exact-input quote",
181
+ " oracle-scan sell <chain> <token> [amt] round-trip: can you exit?",
182
+ "",
183
+ " <chain> is an id (8453) or key (base). --json for machine output.",
184
+ " quote/sell need a chain with a verified router (see: oracle-scan chains).",
185
+ ].join("\n"),
186
+ );
187
+ process.exit(cmd ? 1 : 0);
188
+ }
189
+ } catch (err) {
190
+ const s = args[0] ? (getScanner(Number(args[0])) || listScanners().find((x) => x.key === args[0])) : null;
191
+ die(s ? rpcHint(s, err) : String(err?.message || err));
192
+ }
@@ -0,0 +1,229 @@
1
+ # Adding a chain
2
+
3
+ Oracle treats a chain as **configuration**. You do not write an integration.
4
+
5
+ ## The short version
6
+
7
+ ```js
8
+ import { registerCustomChain } from "@oracle-agent/oracle/scanner";
9
+
10
+ registerCustomChain({
11
+ key: "mychain",
12
+ chainId: 7777,
13
+ name: "My Chain",
14
+ rpcEnv: ["MYCHAIN_RPC_URL"],
15
+ nativeCurrency: { symbol: "MYC", decimals: 18 },
16
+ });
17
+ ```
18
+
19
+ That chain now has block reads, native and ERC-20 balances, on-chain token
20
+ resolution, log scanning, and structural risk checks. No code was written.
21
+
22
+ ```bash
23
+ export MYCHAIN_RPC_URL=https://rpc.mychain.example
24
+ oracle-scan head mychain
25
+ ```
26
+
27
+ ## Config fields
28
+
29
+ | Field | Required | Notes |
30
+ |---|---|---|
31
+ | `key` | yes | lowercase slug, used on the CLI |
32
+ | `chainId` | yes | positive integer |
33
+ | `name` | yes | display name |
34
+ | `rpcEnv` | yes | env var names, `UPPER_SNAKE`. Never a literal URL |
35
+ | `nativeCurrency` | no | `{ symbol, decimals }` |
36
+ | `explorer` | no | base URL |
37
+ | `dexscreenerSlug` | no | enables pool discovery — see below |
38
+ | `venues` | no | verified routers/quoters — required for routing value |
39
+
40
+ **RPC URLs live in the environment, never in config.** A repo carrying an endpoint
41
+ is both a leak and a rate-limit problem for whoever else uses that key.
42
+
43
+ ## Why `dexscreenerSlug` matters
44
+
45
+ Pool discovery goes through market data rather than walking a factory. A factory
46
+ walk finds pools that *exist*; a trader needs pools with *liquidity*.
47
+
48
+ The slug is DexScreener's own chain identifier — not the chain id, and not always
49
+ the obvious name. If you don't know it, **leave it out**. Pool discovery then
50
+ reports `UNAVAILABLE`, which is honest. A wrong slug silently returns another
51
+ chain's pools, which is worse than no answer.
52
+
53
+ ## Adding venues (routing value)
54
+
55
+ A chain with no verified venue is **fail-closed**: read and research work, routing
56
+ value does not. That is a safe default, not a gap.
57
+
58
+ To enable routing you must record *provenance*:
59
+
60
+ ```js
61
+ venues: [
62
+ {
63
+ kind: "router",
64
+ address: "0x...",
65
+ verified: {
66
+ method: "eth_getCode + protocol /chains API",
67
+ source: "https://li.quest/v1/chains",
68
+ date: "2026-07-30",
69
+ },
70
+ },
71
+ ]
72
+ ```
73
+
74
+ Validation **refuses** a venue without `verified`. This is not bureaucracy: the
75
+ destination allowlist is the highest-leverage control in the system, and
76
+ allowlisting a spoofed router makes every other guard irrelevant.
77
+
78
+ ### How to actually verify
79
+
80
+ 1. **Bytecode exists** — `eth_getCode` must return real code. `0x` means nothing is
81
+ deployed at that address on that chain.
82
+ 2. **The protocol's own API or docs** names that address, for that chain, today.
83
+ Query the source (e.g. LI.FI's `/v1/chains` returns each chain's official
84
+ `diamondAddress`) rather than trusting a blog post or a model's recollection.
85
+ 3. **Constructor-bound addresses match** — read `factory()`, `WETH9()` back off the
86
+ deployed contract and confirm they're what you expect.
87
+ 4. **Per chain, separately.** An address verified on Arbitrum is not verified on
88
+ Ethereum, even when the canonical deployment shares an address across chains.
89
+
90
+ Record how and when. The audit trail has to outlive your session.
91
+
92
+ ## Capabilities
93
+
94
+ Ten capabilities exist. The generic EVM scanner provides seven out of the box:
95
+
96
+ | Capability | Generic? | Why |
97
+ |---|---|---|
98
+ | `blockNumber` | yes | standard RPC |
99
+ | `nativeBalance` | yes | standard RPC |
100
+ | `tokenBalance` | yes | ERC-20 is ERC-20 |
101
+ | `resolveToken` | yes | reads metadata off the contract |
102
+ | `resolvePools` | yes* | needs `dexscreenerSlug` |
103
+ | `scanBlocks` | yes | chunked `eth_getLogs` |
104
+ | `scoreRisk` | yes | structural checks |
105
+ | `quote` | venue | needs a verified router |
106
+ | `sellSimulation` | venue | needs a verified router |
107
+ | `prepareUnsignedTx` | venue | needs a verified router |
108
+
109
+ Calling an unimplemented capability **throws**, naming what is supported. It never
110
+ returns `undefined` that a caller could mistake for "no result."
111
+
112
+ ## Enabling quote / sell / prepare
113
+
114
+ Add a verified router plus two fields:
115
+
116
+ ```js
117
+ {
118
+ key: "base",
119
+ chainId: 8453,
120
+ // ...
121
+ venueKind: "uniswap-v2",
122
+ wrappedNative: "0x4200000000000000000000000000000000000006",
123
+ venues: [{ kind: "router", address: "0x...", verified: { /* ... */ } }],
124
+ }
125
+ ```
126
+
127
+ `src/scanner/chains.config.mjs` ships **Base** wired this way as the reference. It
128
+ gets all 10 capabilities; every other built-in stays at 7 and fail-closed until
129
+ someone verifies a venue for it.
130
+
131
+ What you get:
132
+
133
+ - **`quote`** — live exact-input pricing via `getAmountsOut`
134
+ - **`sellSimulation`** — a state-free round trip (buy then sell back) that catches
135
+ the honeypot signature: buy leg quotes fine, sell leg fails. This is the single
136
+ most valuable check on a low-cap venue, because most losses are not bad entries
137
+ but tokens that cannot be exited
138
+ - **`prepareUnsignedTx`** — an **unsigned** swap with a `minOut` computed from the
139
+ live quote, a deadline, and an explicit `approve` step listed separately so
140
+ "approved" and "swapped" are never collapsed into one claim
141
+
142
+ `scoreRisk` also upgrades automatically: with a router it reports real sellability
143
+ instead of `UNKNOWN`.
144
+
145
+ ### The slippage ceiling is hard
146
+
147
+ A supplied `slippageBps` is a **maximum**, capped at 100 bps. Above that,
148
+ `prepareUnsignedTx` throws rather than widening:
149
+
150
+ ```
151
+ slippageBps 250 exceeds the 100 bps ceiling. Block and requote or split
152
+ the order -- do not widen the guard to force a fill.
153
+ ```
154
+
155
+ A cap that yields under pressure is decoration.
156
+
157
+ ### V3 / V4 / custom quoters
158
+
159
+ Two adapters ship:
160
+
161
+ | `venueKind` | Requires | Prices via |
162
+ |---|---|---|
163
+ | `uniswap-v2` | `router` | `getAmountsOut` on the router |
164
+ | `uniswap-v3` | `quoter` **and** `router` | `quoteExactInputSingle` on QuoterV2 |
165
+
166
+ V3 is **not** V2 with different addresses: it has no `getAmountsOut`, prices through
167
+ a separate Quoter contract, and needs an explicit fee tier per hop. Encoding a V3
168
+ swap with V2 assumptions yields a transaction that reverts, or worse routes through
169
+ the wrong pool. That is why they are sibling adapters.
170
+
171
+ The V3 adapter searches all four fee tiers (100 / 500 / 3000 / 10000) and keeps the
172
+ best. This is necessary, not a nicety — liquidity concentrates in one tier and which
173
+ tier varies by pair and chain. On Arbitrum WETH→USDC the 500 tier returns ~2.4% more
174
+ than the 100 tier; defaulting to 0.3% because it is the common choice silently
175
+ misprices stable and exotic pairs alike.
176
+
177
+ A V3 chain needs **both** a quoter and a router. A router alone stays fail-closed:
178
+ it could encode a swap, but with no priced expectation to guard against — exactly
179
+ the state that produces an unbounded fill.
180
+
181
+ **V4 and custom-quoter venues still need their own adapter.** Some forks require an
182
+ off-chain signed quote you cannot produce locally. Write a sibling module rather
183
+ than bending an existing one; silently mis-encoding a swap is far worse than
184
+ declaring the capability unsupported.
185
+
186
+ ### Verifying a venue: run the prober
187
+
188
+ `scripts/verify-v3-venues.mjs` probes candidate addresses on every chain and prints
189
+ a pass/fail with the live price it got:
190
+
191
+ ```bash
192
+ node scripts/verify-v3-venues.mjs
193
+ ```
194
+
195
+ **Codesize is not verification.** The canonical mainnet QuoterV2 address returns
196
+ 2109 bytes of bytecode on Base — it is *some* contract, just not a working quoter
197
+ for that chain. A codesize check waves it through; the functional probe caught it
198
+ and pointed at Base's real quoter (8273 bytes, prices WETH→USDC correctly).
199
+
200
+ The rule: **a contract that correctly prices a pair you can sanity-check IS the
201
+ thing you think it is.** Anything less is a guess with an address attached.
202
+
203
+ ## Honest evidence
204
+
205
+ Every finding carries a label. Respect the distinction:
206
+
207
+ - `LIVE` — read this call
208
+ - `CACHED` — recent, within stated TTL
209
+ - `STALE` — older than TTL; usable only with the caveat stated
210
+ - `UNKNOWN` — we tried and could not determine it
211
+ - `UNAVAILABLE` — this chain/provider cannot answer at all
212
+
213
+ `UNKNOWN` and `UNAVAILABLE` are different facts. Collapsing them is how people end
214
+ up sizing against stale reserves.
215
+
216
+ Risk verdicts are coarse on purpose — `PASS` / `CAUTION` / `FAIL` / `UNKNOWN`. A
217
+ score like "risk 62/100" invites trading a number nobody understands.
218
+
219
+ **`UNKNOWN` is not `PASS`.** The generic scanner returns `UNKNOWN` for sellability
220
+ because it cannot prove exitability without a verified router. A token is not
221
+ tradeable until a round-trip sell simulation succeeds.
222
+
223
+ ## Contributing a chain upstream
224
+
225
+ Append to `CHAIN_CONFIGS` in `src/scanner/chains.config.mjs` and open a PR. Tests
226
+ already assert every built-in config validates and that none embeds an RPC URL, so
227
+ CI will catch a malformed entry.
228
+
229
+ If you add venues, put the verification method and date in the PR body.