@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,69 @@
1
+ // Oracle Control — human-readable grant renderer (Slice A).
2
+ //
3
+ // Renders a validated public grant into deterministic plain text a user can
4
+ // review before approving. Rendering is a PRESENTATION of the deterministic
5
+ // policy only — it never adds, removes, or reinterprets authority, and it only
6
+ // ever surfaces public grant material (no keys, tokens, paths, or secrets).
7
+ //
8
+ // Output is byte-for-byte deterministic for a given grant: fixed field order,
9
+ // fixed labels, UTC timestamps derived purely from expiresAt, and the sha256
10
+ // grant id from the canonical form. Tests assert exact output.
11
+
12
+ import { normalizeGrant, grantId, isReadonlyAction, GRANT_VERSION } from "./policy-schema.mjs";
13
+
14
+ const LABEL_WIDTH = 14;
15
+
16
+ function row(label, value) {
17
+ return `${(label + ":").padEnd(LABEL_WIDTH)}${value}`;
18
+ }
19
+
20
+ /** Deterministic UTC ISO string from unix seconds. */
21
+ function isoUtc(unixSeconds) {
22
+ return new Date(unixSeconds * 1000).toISOString();
23
+ }
24
+
25
+ /** "50 bps (0.50%)" — deterministic fixed-2 percentage. */
26
+ export function formatBps(bps) {
27
+ return `${bps} bps (${(bps / 100).toFixed(2)}%)`;
28
+ }
29
+
30
+ /** Group wei digits for readability: 1000000 -> "1,000,000". Deterministic. */
31
+ export function formatWei(weiString) {
32
+ const s = String(weiString);
33
+ return `${s.replace(/\B(?=(\d{3})+(?!\d))/g, ",")} wei`;
34
+ }
35
+
36
+ /**
37
+ * Render a grant as deterministic human-readable text.
38
+ * Validates/normalizes first (throws GrantValidationError on bad input), so a
39
+ * grant that cannot be rendered is a grant that cannot be authorized.
40
+ */
41
+ export function renderGrant(input, opts = {}) {
42
+ const g = normalizeGrant(input, opts);
43
+ const id = grantId(g);
44
+ const readonlyOnly = g.actions.every((a) => isReadonlyAction(a));
45
+
46
+ const lines = [
47
+ "ORACLE CONTROL GRANT (deterministic authorization)",
48
+ "==================================================",
49
+ row("Grant ID", id),
50
+ row("Version", String(GRANT_VERSION)),
51
+ row("Chain", String(g.chainId)),
52
+ row("Agent", g.agentAddress),
53
+ row("Account", g.accountAddress),
54
+ row("Actions", g.actions.join(", ")),
55
+ row("Targets", g.targets.length ? g.targets.join(", ") : "(none — read/simulate only)"),
56
+ row("Max value", formatWei(g.maxValueWei)),
57
+ row("Max gas", formatWei(g.maxGasWei)),
58
+ row("Max slippage", formatBps(g.maxSlippageBps)),
59
+ row("Expires", `${isoUtc(g.expiresAt)} (unix ${g.expiresAt})`),
60
+ row("Nonce", g.nonce),
61
+ row("Revocation", g.revocationKey),
62
+ "--------------------------------------------------",
63
+ readonlyOnly
64
+ ? "Scope: read/simulate only — this grant cannot move funds."
65
+ : "Scope: includes state-changing actions bounded by the caps above.",
66
+ "Self-custodial: this grant contains public data only and can be revoked at any time via the revocation key.",
67
+ ];
68
+ return lines.join("\n");
69
+ }
@@ -0,0 +1,318 @@
1
+ // Oracle Control — public grant policy schema (Slice A).
2
+ //
3
+ // Oracle Control is DETERMINISTIC AUTHORIZATION: a grant either validates or it
4
+ // does not, with no advisory judgement involved (that is Oracle Router's job,
5
+ // and Router output must never gate anything here).
6
+ //
7
+ // This module is dependency-free (node builtins only, no npm packages) and
8
+ // fully deterministic: same input object => same normalized grant => same
9
+ // canonical string => same grant id, regardless of key order, address casing,
10
+ // array ordering, or numeric representation (number / bigint / decimal string).
11
+ //
12
+ // Self-custody boundary: a grant only ever references PUBLIC material —
13
+ // chain id, the agent's public address, the user's own account address,
14
+ // action scopes, target contracts, spend/gas/slippage ceilings, expiry,
15
+ // nonce, and a public revocation key identifier. Nothing here may carry or
16
+ // derive private keys, bearer tokens, keystore paths, or session secrets.
17
+ //
18
+ // Fail-closed rules enforced by validateGrant / normalizeGrant:
19
+ // - every required field must be present (expiry, chain, agent, account, ...)
20
+ // - unknown fields are rejected (no smuggling extra authority)
21
+ // - broad wildcard actions are rejected by default ("*", "*:*", "verb:*")
22
+ // - empty targets are only allowed when EVERY action is read/simulate scoped
23
+ // - expiresAt is required and, when a reference `now` is supplied, must be
24
+ // strictly in the future
25
+
26
+ import { createHash } from "node:crypto";
27
+
28
+ export const GRANT_VERSION = 1;
29
+
30
+ /** Every field is required. Order here is documentation only; canonical form
31
+ * always sorts keys alphabetically. */
32
+ export const REQUIRED_FIELDS = Object.freeze([
33
+ "chainId",
34
+ "agentAddress",
35
+ "accountAddress",
36
+ "actions",
37
+ "targets",
38
+ "maxValueWei",
39
+ "maxGasWei",
40
+ "maxSlippageBps",
41
+ "expiresAt",
42
+ "nonce",
43
+ "revocationKey",
44
+ ]);
45
+
46
+ /** Action verbs that never touch state; only these may have empty targets. */
47
+ export const READONLY_ACTION_VERBS = Object.freeze(["read", "simulate"]);
48
+
49
+ export const MAX_SLIPPAGE_BPS = 10_000;
50
+ export const MAX_NONCE_LENGTH = 128;
51
+ export const MAX_REVOCATION_KEY_LENGTH = 256;
52
+ export const MAX_ACTIONS = 64;
53
+ export const MAX_TARGETS = 256;
54
+
55
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
56
+ // verb:resource — lowercase verb, resource may carry dots/dashes/underscores.
57
+ const ACTION_RE = /^[a-z][a-z0-9_-]*:[a-z0-9._-]+$/;
58
+ // wildcard-bearing action (only accepted when explicitly opted in, and never
59
+ // with a wildcard verb).
60
+ const ACTION_WILDCARD_RE = /^[a-z][a-z0-9_-]*:\*$/;
61
+ const NONCE_RE = /^[A-Za-z0-9._:-]+$/;
62
+ const REVOCATION_KEY_RE = /^[A-Za-z0-9._:-]+$/;
63
+ const DECIMAL_RE = /^(0|[1-9][0-9]*)$/;
64
+
65
+ export class GrantValidationError extends Error {
66
+ constructor(errors) {
67
+ const lines = errors.map((e) => `${e.field}: ${e.message}`);
68
+ super(`invalid grant (${errors.length} error${errors.length === 1 ? "" : "s"}): ${lines.join("; ")}`);
69
+ this.name = "GrantValidationError";
70
+ this.errors = errors;
71
+ }
72
+ }
73
+
74
+ function isPlainObject(v) {
75
+ return v != null && typeof v === "object" && !Array.isArray(v);
76
+ }
77
+
78
+ /** Parse a non-negative integer amount (number | bigint | decimal string)
79
+ * into a canonical decimal string. Returns null when unparseable. */
80
+ function toAmountString(v) {
81
+ if (typeof v === "bigint") {
82
+ return v >= 0n ? v.toString(10) : null;
83
+ }
84
+ if (typeof v === "number") {
85
+ if (!Number.isSafeInteger(v) || v < 0) return null;
86
+ return String(v);
87
+ }
88
+ if (typeof v === "string") {
89
+ const s = v.trim();
90
+ if (!DECIMAL_RE.test(s)) return null;
91
+ return s;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function toSafeInt(v) {
97
+ if (typeof v === "bigint") {
98
+ if (v < 0n || v > BigInt(Number.MAX_SAFE_INTEGER)) return null;
99
+ return Number(v);
100
+ }
101
+ if (typeof v === "number") {
102
+ if (!Number.isSafeInteger(v) || v < 0) return null;
103
+ return v;
104
+ }
105
+ if (typeof v === "string") {
106
+ const s = v.trim();
107
+ if (!DECIMAL_RE.test(s)) return null;
108
+ const n = Number(s);
109
+ return Number.isSafeInteger(n) ? n : null;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** True when the action verb is read-only (read/simulate). */
115
+ export function isReadonlyAction(action) {
116
+ const verb = String(action).split(":")[0];
117
+ return READONLY_ACTION_VERBS.includes(verb);
118
+ }
119
+
120
+ /**
121
+ * Validate a grant. Never throws; returns { ok, errors, grant } where `grant`
122
+ * is the normalized form when ok, else null.
123
+ *
124
+ * Options:
125
+ * allowWildcardActions (default false) — permit "verb:*" actions. Broad
126
+ * wildcards ("*", "*:*", wildcard verbs) are ALWAYS
127
+ * rejected regardless of this flag.
128
+ * now (unix seconds) — when provided, expiresAt must be
129
+ * strictly greater than it. Pass explicitly for
130
+ * deterministic validation; omitted means no liveness
131
+ * check (pure shape validation).
132
+ */
133
+ export function validateGrant(input, opts = {}) {
134
+ const errors = [];
135
+ const push = (field, message) => errors.push({ field, message });
136
+
137
+ if (!isPlainObject(input)) {
138
+ push("grant", "grant must be a plain object");
139
+ return { ok: false, errors, grant: null };
140
+ }
141
+
142
+ // Fail closed on unknown fields.
143
+ for (const key of Object.keys(input)) {
144
+ if (!REQUIRED_FIELDS.includes(key)) push(key, "unknown field is not allowed");
145
+ }
146
+ for (const key of REQUIRED_FIELDS) {
147
+ if (!(key in input) || input[key] == null) push(key, "required field is missing");
148
+ }
149
+ if (errors.length) return { ok: false, errors, grant: null };
150
+
151
+ const out = {};
152
+
153
+ // chainId
154
+ const chainId = toSafeInt(input.chainId);
155
+ if (chainId == null || chainId <= 0) push("chainId", "must be a positive integer chain id");
156
+ else out.chainId = chainId;
157
+
158
+ // addresses
159
+ for (const field of ["agentAddress", "accountAddress"]) {
160
+ const v = input[field];
161
+ if (typeof v !== "string" || !ADDRESS_RE.test(v.trim())) {
162
+ push(field, "must be a 0x-prefixed 20-byte hex address");
163
+ } else {
164
+ out[field] = v.trim().toLowerCase();
165
+ }
166
+ }
167
+
168
+ // actions
169
+ if (!Array.isArray(input.actions) || input.actions.length === 0) {
170
+ push("actions", "must be a non-empty array of action scopes");
171
+ } else if (input.actions.length > MAX_ACTIONS) {
172
+ push("actions", `too many actions (max ${MAX_ACTIONS})`);
173
+ } else {
174
+ const actions = [];
175
+ for (const raw of input.actions) {
176
+ const a = typeof raw === "string" ? raw.trim() : null;
177
+ if (!a) {
178
+ push("actions", "action entries must be non-empty strings");
179
+ continue;
180
+ }
181
+ if (a.includes("*")) {
182
+ if (opts.allowWildcardActions === true && ACTION_WILDCARD_RE.test(a)) {
183
+ actions.push(a);
184
+ } else {
185
+ push("actions", `broad wildcard action "${a}" is rejected (fail closed)`);
186
+ }
187
+ continue;
188
+ }
189
+ if (!ACTION_RE.test(a)) {
190
+ push("actions", `malformed action "${a}" (expected verb:resource)`);
191
+ continue;
192
+ }
193
+ actions.push(a);
194
+ }
195
+ out.actions = [...new Set(actions)].sort();
196
+ }
197
+
198
+ // targets
199
+ if (!Array.isArray(input.targets)) {
200
+ push("targets", "must be an array of contract addresses");
201
+ } else if (input.targets.length > MAX_TARGETS) {
202
+ push("targets", `too many targets (max ${MAX_TARGETS})`);
203
+ } else {
204
+ const targets = [];
205
+ for (const raw of input.targets) {
206
+ const t = typeof raw === "string" ? raw.trim() : null;
207
+ if (!t || !ADDRESS_RE.test(t)) {
208
+ push("targets", `target "${raw}" must be a 0x-prefixed 20-byte hex address (wildcards not allowed)`);
209
+ continue;
210
+ }
211
+ targets.push(t.toLowerCase());
212
+ }
213
+ out.targets = [...new Set(targets)].sort();
214
+ if (out.targets.length === 0 && Array.isArray(out.actions)) {
215
+ const nonReadonly = out.actions.filter((a) => !isReadonlyAction(a));
216
+ if (nonReadonly.length > 0 || out.actions.length === 0) {
217
+ push(
218
+ "targets",
219
+ `empty targets are only allowed for read/simulate scopes (blocked by: ${nonReadonly.join(", ") || "no valid actions"})`
220
+ );
221
+ }
222
+ }
223
+ }
224
+
225
+ // amounts
226
+ for (const field of ["maxValueWei", "maxGasWei"]) {
227
+ const amt = toAmountString(input[field]);
228
+ if (amt == null) push(field, "must be a non-negative integer wei amount");
229
+ else out[field] = amt;
230
+ }
231
+
232
+ // slippage
233
+ const bps = toSafeInt(input.maxSlippageBps);
234
+ if (bps == null || bps > MAX_SLIPPAGE_BPS) {
235
+ push("maxSlippageBps", `must be an integer between 0 and ${MAX_SLIPPAGE_BPS}`);
236
+ } else {
237
+ out.maxSlippageBps = bps;
238
+ }
239
+
240
+ // expiry — required (checked above); must be a positive unix-seconds int and,
241
+ // when `now` is supplied, strictly in the future.
242
+ // Date-renderable ceiling in unix seconds. JS Date maxes at 8.64e15 ms, so a
243
+ // seconds value above 8.64e12 overflows the renderer (isoUtc does *1000).
244
+ // Also well within the AA lane's uint48 bound, so a schema-valid expiry always
245
+ // fits both the on-chain validUntil field and the human render. Reject rather
246
+ // than render a bogus far-future date (fail closed on the seconds-vs-ms mistake).
247
+ const MAX_UNIX_SECONDS = 8_640_000_000_000;
248
+ const expiresAt = toSafeInt(input.expiresAt);
249
+ if (expiresAt == null || expiresAt <= 0) {
250
+ push("expiresAt", "must be a positive unix-seconds timestamp");
251
+ } else if (expiresAt > MAX_UNIX_SECONDS) {
252
+ push("expiresAt", "exceeds the max unix-seconds timestamp (pass seconds, not milliseconds)");
253
+ } else {
254
+ out.expiresAt = expiresAt;
255
+ if (opts.now != null) {
256
+ const now = toSafeInt(opts.now);
257
+ if (now == null) push("expiresAt", "opts.now must be a unix-seconds integer");
258
+ else if (expiresAt <= now) push("expiresAt", `grant is expired (expiresAt ${expiresAt} <= now ${now})`);
259
+ }
260
+ }
261
+
262
+ // nonce
263
+ const nonce =
264
+ typeof input.nonce === "string"
265
+ ? input.nonce.trim()
266
+ : toSafeInt(input.nonce) != null
267
+ ? String(toSafeInt(input.nonce))
268
+ : null;
269
+ if (!nonce || nonce.length > MAX_NONCE_LENGTH || !NONCE_RE.test(nonce)) {
270
+ push("nonce", `must be a non-empty string (max ${MAX_NONCE_LENGTH} chars, [A-Za-z0-9._:-])`);
271
+ } else {
272
+ out.nonce = nonce;
273
+ }
274
+
275
+ // revocationKey — a PUBLIC identifier used to revoke this grant; never a secret.
276
+ const rk = typeof input.revocationKey === "string" ? input.revocationKey.trim() : null;
277
+ if (!rk || rk.length > MAX_REVOCATION_KEY_LENGTH || !REVOCATION_KEY_RE.test(rk)) {
278
+ push("revocationKey", `must be a non-empty public identifier (max ${MAX_REVOCATION_KEY_LENGTH} chars, [A-Za-z0-9._:-])`);
279
+ } else {
280
+ out.revocationKey = rk;
281
+ }
282
+
283
+ if (errors.length) return { ok: false, errors, grant: null };
284
+ return { ok: true, errors: [], grant: out };
285
+ }
286
+
287
+ /** Validate + normalize; throws GrantValidationError on any failure. */
288
+ export function normalizeGrant(input, opts = {}) {
289
+ const { ok, errors, grant } = validateGrant(input, opts);
290
+ if (!ok) throw new GrantValidationError(errors);
291
+ return grant;
292
+ }
293
+
294
+ /**
295
+ * Deterministic canonical JSON for a grant: validates/normalizes first, then
296
+ * serializes with alphabetically sorted keys (arrays already sorted + deduped
297
+ * by normalization). Identical grants — regardless of input key order, address
298
+ * casing, or numeric representation — always produce the identical string.
299
+ */
300
+ export function canonicalizeGrant(input, opts = {}) {
301
+ const g = normalizeGrant(input, opts);
302
+ const keys = Object.keys(g).sort();
303
+ const parts = keys.map((k) => `${JSON.stringify(k)}:${JSON.stringify(g[k])}`);
304
+ return `{"version":${GRANT_VERSION},${parts.join(",")}}`;
305
+ }
306
+
307
+ /** sha256 hex of the canonical form — stable public identifier for a grant. */
308
+ export function grantId(input, opts = {}) {
309
+ return createHash("sha256").update(canonicalizeGrant(input, opts), "utf8").digest("hex");
310
+ }
311
+
312
+ /** Deterministic expiry check against an explicit unix-seconds `now`. */
313
+ export function isExpired(grant, nowSeconds) {
314
+ const now = toSafeInt(nowSeconds);
315
+ const exp = toSafeInt(isPlainObject(grant) ? grant.expiresAt : null);
316
+ if (now == null || exp == null) return true; // fail closed
317
+ return exp <= now;
318
+ }
@@ -0,0 +1,265 @@
1
+ // Oracle Control — public runtime config: chain/bundler/paymaster registry
2
+ // (Slice K).
3
+ //
4
+ // This module is pure config parsing. It never makes a live network call and
5
+ // never imports the private executor stack — its only job is to turn env
6
+ // vars into a validated, in-memory registry of PUBLIC endpoints (chain
7
+ // metadata, bundler URLs, paymaster URLs, and the public HTTP bind) that the
8
+ // rest of Oracle Control (aa-adapter.mjs, bundler-client.mjs, http.mjs) can
9
+ // read from.
10
+ //
11
+ // Hard boundaries for this file:
12
+ // - Imports ONLY from ./aa-adapter.mjs (ENTRYPOINT_V07 — the single source
13
+ // of truth for the deployed EntryPoint address this codebase targets)
14
+ // and node builtins. NEVER from the private executor stack
15
+ // (get-signer.mjs, keystore.mjs, exec-policy.mjs, local-signer/*,
16
+ // adapters/*, mint-capability.mjs) — this module must never be wired to
17
+ // private operator wallet, and holds no secrets of any kind.
18
+ // - bundlerUrl/paymasterUrl are treated as PUBLIC endpoints (the same way a
19
+ // public RPC URL is public) — but they are still scanned for anything
20
+ // secret-SHAPED (a bearer token, an api-key-looking query param, a raw
21
+ // private-key hex blob, a PEM block) and rejected outright if found.
22
+ // Fail closed: a config that looks like it's carrying a credential in a
23
+ // URL must never load.
24
+ // - resolveChain() fails closed on any chainId not in the registry — no
25
+ // silent fallback to a made-up chain.
26
+ // - entryPoint is never read from env or invented; it is always pinned to
27
+ // aa-adapter.mjs's ENTRYPOINT_V07 and asserted equal for every seeded
28
+ // chain, so a typo/drift in this file can never point a chain at the
29
+ // wrong EntryPoint deployment.
30
+ // - redactedConfig() strips the query string (and any embedded userinfo)
31
+ // off every bundler/paymaster URL before it is considered "safe to log"
32
+ // — even though the full URL already passed the secret-shape scan at
33
+ // load time, a log line is a wider blast radius than an in-memory
34
+ // config object, so this view is deliberately more conservative.
35
+
36
+ import { ENTRYPOINT_V07 } from "./aa-adapter.mjs";
37
+
38
+ export class RuntimeConfigError extends Error {
39
+ constructor(message) {
40
+ super(`runtime-config: ${message}`);
41
+ this.name = "RuntimeConfigError";
42
+ }
43
+ }
44
+
45
+ function fail(message) {
46
+ throw new RuntimeConfigError(message);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Secret-shape guard for public endpoint URLs (fail closed)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** String-value shapes that must never appear in a bundler/paymaster URL.
54
+ * Mirrors the posture of connect-agent.mjs's FORBIDDEN_VALUE_RULES /
55
+ * bundler-client.mjs's SecretLeakError checks, scoped to what a URL could
56
+ * plausibly carry (query-string credentials, embedded key material). */
57
+ const SECRET_URL_RULES = Object.freeze([
58
+ // 32-byte hex — the shape of a raw EVM private key / session secret.
59
+ { rule: "raw-32-byte-hex-key", re: /0x[0-9a-fA-F]{64}/ },
60
+ { rule: "bearer-token", re: /bearer\s+[A-Za-z0-9._~+/=-]+/i },
61
+ { rule: "pem-private-key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----/ },
62
+ { rule: "authorization-header", re: /authorization\s*:/i },
63
+ // Credential-shaped query/path segment: ?api_key=..., ?token=..., ?secret=...,
64
+ // ?password=..., ?auth=..., ?access_token=..., ?bearer=..., or userinfo
65
+ // (user:pass@host) embedded in the URL itself.
66
+ {
67
+ rule: "credential-shaped-param",
68
+ re: /[?&](api[_-]?key|apikey|key|token|access[_-]?token|secret|password|passwd|auth|bearer)=/i,
69
+ },
70
+ { rule: "embedded-userinfo", re: /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+:[^/@\s]+@/i },
71
+ ]);
72
+
73
+ function assertPublicUrlSafe(value, label) {
74
+ if (typeof value !== "string" || value.trim() === "") {
75
+ fail(`${label} must be a non-empty string URL`);
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = new URL(value);
80
+ } catch {
81
+ fail(`${label} is not a valid URL: ${value}`);
82
+ }
83
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
84
+ fail(`${label} must be an http(s) URL, got scheme ${parsed.protocol}`);
85
+ }
86
+ for (const { rule, re } of SECRET_URL_RULES) {
87
+ if (re.test(value)) {
88
+ fail(`${label} looks like it carries secret material (rule: ${rule}) — refusing to load a credential-shaped public endpoint`);
89
+ }
90
+ }
91
+ return value;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Chain registry seed
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** Base metadata for every chain Oracle Control's public runtime config
99
+ * knows about out of the box. entryPoint is intentionally NOT stored here —
100
+ * it is always ENTRYPOINT_V07, asserted below, so there is exactly one
101
+ * place (aa-adapter.mjs) that can ever define it. */
102
+ const CHAIN_SEEDS = Object.freeze([
103
+ { chainId: 8453, name: "Base" },
104
+ { chainId: 42161, name: "Arbitrum" },
105
+ ]);
106
+
107
+ export const DEFAULT_CHAIN_ID = 8453;
108
+ export const DEFAULT_PUBLIC_HOST = "127.0.0.1";
109
+ export const DEFAULT_PUBLIC_PORT = 8799;
110
+
111
+ function buildChainEntry(seed, env) {
112
+ const entry = {
113
+ name: seed.name,
114
+ entryPoint: ENTRYPOINT_V07,
115
+ };
116
+ // entryPoint must always equal aa-adapter's ENTRYPOINT_V07 for every
117
+ // supported chain — asserted here (not just "set here") so a future edit
118
+ // that tries to override it per-chain is caught immediately.
119
+ if (entry.entryPoint !== ENTRYPOINT_V07) {
120
+ fail(`chain ${seed.chainId} entryPoint drifted from aa-adapter ENTRYPOINT_V07`);
121
+ }
122
+
123
+ const bundlerVar = `ORACLE_BUNDLER_URL_${seed.chainId}`;
124
+ const paymasterVar = `ORACLE_PAYMASTER_URL_${seed.chainId}`;
125
+ const bundlerRaw = env[bundlerVar];
126
+ const paymasterRaw = env[paymasterVar];
127
+
128
+ if (bundlerRaw != null && String(bundlerRaw).trim() !== "") {
129
+ entry.bundlerUrl = assertPublicUrlSafe(String(bundlerRaw), bundlerVar);
130
+ }
131
+ if (paymasterRaw != null && String(paymasterRaw).trim() !== "") {
132
+ entry.paymasterUrl = assertPublicUrlSafe(String(paymasterRaw), paymasterVar);
133
+ }
134
+
135
+ return entry;
136
+ }
137
+
138
+ function parsePort(raw, label) {
139
+ const n = Number(raw);
140
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
141
+ fail(`${label} must be an integer port 0-65535, got ${JSON.stringify(raw)}`);
142
+ }
143
+ return n;
144
+ }
145
+
146
+ /**
147
+ * Parse the public runtime config (chain/bundler/paymaster registry + public
148
+ * HTTP bind) from an env-like object. Pure function, no I/O, no network.
149
+ *
150
+ * @param {object} [env] defaults to process.env; any plain object of
151
+ * string-ish values works (this is what makes it deterministically
152
+ * testable without touching real process.env).
153
+ * @returns {{
154
+ * chains: Record<string, {name:string, entryPoint:string, bundlerUrl?:string, paymasterUrl?:string}>,
155
+ * defaultChainId: number,
156
+ * publicHost: string,
157
+ * publicPort: number,
158
+ * }}
159
+ */
160
+ export function loadPublicConfig(env = process.env) {
161
+ if (env == null || typeof env !== "object") {
162
+ fail("env must be an object");
163
+ }
164
+
165
+ const chains = {};
166
+ for (const seed of CHAIN_SEEDS) {
167
+ chains[String(seed.chainId)] = buildChainEntry(seed, env);
168
+ }
169
+
170
+ const rawDefaultChainId = env.ORACLE_DEFAULT_CHAIN_ID;
171
+ let defaultChainId = DEFAULT_CHAIN_ID;
172
+ if (rawDefaultChainId != null && String(rawDefaultChainId).trim() !== "") {
173
+ const n = Number(rawDefaultChainId);
174
+ if (!Number.isInteger(n)) {
175
+ fail(`ORACLE_DEFAULT_CHAIN_ID must be an integer chain id, got ${JSON.stringify(rawDefaultChainId)}`);
176
+ }
177
+ if (chains[String(n)] == null) {
178
+ fail(`ORACLE_DEFAULT_CHAIN_ID ${n} is not a registered chain (known: ${Object.keys(chains).join(", ")})`);
179
+ }
180
+ defaultChainId = n;
181
+ }
182
+
183
+ const publicHost = (() => {
184
+ const v = env.ORACLE_PUBLIC_HOST ?? env.MAD_PUBLIC_HOST;
185
+ return v != null && String(v).trim() !== "" ? String(v) : DEFAULT_PUBLIC_HOST;
186
+ })();
187
+
188
+ const publicPort = (() => {
189
+ const v = env.ORACLE_PUBLIC_PORT ?? env.MAD_PUBLIC_PORT;
190
+ if (v == null || String(v).trim() === "") return DEFAULT_PUBLIC_PORT;
191
+ return parsePort(v, "ORACLE_PUBLIC_PORT");
192
+ })();
193
+
194
+ return Object.freeze({
195
+ chains: Object.freeze(chains),
196
+ defaultChainId,
197
+ publicHost,
198
+ publicPort,
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Resolve a single chain entry from a loaded config. Fails closed on any
204
+ * chainId not present in the registry — never falls back to a guessed/
205
+ * default chain.
206
+ *
207
+ * @param {ReturnType<typeof loadPublicConfig>} config
208
+ * @param {number|string} chainId
209
+ */
210
+ export function resolveChain(config, chainId) {
211
+ if (config == null || typeof config.chains !== "object") {
212
+ fail("resolveChain: config must be a loadPublicConfig() result");
213
+ }
214
+ const key = String(chainId);
215
+ const entry = config.chains[key];
216
+ if (entry == null) {
217
+ fail(`unsupported chainId ${key} (registered: ${Object.keys(config.chains).join(", ") || "none"})`);
218
+ }
219
+ return entry;
220
+ }
221
+
222
+ /** Drop the query string and any userinfo from a URL, keeping only
223
+ * scheme://host:port/path — safe to write to a log line even though the
224
+ * full URL already passed the load-time secret-shape scan. */
225
+ function redactUrl(value) {
226
+ try {
227
+ const u = new URL(value);
228
+ u.search = "";
229
+ u.username = "";
230
+ u.password = "";
231
+ return u.toString();
232
+ } catch {
233
+ return "[unparseable-url-redacted]";
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Safe-to-log view of a loaded config: chain names/entryPoints/host/port
239
+ * pass through unchanged (none of that is secret-shaped), bundler/paymaster
240
+ * URLs are stripped down to origin+path only.
241
+ *
242
+ * @param {ReturnType<typeof loadPublicConfig>} config
243
+ */
244
+ export function redactedConfig(config) {
245
+ if (config == null || typeof config.chains !== "object") {
246
+ fail("redactedConfig: config must be a loadPublicConfig() result");
247
+ }
248
+ const chains = {};
249
+ for (const [chainId, entry] of Object.entries(config.chains)) {
250
+ chains[chainId] = {
251
+ name: entry.name,
252
+ entryPoint: entry.entryPoint,
253
+ ...(entry.bundlerUrl != null ? { bundlerUrl: redactUrl(entry.bundlerUrl) } : {}),
254
+ ...(entry.paymasterUrl != null ? { paymasterUrl: redactUrl(entry.paymasterUrl) } : {}),
255
+ };
256
+ }
257
+ return Object.freeze({
258
+ chains: Object.freeze(chains),
259
+ defaultChainId: config.defaultChainId,
260
+ publicHost: config.publicHost,
261
+ publicPort: config.publicPort,
262
+ });
263
+ }
264
+
265
+ export const _internal = { assertPublicUrlSafe, SECRET_URL_RULES, CHAIN_SEEDS };