@oracle-agent/oracle 0.4.1 → 0.5.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 (80) hide show
  1. package/README.md +74 -20
  2. package/SETUP.md +15 -2
  3. package/bin/oracle-data-mcp.mjs +1 -1
  4. package/bin/oracle.mjs +33 -0
  5. package/docs/cli.md +19 -0
  6. package/docs/connectors.md +13 -0
  7. package/docs/oracle-pack-standard.md +29 -0
  8. package/docs/profiles.md +1 -1
  9. package/examples/oracle-pack-template.mjs +38 -0
  10. package/package.json +13 -4
  11. package/profiles/oracle/SOUL.md +3 -1
  12. package/profiles/robinhood-agent/SOUL.md +5 -3
  13. package/public/agent-connect/app.js +84 -0
  14. package/public/agent-connect/index.html +83 -0
  15. package/public/agent-connect/styles.css +70 -0
  16. package/public/oracle-splash/assets/wordmarks/across.svg +4 -0
  17. package/public/oracle-splash/assets/wordmarks/aerodrome.svg +23 -0
  18. package/public/oracle-splash/assets/wordmarks/balancer.svg +3 -0
  19. package/public/oracle-splash/assets/wordmarks/cowswap.svg +1 -0
  20. package/public/oracle-splash/assets/wordmarks/gmx.svg +13 -0
  21. package/public/oracle-splash/assets/wordmarks/hyperliquid.svg +21 -0
  22. package/public/oracle-splash/assets/wordmarks/hyperswap.svg +13 -0
  23. package/public/oracle-splash/assets/wordmarks/jupiter.svg +35 -0
  24. package/public/oracle-splash/assets/wordmarks/lifi.png +0 -0
  25. package/public/oracle-splash/assets/wordmarks/magic-eden.svg +1 -0
  26. package/public/oracle-splash/assets/wordmarks/morpho.svg +1 -0
  27. package/public/oracle-splash/assets/wordmarks/odos.svg +3 -0
  28. package/public/oracle-splash/assets/wordmarks/oneinch.svg +6 -0
  29. package/public/oracle-splash/assets/wordmarks/opensea.svg +1 -0
  30. package/public/oracle-splash/assets/wordmarks/pancakeswap.svg +18 -0
  31. package/public/oracle-splash/assets/wordmarks/paraswap.svg +16 -0
  32. package/public/oracle-splash/assets/wordmarks/pendle.png +0 -0
  33. package/public/oracle-splash/assets/wordmarks/polymarket.png +0 -0
  34. package/public/oracle-splash/assets/wordmarks/quickswap.png +0 -0
  35. package/public/oracle-splash/assets/wordmarks/relay.svg +8 -0
  36. package/public/oracle-splash/assets/wordmarks/stargate.svg +15 -0
  37. package/public/oracle-splash/assets/wordmarks/uniswap.svg +18 -0
  38. package/public/oracle-splash/assets/wordmarks/velodrome.svg +84 -0
  39. package/public/oracle-splash/index.html +789 -485
  40. package/scripts/adversarial-bench.mjs +114 -0
  41. package/scripts/check-doc-drift.mjs +111 -0
  42. package/skills/oracle-action-semantics/SKILL.md +1 -1
  43. package/src/action-receipts.mjs +168 -0
  44. package/src/address-book.mjs +53 -5
  45. package/src/cli/commands/credential.mjs +9 -0
  46. package/src/cli/commands/data-mcp.mjs +18 -0
  47. package/src/cli/commands/data.mjs +73 -0
  48. package/src/cli/commands/doctor.mjs +131 -0
  49. package/src/cli/commands/help.mjs +7 -0
  50. package/src/cli/commands/init.mjs +27 -0
  51. package/src/cli/commands/mcp.mjs +142 -0
  52. package/src/cli/commands/prepare.mjs +12 -0
  53. package/src/cli/commands/public.mjs +20 -0
  54. package/src/cli/commands/route.mjs +12 -0
  55. package/src/cli/commands/runner.mjs +9 -0
  56. package/src/cli/commands/scan.mjs +12 -0
  57. package/src/cli/commands/sign.mjs +21 -0
  58. package/src/cli/commands/signer.mjs +9 -0
  59. package/src/cli/commands/upgrade.mjs +12 -0
  60. package/src/cli/commands/vault.mjs +9 -0
  61. package/src/cli/commands/version.mjs +22 -0
  62. package/src/cli/first-run.mjs +20 -0
  63. package/src/cli/kernel.mjs +198 -0
  64. package/src/cli/mcp-targets/chatgpt.mjs +51 -0
  65. package/src/cli/mcp-targets/claude-code.mjs +39 -0
  66. package/src/cli/mcp-targets/claude-desktop.mjs +24 -0
  67. package/src/cli/mcp-targets/codex.mjs +41 -0
  68. package/src/cli/mcp-targets/shared.mjs +61 -0
  69. package/src/cli/operator-dispatch.mjs +258 -0
  70. package/src/cli/paths.mjs +78 -0
  71. package/src/cli/spawn-child.mjs +43 -0
  72. package/src/exec-policy.mjs +6 -0
  73. package/src/index.mjs +24 -0
  74. package/src/onboarding/harness-configs.mjs +102 -0
  75. package/src/oracle-env.mjs +11 -2
  76. package/src/portfolio-risk.mjs +170 -0
  77. package/src/public-api/connect-agent.mjs +29 -10
  78. package/src/signals/engine.mjs +146 -0
  79. package/src/signals/index.mjs +1 -0
  80. package/src/watch-preferences.mjs +85 -0
