@oracle-agent/oracle 0.4.1 → 0.4.2

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.
@@ -0,0 +1,168 @@
1
+ // Deterministic, public-only receipts for prepared and executed actions.
2
+ //
3
+ // This module deliberately has no authority and no I/O: it does not authorize,
4
+ // sign, broadcast, or fetch. It only records facts supplied by its caller.
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ export const ACTION_RECEIPT_VERSION = 1;
9
+
10
+ const SECRET_KEYS = new Set([
11
+ "privatekey",
12
+ "mnemonic",
13
+ "bearer",
14
+ "authorization",
15
+ "signature",
16
+ ]);
17
+
18
+ export class ActionReceiptSecretError extends Error {
19
+ constructor(path) {
20
+ super(`action receipt refused: secret-bearing field at ${path}`);
21
+ this.name = "ActionReceiptSecretError";
22
+ this.code = "ACTION_RECEIPT_SECRET";
23
+ }
24
+ }
25
+
26
+ function normalizedKey(key) {
27
+ return String(key).replace(/[^a-z0-9]/gi, "").toLowerCase();
28
+ }
29
+
30
+ function isSecretKey(key) {
31
+ const normalized = normalizedKey(key);
32
+ return [...SECRET_KEYS].some((secret) => normalized.includes(secret));
33
+ }
34
+
35
+ function publicValue(value, path = "$") {
36
+ if (value == null || typeof value === "string" || typeof value === "boolean") return value;
37
+ if (typeof value === "number") {
38
+ if (!Number.isFinite(value)) throw new TypeError(`action receipt: non-finite number at ${path}`);
39
+ return value;
40
+ }
41
+ if (typeof value === "bigint") return value.toString();
42
+ if (Array.isArray(value)) return value.map((item, index) => publicValue(item, `${path}[${index}]`));
43
+ if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) {
44
+ throw new TypeError(`action receipt: JSON-like value required at ${path}`);
45
+ }
46
+
47
+ const out = {};
48
+ for (const key of Object.keys(value).sort()) {
49
+ const childPath = `${path}.${key}`;
50
+ if (isSecretKey(key)) throw new ActionReceiptSecretError(childPath);
51
+ if (value[key] !== undefined) out[key] = publicValue(value[key], childPath);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Recursively reject secret-bearing fields and return a detached public copy. */
57
+ export function assertNoReceiptSecrets(value) {
58
+ return publicValue(value);
59
+ }
60
+
61
+ /** Canonical JSON with recursively sorted object keys. */
62
+ export function canonicalReceiptJson(value) {
63
+ const safe = publicValue(value);
64
+ if (safe === null || typeof safe !== "object") return JSON.stringify(safe);
65
+ if (Array.isArray(safe)) return `[${safe.map(canonicalReceiptJson).join(",")}]`;
66
+ return `{${Object.keys(safe).map((key) => `${JSON.stringify(key)}:${canonicalReceiptJson(safe[key])}`).join(",")}}`;
67
+ }
68
+
69
+ /** Hash exactly the public receipt fields, excluding the self-referential id. */
70
+ export function computeReceiptId(receipt) {
71
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
72
+ throw new TypeError("action receipt: object required");
73
+ }
74
+ const { receiptId: _ignored, summary: _derived, ...publicFields } = receipt;
75
+ return createHash("sha256").update(canonicalReceiptJson(publicFields)).digest("hex");
76
+ }
77
+
78
+ function supplied(input, ...keys) {
79
+ for (const key of keys) if (input[key] !== undefined) return input[key];
80
+ return undefined;
81
+ }
82
+
83
+ function required(input, keys, label) {
84
+ const value = supplied(input, ...keys);
85
+ if (value === undefined || value === null) throw new TypeError(`action receipt: ${label} required`);
86
+ return value;
87
+ }
88
+
89
+ function balanceFields(input) {
90
+ const balances = supplied(input, "balances", "balanceChanges");
91
+ const before = supplied(input, "beforeBalances", "balancesBefore", "before");
92
+ const after = supplied(input, "afterBalances", "balancesAfter", "after");
93
+ if (balances !== undefined) return { balances };
94
+ if (before === undefined && after === undefined) return {};
95
+ return { balances: { ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) } };
96
+ }
97
+
98
+ /**
99
+ * Normalize caller-supplied prepare/execute facts into the versioned schema.
100
+ * Unknown input fields are intentionally omitted from the receipt.
101
+ */
102
+ export function normalizeActionReceipt(input = {}) {
103
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
104
+ throw new TypeError("action receipt: input object required");
105
+ }
106
+ // Scan the complete input before selecting fields so a secret cannot be
107
+ // hidden in an otherwise unknown property and accidentally appear later.
108
+ publicValue(input);
109
+
110
+ const txHash = supplied(input, "txHash", "transactionHash");
111
+ const phase = supplied(input, "phase", "stage", "resultType") ?? (txHash == null ? "prepare" : "execute");
112
+ if (phase !== "prepare" && phase !== "execute") {
113
+ throw new TypeError('action receipt: phase must be "prepare" or "execute"');
114
+ }
115
+
116
+ const fields = {
117
+ receiptVersion: ACTION_RECEIPT_VERSION,
118
+ phase,
119
+ intent: required(input, ["intent"], "intent"),
120
+ route: required(input, ["route"], "route"),
121
+ decodedAction: required(input, ["decodedAction", "action"], "decodedAction"),
122
+ policyChecks: required(input, ["policyChecks", "checks"], "policyChecks"),
123
+ allowlistHits: required(input, ["allowlistHits", "allowlistMatches"], "allowlistHits"),
124
+ prepareHash: required(input, ["prepareHash"], "prepareHash"),
125
+ ...(txHash == null ? {} : { txHash }),
126
+ ...balanceFields(input),
127
+ };
128
+ const receipt = publicValue(fields);
129
+ return Object.freeze({ ...receipt, receiptId: computeReceiptId(receipt) });
130
+ }
131
+
132
+ export const createActionReceipt = normalizeActionReceipt;
133
+
134
+ function label(value, preferred = []) {
135
+ if (typeof value === "string" || typeof value === "number") return String(value);
136
+ for (const key of preferred) if (value?.[key] != null) return String(value[key]);
137
+ return "recorded";
138
+ }
139
+
140
+ function checkResult(check) {
141
+ if (typeof check === "boolean") return check;
142
+ if (check && typeof check === "object") {
143
+ if (typeof check.ok === "boolean") return check.ok;
144
+ if (typeof check.allowed === "boolean") return check.allowed;
145
+ if (typeof check.passed === "boolean") return check.passed;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** Produce a compact human-readable statement without changing the receipt. */
151
+ export function summarizeActionReceipt(receipt) {
152
+ if (!receipt || typeof receipt !== "object") throw new TypeError("action receipt: object required");
153
+ const action = label(receipt.decodedAction, ["type", "action", "method", "name"]);
154
+ const route = label(receipt.route, ["name", "provider", "venue", "id"]);
155
+ const checks = Array.isArray(receipt.policyChecks)
156
+ ? receipt.policyChecks
157
+ : Object.values(receipt.policyChecks || {});
158
+ const passed = checks.filter((check) => checkResult(check) === true).length;
159
+ const failed = checks.filter((check) => checkResult(check) === false).length;
160
+ const hits = Array.isArray(receipt.allowlistHits)
161
+ ? receipt.allowlistHits.length
162
+ : Object.keys(receipt.allowlistHits || {}).length;
163
+ const tx = receipt.txHash == null ? "no transaction hash" : `tx ${receipt.txHash}`;
164
+ const balances = receipt.balances == null ? "balances not provided" : "before/after balances recorded";
165
+ return `${receipt.phase} ${action} via ${route}; policy ${passed} passed, ${failed} failed; ${hits} allowlist hit${hits === 1 ? "" : "s"}; ${tx}; ${balances}; receipt ${receipt.receiptId}`;
166
+ }
167
+
168
+ export const formatActionReceipt = summarizeActionReceipt;
@@ -14,6 +14,47 @@ const FORBIDDEN_KEYS = new Set([
14
14
  "privatekey", "private_key", "secret", "seed", "mnemonic", "passphrase", "signature", "keystore",
15
15
  ]);
16
16
 
17
+ // Exact-name matching missed obvious aliases (secretKey, seedPhrase, wif,
18
+ // keyMaterial, xprv...). Match on a normalized key instead: strip separators,
19
+ // lowercase, then look for any secret-ish token as a substring. A label store
20
+ // has no legitimate field containing these words.
21
+ const FORBIDDEN_TOKENS = [
22
+ "privatekey", "privkey", "secretkey", "secret", "seedphrase", "seed", "mnemonic",
23
+ "passphrase", "password", "keystore", "keymaterial", "signature", "wif",
24
+ "xprv", "xpriv", "apikey", "bearer", "credential",
25
+ ];
26
+
27
+ // Key-shaped values must not be persisted even in a free-text field. WIF, xprv,
28
+ // and BIP39 shapes are unambiguous. A bare 64-hex string is NOT — a raw EVM
29
+ // private key and a bytes32 tx hash are byte-identical — so that rule matches
30
+ // only when the WHOLE field is the hex blob. "paid via tx 0xabc..." is a note,
31
+ // not a leaked key.
32
+ const KEY_SHAPED_VALUE = [
33
+ /\b[5KL][1-9A-HJ-NP-Za-km-z]{50,51}\b/,
34
+ /\bxprv[0-9A-Za-z]{50,}\b/,
35
+ /\b(?:[a-z]{3,8}\s+){11,}[a-z]{3,8}\b/i,
36
+ ];
37
+
38
+ const BARE_32_BYTE_HEX = /^\s*(?:0x)?[0-9a-fA-F]{64}\s*$/;
39
+
40
+ function normalizeKeyName(key) {
41
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
42
+ }
43
+
44
+ function assertNoSecretKeyName(key, path) {
45
+ const normalized = normalizeKeyName(key);
46
+ if (FORBIDDEN_KEYS.has(String(key).toLowerCase()) || FORBIDDEN_TOKENS.some((t) => normalized.includes(t))) {
47
+ throw new Error(`address-book: ${path} is forbidden; the book stores labels, never key material`);
48
+ }
49
+ }
50
+
51
+ function assertNoSecretValue(value, path) {
52
+ if (typeof value !== "string") return;
53
+ if (KEY_SHAPED_VALUE.some((re) => re.test(value)) || BARE_32_BYTE_HEX.test(value)) {
54
+ throw new Error(`address-book: ${path} looks like key material; the book stores labels, never key material`);
55
+ }
56
+ }
57
+
17
58
  export function addressBookPath() {
18
59
  const override = env("ORACLE_ADDRESS_BOOK", "MAD_ADDRESS_BOOK", "");
19
60
  if (override) return path.resolve(override);
@@ -21,11 +62,18 @@ export function addressBookPath() {
21
62
  return path.join(dir, "address-book.json");
22
63
  }
23
64
 
24
- function assertNoSecrets(input) {
25
- for (const key of Object.keys(input || {})) {
26
- if (FORBIDDEN_KEYS.has(key.toLowerCase())) {
27
- throw new Error(`address-book: ${key} is forbidden; the book stores labels, never key material`);
28
- }
65
+ function assertNoSecrets(input, path = "field") {
66
+ if (Array.isArray(input)) {
67
+ input.forEach((item, i) => assertNoSecrets(item, `${path}[${i}]`));
68
+ return;
69
+ }
70
+ if (!input || typeof input !== "object") {
71
+ assertNoSecretValue(input, path);
72
+ return;
73
+ }
74
+ for (const [key, value] of Object.entries(input)) {
75
+ assertNoSecretKeyName(key, key);
76
+ assertNoSecrets(value, key);
29
77
  }
30
78
  }
31
79
 
@@ -419,6 +419,12 @@ export function enforceTxPolicy(tx = {}, phase = "broadcast") {
419
419
  // Spending caps toggle. Set MAD_VALUE_CAPS_ENABLED=0 in exec.env to lift the
420
420
  // per-tx + daily native caps (chain/dest allowlists still enforced). Flip back
421
421
  // to 1 to re-arm the cap wall. Default = ON when unset.
422
+ //
423
+ // Deliberately strict: ONLY the literal "0" lifts the caps. This is the
424
+ // opposite polarity from a kill switch. Disabling a kill switch makes a
425
+ // deployment safer, so it accepts 0/false/off/no. Disabling THIS makes a
426
+ // deployment riskier, so a stray "false" must keep the cap wall standing
427
+ // rather than silently removing it. Do not route this through envEnabled.
422
428
  const capsOn = String(env("ORACLE_VALUE_CAPS_ENABLED", "MAD_VALUE_CAPS_ENABLED", "1")).trim() !== "0";
423
429
  if (!capsOn) return true;
424
430
 
package/src/index.mjs CHANGED
@@ -67,3 +67,27 @@ export {
67
67
  DEFAULT_SCOPES,
68
68
  normalizeScopes,
69
69
  } from "./scopes.mjs";
70
+
71
+ export { emitHarnessConfigs, createHarnessConfigs } from "./onboarding/harness-configs.mjs";
72
+ export {
73
+ ACTION_RECEIPT_VERSION,
74
+ ActionReceiptSecretError,
75
+ assertNoReceiptSecrets,
76
+ canonicalReceiptJson,
77
+ computeReceiptId,
78
+ normalizeActionReceipt,
79
+ createActionReceipt,
80
+ summarizeActionReceipt,
81
+ formatActionReceipt,
82
+ } from "./action-receipts.mjs";
83
+ export { DEFAULT_RISK_THRESHOLDS, summarizePortfolioRisk, portfolioRiskSummary } from "./portfolio-risk.mjs";
84
+ export {
85
+ WATCH_CATEGORIES,
86
+ defaultPreferences,
87
+ subscribe,
88
+ unsubscribe,
89
+ createWatch,
90
+ evaluateAlert,
91
+ shouldDeliverAlert,
92
+ } from "./watch-preferences.mjs";
93
+ export { SIGNAL_TYPES, scoreSignals, createSignalsEngine } from "./signals/index.mjs";
@@ -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;