@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,397 @@
1
+ // Oracle Public API — connect-agent contract (Slice D).
2
+ //
3
+ // BFF-safe, self-custodial contract that turns a user's "connect my agent"
4
+ // request into an UNSIGNED grant payload the USER signs with their own wallet.
5
+ //
6
+ // Hard boundaries for this file:
7
+ // - NEVER signs anything. There is no signer, no key material, no signature
8
+ // field ever populated here. The output hands back the exact bytes the
9
+ // user must sign themselves; signing happens client-side in the user's
10
+ // own wallet, never on a server.
11
+ // - Imports ONLY from the assigned public peers
12
+ // (src/public-control/policy-schema.mjs, session-key-model.mjs).
13
+ // Never from the private executor stack (get-signer, keystore,
14
+ // exec-policy, local-signer/*, adapters/*).
15
+ // - Oracle Control is deterministic authorization: same input => same
16
+ // canonical bytes => same grant id. No advisory logic here (that is
17
+ // Oracle Router's job and Router output never gates anything here).
18
+ // - Fail-closed no-secret invariant: every object returned from this module
19
+ // is recursively scanned and the call THROWS if anything shaped like a
20
+ // private key, session secret, bearer token, keystore path, or signer URL
21
+ // is present. See assertNoSecretMaterial().
22
+
23
+ import {
24
+ GRANT_VERSION,
25
+ GrantValidationError,
26
+ normalizeGrant,
27
+ canonicalizeGrant,
28
+ grantId,
29
+ isReadonlyAction,
30
+ } from "../public-control/policy-schema.mjs";
31
+
32
+ export const CONNECT_REQUEST_KIND = "oracle-connect-request";
33
+ export const UNSIGNED_GRANT_KIND = "oracle-unsigned-grant";
34
+
35
+ /** Identifier for how the signable bytes are derived: utf-8 encoding of the
36
+ * canonical grant JSON produced by policy-schema's canonicalizeGrant(). */
37
+ export const SIGNING_SCHEME = "oracle-grant-canonical-json-utf8-v1";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // No-secret invariant (fail closed)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export class SecretLeakError extends Error {
44
+ constructor(path, rule) {
45
+ super(`secret material must never appear in a public API object (at ${path}, rule: ${rule})`);
46
+ this.name = "SecretLeakError";
47
+ this.path = path;
48
+ this.rule = rule;
49
+ }
50
+ }
51
+
52
+ /** Key names that must never appear on any object we return. */
53
+ const FORBIDDEN_KEY_RE =
54
+ /(private[_-]?key|secret|bearer|keystore|mnemonic|seed[_-]?phrase|passphrase|password|api[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|signer[_-]?url|signature)/i;
55
+
56
+ /** String-value shapes that must never appear in any object we return. */
57
+ const FORBIDDEN_VALUE_RULES = Object.freeze([
58
+ // 32-byte hex — the shape of a raw EVM private key / session secret. Public
59
+ // grant material never legitimately carries a 0x + 64-hex value (addresses
60
+ // are 40 hex; the grant id is bare sha256 hex), so this fails closed.
61
+ { rule: "raw-32-byte-hex-key", re: /^0x[0-9a-fA-F]{64}$/ },
62
+ // Same 32 bytes WITHOUT the 0x prefix, as the entire string. A bare-hex
63
+ // private key (the shape ethers and most wallet exports hand you) must not
64
+ // pass just because it lacks the prefix.
65
+ //
66
+ // Deliberately anchored: an embedded/substring match would flag legitimate
67
+ // prose that quotes a public sha256 identifier (the human-readable grant
68
+ // render says "grant <id>"), and a scanner that cries wolf on normal output
69
+ // gets disabled. Smuggling a key inside a longer string is still caught at
70
+ // the serialized layer for any non-allowlisted field.
71
+ { rule: "raw-32-byte-hex-bare", re: /^[0-9a-fA-F]{64}$/ },
72
+ // Bitcoin WIF (mainnet 5/K/L, testnet c) — base58, 51-52 chars.
73
+ { rule: "bitcoin-wif", re: /\b[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}\b/ },
74
+ // BIP-39 mnemonic: 12/15/18/21/24 lowercase words. Detect a long run of
75
+ // space-separated short alpha words rather than shipping the wordlist.
76
+ { rule: "bip39-mnemonic", re: /\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/ },
77
+ { rule: "bearer-token", re: /bearer\s+[A-Za-z0-9._~+/=-]+/i },
78
+ { rule: "pem-private-key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----/ },
79
+ { rule: "keystore-path", re: /keystore/i },
80
+ { rule: "signer-url", re: /https?:\/\/\S*sign/i },
81
+ { rule: "authorization-header", re: /authorization\s*:/i },
82
+ ]);
83
+
84
+ /**
85
+ * Field names whose values are legitimately 64-hex and are NOT secrets:
86
+ * sha256 grant ids, tx hashes, block hashes, canonical digests. These are
87
+ * public identifiers — the whole point of a grant id is that it is quotable.
88
+ *
89
+ * Deliberately EXCLUDED: `nonce` and `salt`. Those are caller-supplied on the
90
+ * public path, so allowlisting them would let anyone launder key material
91
+ * through the scan just by naming the field `nonce`. Oracle's own nonces are
92
+ * short opaque strings, not 32-byte hex, so nothing legitimate is lost.
93
+ */
94
+ const HEX64_ALLOWED_KEYS = Object.freeze([
95
+ "grantid",
96
+ "id",
97
+ "hash",
98
+ "sha256",
99
+ "sha256hex",
100
+ "checksum",
101
+ "txhash",
102
+ "transactionhash",
103
+ "blockhash",
104
+ "digest",
105
+ "userophash",
106
+ "commithash",
107
+ "prevhash",
108
+ "intenthash",
109
+ "canonicalhash",
110
+ "payloadhash",
111
+ "requestid",
112
+ ]);
113
+
114
+ /**
115
+ * Strip values that are legitimately 64-hex public identifiers before running
116
+ * the raw-key shape rules, so a sha256 grant id does not read as a private key.
117
+ * Only exact-match 64-hex values on an allowlisted key are removed; a 64-hex
118
+ * string sitting anywhere else still trips the rule.
119
+ */
120
+ function maskAllowedHex64(json) {
121
+ return json.replace(
122
+ /"([A-Za-z0-9_-]+)"\s*:\s*"(?:0x)?([0-9a-fA-F]{64})"/g,
123
+ (match, key) =>
124
+ HEX64_ALLOWED_KEYS.includes(String(key).toLowerCase().replace(/[_-]/g, ""))
125
+ ? `"${key}":"<hex64>"`
126
+ : match
127
+ );
128
+ }
129
+ /**
130
+ * Scan the SERIALIZED form of a payload.
131
+ *
132
+ * Scanning the object graph alone is not sufficient: `toJSON()`, getters,
133
+ * symbol keys, non-enumerable properties and BigInt coercion all let a value
134
+ * reach the wire in a shape the graph walk never visited. Verified: an object
135
+ * `{ nested: { toJSON: () => "<private key>" } }` passes a graph-only scan and
136
+ * then serializes the raw key into the response body.
137
+ *
138
+ * So the authoritative check runs against the exact bytes we are about to
139
+ * write. Whatever JSON.stringify produces is what the client sees, and that is
140
+ * what we scan. Returns the JSON string when clean; throws SecretLeakError
141
+ * otherwise.
142
+ */
143
+ export function assertSerializedNoSecrets(value) {
144
+ let json;
145
+ try {
146
+ json = JSON.stringify(value);
147
+ } catch (e) {
148
+ // A payload we cannot serialize (circular, BigInt, throwing toJSON) must
149
+ // not be sent at all — fail closed rather than guess.
150
+ throw new SecretLeakError("$", `unserializable-payload: ${String(e?.message || e)}`);
151
+ }
152
+ if (json === undefined) return "null";
153
+
154
+ // Public 64-hex identifiers (grant ids, tx/block hashes, sha256 digests) are
155
+ // masked first so they do not read as raw key material.
156
+ const scanned = maskAllowedHex64(json);
157
+
158
+ for (const { rule, re } of FORBIDDEN_VALUE_RULES) {
159
+ if (re.test(scanned)) throw new SecretLeakError("$<serialized>", rule);
160
+ }
161
+
162
+ // Embedded key material. The per-string rules are anchored (so human-readable
163
+ // prose quoting a public grant id does not trip them), but at the serialized
164
+ // layer a 0x-prefixed 32-byte value has no legitimate reason to appear
165
+ // anywhere in a public response — addresses are 40 hex, and every public
166
+ // 64-hex identifier was masked above. This is what catches a key smuggled in
167
+ // via toJSON(), a getter, or concatenated into a longer string.
168
+ const embedded = /(?:^|[^0-9a-fA-Fx])0x[0-9a-fA-F]{64}(?![0-9a-fA-F])/;
169
+ if (embedded.test(scanned)) {
170
+ throw new SecretLeakError("$<serialized>", "raw-32-byte-hex-embedded");
171
+ }
172
+ // Key names are checked against the serialized text too, so a key smuggled
173
+ // in via toJSON or a getter is still caught.
174
+ const keyHit = scanned.match(
175
+ /"([^"]*(?:private[_-]?key|secret|bearer|keystore|mnemonic|seed[_-]?phrase|passphrase|password|api[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|signer[_-]?url)[^"]*)"\s*:/i
176
+ );
177
+ if (keyHit) throw new SecretLeakError(`$<serialized>.${keyHit[1]}`, "forbidden-key-name");
178
+ return json;
179
+ }
180
+
181
+ /**
182
+ * Recursively scan a value (objects, arrays, Maps, Sets, typed arrays) for
183
+ * anything shaped like secret material. Throws SecretLeakError on the first
184
+ * hit — fail closed. Returns the value unchanged when clean, so it can be
185
+ * used inline: `return assertNoSecretMaterial(result)`.
186
+ *
187
+ * `keyHint` carries the property name a string was found under, so a 64-hex
188
+ * value on a known public identifier (grantId, txHash, ...) is not mistaken
189
+ * for raw key material. A 64-hex string under any other key still trips.
190
+ */
191
+ export function assertNoSecretMaterial(value, path = "$", seen = new Set(), keyHint = "") {
192
+ if (value == null) return value;
193
+ if (typeof value === "string") {
194
+ const hex64Ok = HEX64_ALLOWED_KEYS.includes(
195
+ String(keyHint).toLowerCase().replace(/[_-]/g, "")
196
+ );
197
+ for (const { rule, re } of FORBIDDEN_VALUE_RULES) {
198
+ if (hex64Ok && rule.startsWith("raw-32-byte-hex")) continue;
199
+ if (re.test(value)) throw new SecretLeakError(path, rule);
200
+ }
201
+ return value;
202
+ }
203
+ if (typeof value !== "object") return value;
204
+ if (seen.has(value)) return value;
205
+ seen.add(value);
206
+
207
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value;
208
+ if (value instanceof Map) {
209
+ for (const [k, v] of value.entries()) {
210
+ if (typeof k === "string" && FORBIDDEN_KEY_RE.test(k)) {
211
+ throw new SecretLeakError(`${path}[${JSON.stringify(k)}]`, "forbidden-key-name");
212
+ }
213
+ assertNoSecretMaterial(k, `${path}[key]`, seen);
214
+ assertNoSecretMaterial(v, `${path}[${String(k)}]`, seen);
215
+ }
216
+ return value;
217
+ }
218
+ if (value instanceof Set) {
219
+ let i = 0;
220
+ for (const v of value.values()) assertNoSecretMaterial(v, `${path}[${i++}]`, seen);
221
+ return value;
222
+ }
223
+ if (Array.isArray(value)) {
224
+ value.forEach((v, i) => assertNoSecretMaterial(v, `${path}[${i}]`, seen));
225
+ return value;
226
+ }
227
+ for (const [k, v] of Object.entries(value)) {
228
+ if (FORBIDDEN_KEY_RE.test(k)) {
229
+ throw new SecretLeakError(`${path}.${k}`, "forbidden-key-name");
230
+ }
231
+ assertNoSecretMaterial(v, `${path}.${k}`, seen, k);
232
+ }
233
+ return value;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Helpers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function deepFreeze(value) {
241
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
242
+ Object.freeze(value);
243
+ for (const v of Object.values(value)) deepFreeze(v);
244
+ }
245
+ return value;
246
+ }
247
+
248
+ function toUnixSeconds(v, label) {
249
+ const n = typeof v === "string" ? Number(v.trim()) : Number(v);
250
+ if (!Number.isSafeInteger(n) || n <= 0) {
251
+ throw new GrantValidationError([{ field: label, message: "must be a positive unix-seconds integer" }]);
252
+ }
253
+ return n;
254
+ }
255
+
256
+ /** Deterministic UTC ISO string from unix seconds. */
257
+ function isoUtc(unixSeconds) {
258
+ return new Date(unixSeconds * 1000).toISOString();
259
+ }
260
+
261
+ /** Deterministic, purely-presentational human render of a normalized grant.
262
+ * Presentation only — it never adds, removes, or reinterprets authority and
263
+ * only surfaces public grant material. */
264
+ function renderUnsignedGrant(grant, id) {
265
+ const readonlyOnly = grant.actions.every((a) => isReadonlyAction(a));
266
+ return [
267
+ "ORACLE — CONNECT AGENT (unsigned grant, you sign it, we never do)",
268
+ "=================================================================",
269
+ `Grant ID: ${id}`,
270
+ `Version: ${GRANT_VERSION}`,
271
+ `Chain: ${grant.chainId}`,
272
+ `Agent: ${grant.agentAddress}`,
273
+ `Your account: ${grant.accountAddress}`,
274
+ `Actions: ${grant.actions.join(", ")}`,
275
+ `Targets: ${grant.targets.length ? grant.targets.join(", ") : "(none — read/simulate only)"}`,
276
+ `Max value: ${grant.maxValueWei} wei`,
277
+ `Max gas: ${grant.maxGasWei} wei`,
278
+ `Max slippage: ${grant.maxSlippageBps} bps`,
279
+ `Expires: ${isoUtc(grant.expiresAt)} (unix ${grant.expiresAt})`,
280
+ `Nonce: ${grant.nonce}`,
281
+ `Revocation: ${grant.revocationKey}`,
282
+ "-----------------------------------------------------------------",
283
+ readonlyOnly
284
+ ? "Scope: read/simulate only — this grant cannot move funds."
285
+ : "Scope: includes state-changing actions bounded by the caps above.",
286
+ "Self-custodial: this payload is UNSIGNED and contains public data only.",
287
+ "Sign the bytes below with YOUR wallet; Oracle never holds your keys.",
288
+ ].join("\n");
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Public contract
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Turn raw user inputs into a validated connect request.
297
+ *
298
+ * Accepts the canonical grant fields (see policy-schema REQUIRED_FIELDS).
299
+ * Convenience: instead of `expiresAt` the caller may pass `ttlSeconds`
300
+ * together with an explicit `now` (unix seconds) — determinism requires the
301
+ * caller to supply `now`; there is no wall-clock fallback here.
302
+ *
303
+ * Throws GrantValidationError on any invalid/unknown/missing field (the
304
+ * schema fails closed on unknown fields, wildcard actions, empty targets for
305
+ * state-changing scopes, etc). Never signs anything.
306
+ *
307
+ * @returns frozen { kind, version, grant } where grant is the normalized
308
+ * canonical grant from policy-schema.
309
+ */
310
+ export function buildConnectRequest(input = {}, opts = {}) {
311
+ if (input == null || typeof input !== "object" || Array.isArray(input)) {
312
+ throw new GrantValidationError([{ field: "request", message: "request must be a plain object" }]);
313
+ }
314
+
315
+ const { ttlSeconds, ...rest } = input;
316
+ const fields = { ...rest };
317
+
318
+ if (ttlSeconds != null) {
319
+ if (fields.expiresAt != null) {
320
+ throw new GrantValidationError([
321
+ { field: "ttlSeconds", message: "pass either ttlSeconds or expiresAt, not both" },
322
+ ]);
323
+ }
324
+ if (opts.now == null) {
325
+ throw new GrantValidationError([
326
+ { field: "ttlSeconds", message: "ttlSeconds requires an explicit opts.now (unix seconds) for determinism" },
327
+ ]);
328
+ }
329
+ const ttl = toUnixSeconds(ttlSeconds, "ttlSeconds");
330
+ fields.expiresAt = toUnixSeconds(opts.now, "opts.now") + ttl;
331
+ }
332
+
333
+ const grant = normalizeGrant(fields, {
334
+ now: opts.now,
335
+ allowWildcardActions: opts.allowWildcardActions === true,
336
+ });
337
+
338
+ const request = deepFreeze({
339
+ kind: CONNECT_REQUEST_KIND,
340
+ version: GRANT_VERSION,
341
+ grant,
342
+ });
343
+ return assertNoSecretMaterial(request);
344
+ }
345
+
346
+ /**
347
+ * Assemble the UNSIGNED grant payload for a connect request (or raw grant
348
+ * input). Returns everything a BFF/browser needs to show the user what they
349
+ * are approving and exactly which bytes to sign — and nothing else:
350
+ *
351
+ * payload — { version, grantId, grant, canonical } canonical grant data
352
+ * signing — { scheme, message, bytesHex, byteLength, sha256 } where
353
+ * `message` is the exact canonical JSON string and `bytesHex`
354
+ * is the 0x-hex of its utf-8 bytes: THE bytes the user signs.
355
+ * render — deterministic human-readable review text
356
+ * unsigned — always true; this module never signs and never will.
357
+ *
358
+ * Deterministic: identical grants (any key order / address casing / numeric
359
+ * representation) produce byte-identical canonical/message/bytesHex/grantId.
360
+ *
361
+ * Fail-closed: throws GrantValidationError on bad input and SecretLeakError
362
+ * if the assembled object ever carried secret-shaped material.
363
+ */
364
+ export function assembleUnsignedGrant(requestOrInput = {}, opts = {}) {
365
+ const raw =
366
+ requestOrInput && requestOrInput.kind === CONNECT_REQUEST_KIND
367
+ ? requestOrInput.grant
368
+ : requestOrInput;
369
+
370
+ const grant = normalizeGrant(raw, {
371
+ now: opts.now,
372
+ allowWildcardActions: opts.allowWildcardActions === true,
373
+ });
374
+ const canonical = canonicalizeGrant(grant);
375
+ const id = grantId(grant);
376
+ const bytes = Buffer.from(canonical, "utf8");
377
+
378
+ const result = deepFreeze({
379
+ kind: UNSIGNED_GRANT_KIND,
380
+ unsigned: true,
381
+ payload: deepFreeze({
382
+ version: GRANT_VERSION,
383
+ grantId: id,
384
+ grant,
385
+ canonical,
386
+ }),
387
+ signing: deepFreeze({
388
+ scheme: SIGNING_SCHEME,
389
+ message: canonical,
390
+ bytesHex: `0x${bytes.toString("hex")}`,
391
+ byteLength: bytes.length,
392
+ sha256: id,
393
+ }),
394
+ render: renderUnsignedGrant(grant, id),
395
+ });
396
+ return assertNoSecretMaterial(result);
397
+ }
@@ -0,0 +1,142 @@
1
+ // Oracle Public API — grant reads (Slice D).
2
+ //
3
+ // Pure, deterministic read functions over an INJECTED grant store. No DB, no
4
+ // chain, no clock fallback: callers pass an array/Map of grant records plus an
5
+ // explicit `now` (unix seconds). Same inputs always produce the same output.
6
+ //
7
+ // Imports ONLY from the assigned public peers (policy-schema.mjs) and the
8
+ // sibling connect-agent.mjs for the shared no-secret invariant. Never from the
9
+ // private executor stack.
10
+ //
11
+ // Store shape (injected, caller-owned):
12
+ // - an Array or Map whose entries are either
13
+ // a) a raw grant object (policy-schema shape), or
14
+ // b) a wrapper record { grant, revoked?, revokedAt? }.
15
+ // - revocation may also be supplied out-of-band via opts.revoked: a Set /
16
+ // array / Map-like of grant ids and/or revocation keys, or a function
17
+ // (id, grant) => boolean.
18
+ //
19
+ // Fail-closed rules:
20
+ // - records that do not validate against the public policy schema are
21
+ // EXCLUDED from active listings (a grant that cannot validate cannot
22
+ // authorize anything).
23
+ // - `now` is required for listActiveGrants; without a reference time we
24
+ // cannot prove liveness, so we refuse rather than guess.
25
+ // - every returned object passes the no-secret invariant scan.
26
+
27
+ import { validateGrant, grantId, isExpired } from "../public-control/policy-schema.mjs";
28
+ import { assertNoSecretMaterial } from "./connect-agent.mjs";
29
+
30
+ export const GRANT_STATUS = Object.freeze({
31
+ ACTIVE: "active",
32
+ EXPIRED: "expired",
33
+ REVOKED: "revoked",
34
+ INVALID: "invalid",
35
+ });
36
+
37
+ function toRecords(store) {
38
+ if (store == null) return [];
39
+ if (Array.isArray(store)) return store;
40
+ if (store instanceof Map) return [...store.values()];
41
+ if (typeof store[Symbol.iterator] === "function") return [...store];
42
+ throw new TypeError("grant store must be an array, Map, or iterable of grant records");
43
+ }
44
+
45
+ function unwrap(record) {
46
+ if (record && typeof record === "object" && !Array.isArray(record) && record.grant != null) {
47
+ return {
48
+ grant: record.grant,
49
+ revoked: record.revoked === true || record.revokedAt != null,
50
+ };
51
+ }
52
+ return { grant: record, revoked: false };
53
+ }
54
+
55
+ function toSafeNow(now) {
56
+ const n = typeof now === "string" ? Number(now.trim()) : Number(now);
57
+ if (!Number.isSafeInteger(n) || n < 0) {
58
+ throw new TypeError("an explicit unix-seconds `now` is required (deterministic liveness, no wall-clock fallback)");
59
+ }
60
+ return n;
61
+ }
62
+
63
+ function isRevokedBy(revoked, id, grant) {
64
+ if (revoked == null) return false;
65
+ if (typeof revoked === "function") return revoked(id, grant) === true;
66
+ const keys = [id, grant.revocationKey];
67
+ if (revoked instanceof Set) return keys.some((k) => revoked.has(k));
68
+ if (Array.isArray(revoked)) return keys.some((k) => revoked.includes(k));
69
+ if (typeof revoked.has === "function") return keys.some((k) => revoked.has(k));
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Classify a single store record. Returns { status, id, grant } where grant
75
+ * is the normalized public grant (null when invalid). Pure + deterministic.
76
+ */
77
+ export function classifyGrant(record, { now, revoked } = {}) {
78
+ const nowSec = toSafeNow(now);
79
+ const { grant: raw, revoked: recordRevoked } = unwrap(record);
80
+ const { ok, grant } = validateGrant(raw);
81
+ if (!ok) return { status: GRANT_STATUS.INVALID, id: null, grant: null };
82
+ const id = grantId(grant);
83
+ // Revocation takes precedence over expiry (matches session-key-model
84
+ // semantics: a revoked-then-expired grant still reports "revoked").
85
+ if (recordRevoked || isRevokedBy(revoked, id, grant)) {
86
+ return { status: GRANT_STATUS.REVOKED, id, grant };
87
+ }
88
+ if (isExpired(grant, nowSec)) return { status: GRANT_STATUS.EXPIRED, id, grant };
89
+ return { status: GRANT_STATUS.ACTIVE, id, grant };
90
+ }
91
+
92
+ /**
93
+ * List grants that are currently ACTIVE: valid under the public policy
94
+ * schema, not expired at `now`, and not revoked (per record flag or the
95
+ * injected `revoked` source). Invalid records are silently excluded
96
+ * (fail closed — they cannot authorize anything).
97
+ *
98
+ * Optional filters: { agentAddress, accountAddress, chainId }.
99
+ *
100
+ * @returns frozen array of { id, grant } sorted by id (deterministic order).
101
+ */
102
+ export function listActiveGrants(store, { now, revoked, agentAddress, accountAddress, chainId } = {}) {
103
+ const nowSec = toSafeNow(now);
104
+ const wantAgent = agentAddress != null ? String(agentAddress).trim().toLowerCase() : null;
105
+ const wantAccount = accountAddress != null ? String(accountAddress).trim().toLowerCase() : null;
106
+ const wantChain = chainId != null ? Number(chainId) : null;
107
+
108
+ const out = [];
109
+ for (const record of toRecords(store)) {
110
+ const { status, id, grant } = classifyGrant(record, { now: nowSec, revoked });
111
+ if (status !== GRANT_STATUS.ACTIVE) continue;
112
+ if (wantAgent != null && grant.agentAddress !== wantAgent) continue;
113
+ if (wantAccount != null && grant.accountAddress !== wantAccount) continue;
114
+ if (wantChain != null && grant.chainId !== wantChain) continue;
115
+ out.push(Object.freeze({ id, grant }));
116
+ }
117
+ out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
118
+ return assertNoSecretMaterial(Object.freeze(out));
119
+ }
120
+
121
+ /**
122
+ * Look up a single grant by its public grant id. Returns
123
+ * { id, status, grant } or null when no record in the store matches.
124
+ * Unlike listActiveGrants this also surfaces expired/revoked grants (with
125
+ * their status) so a UI can explain WHY a grant is not active — but invalid
126
+ * records never match (they have no trustworthy id).
127
+ */
128
+ export function getGrant(store, id, { now, revoked } = {}) {
129
+ const nowSec = toSafeNow(now);
130
+ const wanted = String(id ?? "").trim().toLowerCase();
131
+ if (!/^[0-9a-f]{64}$/.test(wanted)) return null;
132
+ for (const record of toRecords(store)) {
133
+ const res = classifyGrant(record, { now: nowSec, revoked });
134
+ if (res.status === GRANT_STATUS.INVALID) continue;
135
+ if (res.id === wanted) {
136
+ return assertNoSecretMaterial(
137
+ Object.freeze({ id: res.id, status: res.status, grant: res.grant })
138
+ );
139
+ }
140
+ }
141
+ return null;
142
+ }