@@ -0,0 +1,102 @@
1
+ const PACKAGE = "@oracle-agent/oracle";
2
+ const COMMAND = "npx";
3
+ const ARGS = ["-y", "--package", PACKAGE, "oracle-data-mcp"];
4
+ const FORBIDDEN_NAME = /(?:^|_)(?:PRIVATE_KEY|SECRET|TOKEN|PASSWORD|PASSPHRASE|MNEMONIC|SEED)(?:_|$)/i;
5
+
6
+ function requiredText(value, name) {
7
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
8
+ if (/\r|\n|\0/.test(value)) throw new TypeError(`${name} must be a single-line string`);
9
+ return value.trim();
10
+ }
11
+
12
+ function validateUrl(value) {
13
+ const text = requiredText(value, "url");
14
+ let parsed;
15
+ try { parsed = new URL(text); } catch { throw new TypeError("url must be a valid http(s) URL"); }
16
+ if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
17
+ throw new TypeError("url must be a credential-free http(s) URL");
18
+ }
19
+ for (const name of parsed.searchParams.keys()) {
20
+ if (FORBIDDEN_NAME.test(name) || /^(?:key|api[_-]?key|auth)$/i.test(name)) {
21
+ throw new TypeError(`url must not contain secret-shaped query parameter: ${name}`);
22
+ }
23
+ }
24
+ return parsed.href;
25
+ }
26
+
27
+ function validateKey(value) {
28
+ const text = requiredText(value, "key");
29
+ if (/^[A-Z][A-Z0-9_]*\s*=/.test(text) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) {
30
+ throw new TypeError("key must be an Oracle agent key, not an environment assignment or private key");
31
+ }
32
+ if (/^(?:0x)?[0-9a-f]{64}$/i.test(text) || text.trim().split(/\s+/).length >= 12) {
33
+ throw new TypeError("key looks like private signing material");
34
+ }
35
+ return text;
36
+ }
37
+
38
+ function validateLabel(value) {
39
+ const label = requiredText(value, "label");
40
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(label)) {
41
+ throw new TypeError("label must contain only letters, digits, underscores, and hyphens");
42
+ }
43
+ if (FORBIDDEN_NAME.test(label)) throw new TypeError("label must not be a secret-shaped environment name");
44
+ return label;
45
+ }
46
+
47
+ function json(value) {
48
+ return `${JSON.stringify(value, null, 2)}\n`;
49
+ }
50
+
51
+ function yamlString(value) {
52
+ return JSON.stringify(value);
53
+ }
54
+
55
+ function tomlString(value) {
56
+ return JSON.stringify(value);
57
+ }
58
+
59
+ /**
60
+ * Return deterministic, copy-pasteable MCP configuration snippets.
61
+ * This function performs no I/O and never reads from process.env.
62
+ */
63
+ export function emitHarnessConfigs({ url, key, label = "oracle-data" } = {}) {
64
+ const endpoint = validateUrl(url);
65
+ const agentKey = validateKey(key);
66
+ const name = validateLabel(label);
67
+ const server = {
68
+ command: COMMAND,
69
+ args: [...ARGS],
70
+ env: { ORACLE_DATA_URL: endpoint, ORACLE_AGENT_KEY: agentKey },
71
+ };
72
+ const mcp = { mcpServers: { [name]: server } };
73
+
74
+ return {
75
+ hermes: [
76
+ "mcp_servers:",
77
+ ` ${name}:`,
78
+ ` command: ${yamlString(COMMAND)}`,
79
+ " args:",
80
+ ...ARGS.map((arg) => ` - ${yamlString(arg)}`),
81
+ " env:",
82
+ ` ORACLE_DATA_URL: ${yamlString(endpoint)}`,
83
+ ` ORACLE_AGENT_KEY: ${yamlString(agentKey)}`,
84
+ "",
85
+ ].join("\n"),
86
+ claudeCode: json(mcp),
87
+ codex: [
88
+ `[mcp_servers.${tomlString(name)}]`,
89
+ `command = ${tomlString(COMMAND)}`,
90
+ `args = [${ARGS.map(tomlString).join(", ")}]`,
91
+ "",
92
+ `[mcp_servers.${tomlString(name)}.env]`,
93
+ `ORACLE_DATA_URL = ${tomlString(endpoint)}`,
94
+ `ORACLE_AGENT_KEY = ${tomlString(agentKey)}`,
95
+ "",
96
+ ].join("\n"),
97
+ cursor: json(mcp),
98
+ genericMcp: json(mcp),
99
+ };
100
+ }
101
+
102
+ export const createHarnessConfigs = emitHarnessConfigs;
@@ -20,9 +20,18 @@ export function envFlag(primary, legacy, defaultValue = false) {
20
20
  return String(env(primary, legacy, fallback)).trim() === "1";
21
21
  }
22
22
 
23
+ const DISABLE_WORDS = ["0", "false", "off", "no"];
24
+
25
+ // Reading a flag that may default ON. Only an explicit disable word turns it
26
+ // off, and an unset value keeps `defaultValue`.
27
+ //
28
+ // The old shape (`!== "0"`) inverted with a false default: ORACLE_ONBOARD_HTTP
29
+ // defaults off, so a user writing `false` to keep it off actually ENABLED it,
30
+ // because "false" is not "0". A disable word must never enable a surface.
23
31
  export function envEnabled(primary, legacy, defaultValue = true) {
24
- const fallback = defaultValue ? "1" : "0";
25
- return String(env(primary, legacy, fallback)).trim() !== "0";
32
+ const raw = String(env(primary, legacy, "")).trim().toLowerCase();
33
+ if (raw === "") return defaultValue;
34
+ return !DISABLE_WORDS.includes(raw);
26
35
  }
27
36
 
28
37
  export function csvEnv(primary, legacy) {
@@ -0,0 +1,170 @@
1
+ // Pure portfolio exposure and coverage summary. This module performs no I/O.
2
+
3
+ export const DEFAULT_RISK_THRESHOLDS = Object.freeze({
4
+ assetHighPct: 50,
5
+ assetMediumPct: 25,
6
+ chainHighPct: 75,
7
+ venueHighPct: 60,
8
+ stablecoinHighPct: 70,
9
+ });
10
+
11
+ const STABLECOINS = new Set(["USDC", "USDT", "USDT0", "DAI", "FRAX", "LUSD", "TUSD", "USDP", "GUSD", "PYUSD", "USDE"]);
12
+
13
+ function finite(value) {
14
+ if (value === null || value === "" || value === undefined) return null;
15
+ const number = Number(value);
16
+ return Number.isFinite(number) && number >= 0 ? number : null;
17
+ }
18
+
19
+ function rounded(value) {
20
+ return Math.round((value + Number.EPSILON) * 1e8) / 1e8;
21
+ }
22
+
23
+ function pct(value, total) {
24
+ return total > 0 ? rounded((value / total) * 100) : null;
25
+ }
26
+
27
+ function addressOf(snapshot) {
28
+ return snapshot.address || snapshot.owner || snapshot.walletAddress || snapshot.wallet?.address || null;
29
+ }
30
+
31
+ function labelIndex(labels) {
32
+ const entries = Array.isArray(labels) ? labels : Array.isArray(labels?.entries) ? labels.entries : [];
33
+ const index = new Map();
34
+ for (const entry of entries) {
35
+ if (!entry?.address) continue;
36
+ const key = String(entry.address).toLowerCase();
37
+ if (!index.has(key)) index.set(key, entry);
38
+ }
39
+ return index;
40
+ }
41
+
42
+ function addAsset(output, raw, context = {}) {
43
+ if (!raw || typeof raw !== "object") return;
44
+ const symbol = String(raw.symbol ?? raw.coin ?? raw.name ?? raw.collection ?? raw.tokenId ?? "unknown");
45
+ const value = finite(raw.usdValue ?? raw.valueUsd ?? raw.knownUsd ?? raw.estimatedValueUsd ?? raw.floorValueUsd);
46
+ const kind = raw.kind === "nft" || raw.type === "nft" || raw.collection || raw.tokenId != null ? "nft" : "fungible";
47
+ output.push({
48
+ symbol,
49
+ usdValue: value,
50
+ chain: String(raw.chain ?? raw.chainName ?? context.chain ?? "unknown"),
51
+ venue: String(raw.venue ?? raw.protocol ?? raw.platform ?? context.venue ?? "wallet"),
52
+ address: raw.address ?? context.address ?? null,
53
+ kind,
54
+ stablecoin: raw.stablecoin === true || STABLECOINS.has(symbol.toUpperCase()),
55
+ });
56
+ }
57
+
58
+ function assetsFromSnapshot(snapshot) {
59
+ const output = [];
60
+ const rootAddress = addressOf(snapshot);
61
+ for (const asset of snapshot.assets || snapshot.holdings || []) addAsset(output, asset, { address: rootAddress, chain: snapshot.chain, venue: snapshot.venue });
62
+ for (const chain of snapshot.chains || []) {
63
+ const chainName = chain.chain ?? chain.name ?? chain.slug ?? (chain.chainId != null ? String(chain.chainId) : chain.family) ?? "unknown";
64
+ const context = { address: chain.address ?? rootAddress, chain: chainName, venue: chain.venue };
65
+ if (chain.native && (chain.native.amount == null || Number(chain.native.amount) !== 0)) {
66
+ addAsset(output, { ...chain.native, symbol: chain.native.symbol ?? chain.symbol ?? chainName }, context);
67
+ }
68
+ for (const asset of chain.assets || chain.fungibleTokens?.assets || []) addAsset(output, asset, context);
69
+ for (const asset of chain.collectibles?.assets || chain.nfts || []) addAsset(output, { ...asset, kind: "nft" }, context);
70
+ for (const asset of chain.spot?.balances || []) addAsset(output, asset, { ...context, venue: "Hyperliquid spot" });
71
+ const accountValue = finite(chain.perps?.accountValueUsd);
72
+ if (accountValue != null) addAsset(output, { symbol: "perps account", usdValue: accountValue }, { ...context, venue: "Hyperliquid perps" });
73
+ }
74
+ for (const item of snapshot.inventory?.items || snapshot.nfts?.items || []) addAsset(output, { ...item, kind: "nft" }, { address: rootAddress, chain: item.chain ?? snapshot.chain, venue: item.marketplace ?? snapshot.venue });
75
+ return output;
76
+ }
77
+
78
+ function snapshotKey(snapshot, index) {
79
+ const address = addressOf(snapshot);
80
+ const chain = snapshot.chain ?? snapshot.chainId ?? snapshot.family ?? "*";
81
+ return address ? `${String(address).toLowerCase()}|${String(chain).toLowerCase()}` : `snapshot:${index}`;
82
+ }
83
+
84
+ function buckets(assets, property, total) {
85
+ const map = new Map();
86
+ for (const asset of assets) {
87
+ if (asset.usdValue == null) continue;
88
+ const name = asset[property];
89
+ map.set(name, (map.get(name) || 0) + asset.usdValue);
90
+ }
91
+ return [...map].map(([name, knownUsd]) => ({ name, knownUsd: rounded(knownUsd), percentOfKnown: pct(knownUsd, total) }))
92
+ .sort((a, b) => b.knownUsd - a.knownUsd || a.name.localeCompare(b.name));
93
+ }
94
+
95
+ /**
96
+ * Summarize already-fetched portfolio snapshots. Accepts either
97
+ * `{ snapshots, labels, thresholds }` or an array plus an options object.
98
+ */
99
+ export function summarizePortfolioRisk(input = {}, options = {}) {
100
+ const snapshots = Array.isArray(input) ? input : Array.isArray(input.snapshots) ? input.snapshots : input.snapshot ? [input.snapshot] : [];
101
+ const labels = labelIndex(options.labels ?? options.addressBook ?? input.labels ?? input.addressBook);
102
+ const thresholds = { ...DEFAULT_RISK_THRESHOLDS, ...(input.thresholds || {}), ...(options.thresholds || {}) };
103
+ const unique = [];
104
+ const seen = new Set();
105
+ let duplicatesIgnored = 0;
106
+ snapshots.forEach((snapshot, index) => {
107
+ if (!snapshot || typeof snapshot !== "object") return;
108
+ const key = snapshotKey(snapshot, index);
109
+ if (seen.has(key)) duplicatesIgnored += 1;
110
+ else { seen.add(key); unique.push(snapshot); }
111
+ });
112
+
113
+ const assets = unique.flatMap(assetsFromSnapshot);
114
+ const priced = assets.filter((asset) => asset.usdValue != null);
115
+ const unknown = assets.filter((asset) => asset.usdValue == null);
116
+ const explicitUnknown = unique.reduce((sum, snapshot) => sum + Number(snapshot.valuation?.unpricedNonzeroItems || snapshot.unpricedNonzeroItems || 0), 0);
117
+ const totalKnownUsd = rounded(priced.reduce((sum, asset) => sum + asset.usdValue, 0));
118
+ const unknownItemCount = unknown.length + explicitUnknown;
119
+ const chainExposures = buckets(priced, "chain", totalKnownUsd);
120
+ const venueExposures = buckets(priced, "venue", totalKnownUsd);
121
+ const groupedAssets = buckets(priced, "symbol", totalKnownUsd);
122
+ for (const row of chainExposures) row.chain = row.name;
123
+ for (const row of venueExposures) row.venue = row.name;
124
+ for (const row of groupedAssets) {
125
+ row.asset = row.name;
126
+ row.symbol = row.name;
127
+ }
128
+ const stablecoinKnownUsd = rounded(priced.filter((asset) => asset.stablecoin).reduce((sum, asset) => sum + asset.usdValue, 0));
129
+ const nftAssets = assets.filter((asset) => asset.kind === "nft");
130
+ const snapshotNftUnvalued = unique.reduce((sum, snapshot) => sum + Number(snapshot.valuation?.nftUnvaluedItems || 0), 0);
131
+ const nftUnvaluedItems = nftAssets.filter((asset) => asset.usdValue == null).length + snapshotNftUnvalued;
132
+ const nftValuedItems = nftAssets.filter((asset) => asset.usdValue != null).length;
133
+ const top = groupedAssets[0] || null;
134
+ const concentrationLevel = !top ? "unknown" : top.percentOfKnown >= thresholds.assetHighPct ? "high" : top.percentOfKnown >= thresholds.assetMediumPct ? "medium" : "low";
135
+ const warnings = [];
136
+ if (unknownItemCount) warnings.push(`${unknownItemCount} nonzero holding(s) have unknown USD value; they are excluded from totals and percentages.`);
137
+ if (nftUnvaluedItems) warnings.push("NFT valuation coverage is partial; estimates are not executable bids and unvalued NFTs are not treated as zero.");
138
+ if (duplicatesIgnored) warnings.push(`${duplicatesIgnored} duplicate address/chain snapshot(s) were ignored.`);
139
+ if (top && concentrationLevel !== "low") warnings.push(`Asset concentration is ${concentrationLevel}: ${top.name} is ${top.percentOfKnown}% of known value.`);
140
+ if (chainExposures[0]?.percentOfKnown >= thresholds.chainHighPct) warnings.push(`Chain concentration is high: ${chainExposures[0].name} is ${chainExposures[0].percentOfKnown}% of known value.`);
141
+ if (venueExposures[0]?.percentOfKnown >= thresholds.venueHighPct) warnings.push(`Venue concentration is high: ${venueExposures[0].name} is ${venueExposures[0].percentOfKnown}% of known value.`);
142
+
143
+ const addresses = unique.map(addressOf).filter(Boolean).map((address) => {
144
+ const entry = labels.get(String(address).toLowerCase());
145
+ return { address, label: entry?.label ?? null, who: entry?.who ?? null, role: entry?.role ?? null };
146
+ });
147
+ return {
148
+ totalKnownUsd,
149
+ coverage: {
150
+ complete: unique.length > 0 && unknownItemCount === 0 && unique.every((snapshot) => snapshot.valuation?.complete !== false && snapshot.coverage?.complete !== false),
151
+ label: unknownItemCount === 0 && unique.length && unique.every((snapshot) => snapshot.valuation?.complete !== false && snapshot.coverage?.complete !== false)
152
+ ? "complete for the supplied snapshots" : "known priced value only; portfolio coverage is partial or unverified",
153
+ unknownUsd: unknownItemCount ? null : 0,
154
+ pricedItemCount: priced.length,
155
+ unknownItemCount,
156
+ snapshotCount: unique.length,
157
+ duplicatesIgnored,
158
+ },
159
+ chainExposures,
160
+ venueExposures,
161
+ topAssets: groupedAssets.slice(0, Math.max(1, Number(options.topAssets ?? input.topAssets ?? 10))),
162
+ concentrationRisk: { level: concentrationLevel, topAsset: top?.name ?? null, topAssetPercent: top?.percentOfKnown ?? null, thresholds: { mediumPct: thresholds.assetMediumPct, highPct: thresholds.assetHighPct } },
163
+ stablecoinExposure: { knownUsd: stablecoinKnownUsd, percentOfKnown: pct(stablecoinKnownUsd, totalKnownUsd), high: totalKnownUsd > 0 && pct(stablecoinKnownUsd, totalKnownUsd) >= thresholds.stablecoinHighPct },
164
+ nftCoverage: { status: nftAssets.length || snapshotNftUnvalued ? (nftUnvaluedItems ? "partial" : "valued") : "not-observed", valuedItems: nftValuedItems, unvaluedItems: nftUnvaluedItems, knownUsd: rounded(nftAssets.filter((asset) => asset.usdValue != null).reduce((sum, asset) => sum + asset.usdValue, 0)) },
165
+ addresses,
166
+ warnings: [...new Set(warnings)],
167
+ };
168
+ }
169
+
170
+ export const portfolioRiskSummary = summarizePortfolioRisk;
@@ -53,6 +53,18 @@ export class SecretLeakError extends Error {
53
53
  const FORBIDDEN_KEY_RE =
54
54
  /(private[_-]?key|secret|bearer|keystore|mnemonic|seed[_-]?phrase|passphrase|password|api[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|signer[_-]?url|signature)/i;
55
55
 
56
+ /** Short wallet-export aliases need exact normalized matching. Substring
57
+ * matching `wif`, for example, would incorrectly reject an unrelated key such
58
+ * as `swift`. Removing common separators catches snake/kebab/spaced variants
59
+ * without widening those short aliases into arbitrary words. */
60
+ const FORBIDDEN_KEY_ALIASES = Object.freeze(["privkey", "wif", "xprv", "seed"]);
61
+
62
+ function isForbiddenKeyName(key) {
63
+ const text = String(key);
64
+ const normalized = text.toLowerCase().replace(/[\s._-]+/g, "");
65
+ return FORBIDDEN_KEY_RE.test(text) || FORBIDDEN_KEY_ALIASES.includes(normalized);
66
+ }
67
+
56
68
  /** String-value shapes that must never appear in any object we return. */
57
69
  const FORBIDDEN_VALUE_RULES = Object.freeze([
58
70
  // 32-byte hex — the shape of a raw EVM private key / session secret. Public
@@ -69,8 +81,12 @@ const FORBIDDEN_VALUE_RULES = Object.freeze([
69
81
  // gets disabled. Smuggling a key inside a longer string is still caught at
70
82
  // the serialized layer for any non-allowlisted field.
71
83
  { 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/ },
84
+ // BIP-32 private extended keys. Public xpub/tpub/ypub/zpub identifiers are
85
+ // intentionally not matched; only their private-key counterparts fail.
86
+ { rule: "extended-private-key", re: /\b(?:xprv|tprv|yprv|zprv)[1-9A-HJ-NP-Za-km-z]{107}\b/ },
87
+ // Bitcoin WIF (mainnet 5/K/L, testnet uncompressed 9 or compressed c) —
88
+ // base58, 51-52 chars.
89
+ { rule: "bitcoin-wif", re: /\b[59KLc][1-9A-HJ-NP-Za-km-z]{50,51}\b/ },
74
90
  // BIP-39 mnemonic: 12/15/18/21/24 lowercase words. Detect a long run of
75
91
  // space-separated short alpha words rather than shipping the wordlist.
76
92
  { rule: "bip39-mnemonic", re: /\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/ },
@@ -169,12 +185,15 @@ export function assertSerializedNoSecrets(value) {
169
185
  if (embedded.test(scanned)) {
170
186
  throw new SecretLeakError("$<serialized>", "raw-32-byte-hex-embedded");
171
187
  }
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");
188
+ // Parse the exact serialized bytes with a reviver so escaped or toJSON-made
189
+ // key names receive the same normalization as real object properties. This
190
+ // avoids maintaining a second, inevitably drifting key-name regex.
191
+ JSON.parse(json, (key, item) => {
192
+ if (key && isForbiddenKeyName(key)) {
193
+ throw new SecretLeakError(`$<serialized>.${key}`, "forbidden-key-name");
194
+ }
195
+ return item;
196
+ });
178
197
  return json;
179
198
  }
180
199
 
@@ -207,7 +226,7 @@ export function assertNoSecretMaterial(value, path = "$", seen = new Set(), keyH
207
226
  if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value;
208
227
  if (value instanceof Map) {
209
228
  for (const [k, v] of value.entries()) {
210
- if (typeof k === "string" && FORBIDDEN_KEY_RE.test(k)) {
229
+ if (typeof k === "string" && isForbiddenKeyName(k)) {
211
230
  throw new SecretLeakError(`${path}[${JSON.stringify(k)}]`, "forbidden-key-name");
212
231
  }
213
232
  assertNoSecretMaterial(k, `${path}[key]`, seen);
@@ -225,7 +244,7 @@ export function assertNoSecretMaterial(value, path = "$", seen = new Set(), keyH
225
244
  return value;
226
245
  }
227
246
  for (const [k, v] of Object.entries(value)) {
228
- if (FORBIDDEN_KEY_RE.test(k)) {
247
+ if (isForbiddenKeyName(k)) {
229
248
  throw new SecretLeakError(`${path}.${k}`, "forbidden-key-name");
230
249
  }
231
250
  assertNoSecretMaterial(v, `${path}.${k}`, seen, k);
@@ -0,0 +1,146 @@
1
+ const SIGNAL_TYPES = Object.freeze([
2
+ "wallet",
3
+ "pool",
4
+ "nft_floor",
5
+ "hl_flow",
6
+ "prediction_market",
7
+ ]);
8
+
9
+ const TYPE_ALIASES = Object.freeze({
10
+ wallets: "wallet",
11
+ smart_wallet: "wallet",
12
+ pools: "pool",
13
+ new_pool: "pool",
14
+ nft: "nft_floor",
15
+ nft_floors: "nft_floor",
16
+ hyperliquid: "hl_flow",
17
+ prediction: "prediction_market",
18
+ prediction_markets: "prediction_market",
19
+ });
20
+
21
+ const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
22
+ const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : null);
23
+ const round = (value) => Math.round(value * 10_000) / 10_000;
24
+
25
+ function typeOf(event) {
26
+ const raw = String(event?.type ?? event?.surface ?? event?.kind ?? "").toLowerCase();
27
+ return TYPE_ALIASES[raw] ?? raw;
28
+ }
29
+
30
+ function idOf(event, index) {
31
+ return String(event?.id ?? event?.signalId ?? event?.asset ?? event?.market ?? event?.pool ?? event?.collection ?? `${typeOf(event)}:${index}`);
32
+ }
33
+
34
+ function add(contributions, feature, value, observed = true) {
35
+ if (!observed || value === null || !Number.isFinite(value)) return;
36
+ contributions.push({ feature, value: round(value) });
37
+ }
38
+
39
+ function walletFeatures(event, out) {
40
+ const buys = finite(event.repeatBuys ?? event.repeat_buys ?? event.buyCount);
41
+ const winRate = finite(event.walletWinRate ?? event.winRate);
42
+ add(out, "repeat_buys", buys === null ? null : clamp((buys - 1) / 4) * 0.38);
43
+ add(out, "wallet_win_rate", winRate === null ? null : (clamp(winRate) - 0.5) * 0.5);
44
+ }
45
+
46
+ function poolFeatures(event, out) {
47
+ const age = finite(event.ageHours ?? event.poolAgeHours);
48
+ const liquidity = finite(event.liquidityUsd ?? event.liquidity);
49
+ const locked = event.liquidityLocked ?? event.locked;
50
+ add(out, "pool_age", age === null ? null : age < 24 ? -0.42 : clamp(age / 720) * 0.12);
51
+ add(out, "liquidity", liquidity === null ? null : (clamp(Math.log10(Math.max(1, liquidity)) / 7) - 0.5) * 0.28);
52
+ add(out, "liquidity_lock", locked == null ? null : locked ? 0.12 : -0.28);
53
+ }
54
+
55
+ function nftFeatures(event, out) {
56
+ const change = finite(event.floorChange ?? event.floorChangePct);
57
+ const sales = finite(event.sales ?? event.saleCount);
58
+ add(out, "floor_change", change === null ? null : clamp(change, -1, 1) * 0.35);
59
+ add(out, "sales_depth", sales === null ? null : clamp(sales / 50) * 0.18);
60
+ }
61
+
62
+ function hlFeatures(event, out) {
63
+ const imbalance = finite(event.flowImbalance ?? event.imbalance);
64
+ const funding = finite(event.fundingRate ?? event.funding);
65
+ add(out, "flow_imbalance", imbalance === null ? null : clamp(imbalance, -1, 1) * 0.42);
66
+ add(out, "funding", funding === null ? null : -clamp(funding * 100, -1, 1) * 0.12);
67
+ }
68
+
69
+ function predictionFeatures(event, out) {
70
+ const market = finite(event.marketProbability ?? event.marketPrice ?? event.probability);
71
+ const fair = finite(event.fairProbability ?? event.referenceProbability ?? event.fairValue);
72
+ add(out, "probability_mispricing", market === null || fair === null ? null : clamp(fair - market, -1, 1) * 0.65);
73
+ const liquidity = finite(event.liquidityUsd ?? event.liquidity);
74
+ add(out, "market_liquidity", liquidity === null ? null : clamp(Math.log10(Math.max(1, liquidity)) / 6) * 0.12);
75
+ }
76
+
77
+ const FEATURE_BUILDERS = Object.freeze({
78
+ wallet: walletFeatures,
79
+ pool: poolFeatures,
80
+ nft_floor: nftFeatures,
81
+ hl_flow: hlFeatures,
82
+ prediction_market: predictionFeatures,
83
+ });
84
+
85
+ function markoutFor(event, id, markouts) {
86
+ const rows = markouts.filter((row) => {
87
+ const target = row?.signalId ?? row?.eventId ?? row?.id ?? row?.asset ?? row?.market;
88
+ return target != null && String(target) === id;
89
+ });
90
+ if (rows.length === 0) return null;
91
+ const values = rows.map((row) => finite(row.return ?? row.markout ?? row.pnl ?? row.value)).filter((value) => value !== null);
92
+ if (values.length === 0) return null;
93
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
94
+ }
95
+
96
+ /** Pure, deterministic scoring of observed onchain events. It never fetches data or executes. */
97
+ export function scoreSignals(events = [], markouts = [], options = {}) {
98
+ if (!Array.isArray(events) || !Array.isArray(markouts)) throw new TypeError("events and markouts must be arrays");
99
+ const minimumCoverage = clamp(finite(options.minimumCoverage) ?? 0.5);
100
+
101
+ return events.map((event, index) => {
102
+ const type = typeOf(event);
103
+ const id = idOf(event, index);
104
+ const builder = FEATURE_BUILDERS[type];
105
+ const contributions = [];
106
+ if (builder) builder(event, contributions);
107
+
108
+ const expected = type === "wallet" ? 2 : type === "pool" ? 3 : 2;
109
+ const coverage = round(clamp(contributions.length / expected));
110
+ const rawFeatureScore = contributions.reduce((sum, item) => sum + item.value, 0);
111
+ const markout = markoutFor(event, id, markouts);
112
+ const markoutContribution = markout === null ? 0 : clamp(markout, -1, 1) * 0.35;
113
+ if (markout !== null) add(contributions, "shadow_markout", markoutContribution);
114
+ const score = round(clamp(0.5 + rawFeatureScore + markoutContribution));
115
+ const markoutFactor = markout === null ? 0.8 : clamp(0.85 + markout * 0.3, 0.35, 1);
116
+ const confidence = round(clamp(coverage * markoutFactor));
117
+ const flags = ["COLD_INTELLIGENCE_ONLY", "NO_HOT_EXECUTION"];
118
+ if (!builder) flags.push("UNSUPPORTED_SIGNAL_TYPE");
119
+ if (coverage < minimumCoverage) flags.push("LOW_COVERAGE", "NO_TRADE");
120
+ if (confidence < 0.5) flags.push("LOW_CONFIDENCE", "NO_TRADE");
121
+ if (type === "pool" && (finite(event.ageHours ?? event.poolAgeHours) ?? Infinity) < 24) flags.push("NEW_POOL_RISK", "NO_TRADE");
122
+ if (markout !== null && markout < 0) flags.push("NEGATIVE_MARKOUT");
123
+
124
+ return {
125
+ id,
126
+ type,
127
+ score,
128
+ confidence,
129
+ coverage,
130
+ contributions,
131
+ flags: [...new Set(flags)],
132
+ noTrade: flags.includes("NO_TRADE"),
133
+ executionAllowed: false,
134
+ };
135
+ }).sort((a, b) => b.score - a.score || b.confidence - a.confidence || a.id.localeCompare(b.id));
136
+ }
137
+
138
+ export function createSignalsEngine(defaults = {}) {
139
+ return Object.freeze({
140
+ score(events, markouts = [], options = {}) {
141
+ return scoreSignals(events, markouts, { ...defaults, ...options });
142
+ },
143
+ });
144
+ }
145
+
146
+ export { SIGNAL_TYPES };
@@ -0,0 +1 @@
1
+ export { SIGNAL_TYPES, createSignalsEngine, scoreSignals } from "./engine.mjs";
@@ -0,0 +1,85 @@
1
+ /** Notification classes supported by Oracle watches. */
2
+ export const WATCH_CATEGORIES = Object.freeze([
3
+ 'price',
4
+ 'wallet',
5
+ 'risk',
6
+ 'execution',
7
+ 'security',
8
+ 'nft',
9
+ 'governance',
10
+ 'system',
11
+ ]);
12
+
13
+ const CATEGORY_SET = new Set(WATCH_CATEGORIES);
14
+
15
+ function assertCategory(category) {
16
+ if (!CATEGORY_SET.has(category)) {
17
+ throw new TypeError(`Unknown watch category: ${String(category)}`);
18
+ }
19
+ return category;
20
+ }
21
+
22
+ function subscribedCategories(preferences = {}) {
23
+ const categories = preferences.subscribedCategories ?? [];
24
+ if (!Array.isArray(categories)) {
25
+ throw new TypeError('subscribedCategories must be an array');
26
+ }
27
+ return categories;
28
+ }
29
+
30
+ /** New users receive no category notifications until they explicitly opt in. */
31
+ export function defaultPreferences() {
32
+ return { subscribedCategories: [] };
33
+ }
34
+
35
+ /** Return a new preference value with exactly one notification class enabled. */
36
+ export function subscribe(preferences, category) {
37
+ assertCategory(category);
38
+ const current = subscribedCategories(preferences);
39
+ if (current.includes(category)) return { ...preferences, subscribedCategories: [...current] };
40
+ return { ...preferences, subscribedCategories: [...current, category] };
41
+ }
42
+
43
+ /** Return a new preference value with the requested notification class disabled. */
44
+ export function unsubscribe(preferences, category) {
45
+ assertCategory(category);
46
+ const current = subscribedCategories(preferences);
47
+ return {
48
+ ...preferences,
49
+ subscribedCategories: current.filter((candidate) => candidate !== category),
50
+ };
51
+ }
52
+
53
+ /** Create a direct watch. Notifications are opt-in independently for every watch. */
54
+ export function createWatch({ category, notify = false, ...watch } = {}) {
55
+ assertCategory(category);
56
+ if (typeof notify !== 'boolean') throw new TypeError('watch notify must be a boolean');
57
+ return { ...watch, category, notify };
58
+ }
59
+
60
+ function isMatchingNotifyingWatch(alert, watch) {
61
+ if (!watch || watch.notify !== true || watch.category !== alert.category) return false;
62
+ if (alert.watchId !== undefined && watch.id !== alert.watchId) return false;
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * Decide whether an alert may be delivered. This function only describes policy;
68
+ * it performs no scheduling or delivery.
69
+ */
70
+ export function evaluateAlert(alert, preferences = defaultPreferences(), watch) {
71
+ if (!alert || typeof alert !== 'object') throw new TypeError('alert must be an object');
72
+ assertCategory(alert.category);
73
+
74
+ if (subscribedCategories(preferences).includes(alert.category)) {
75
+ return { deliver: true, reason: 'category-subscribed' };
76
+ }
77
+ if (isMatchingNotifyingWatch(alert, watch)) {
78
+ return { deliver: true, reason: 'watch-opt-in' };
79
+ }
80
+ return { deliver: false, reason: 'not-subscribed' };
81
+ }
82
+
83
+ export function shouldDeliverAlert(alert, preferences, watch) {
84
+ return evaluateAlert(alert, preferences, watch).deliver;
85
+ }