@oracle-agent/oracle 0.3.1 → 0.3.3
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.
- package/README.md +44 -12
- package/SETUP.md +49 -1
- package/artifacts/specialist-packs/oracle-full-crypto.json +31 -9
- package/bin/oracle-data-mcp.mjs +247 -4
- package/bin/oracle-init.mjs +100 -27
- package/docs/profiles.md +27 -7
- package/package.json +1 -1
- package/profiles/_template/SOUL.md +8 -1
- package/profiles/oracle/SOUL.md +8 -1
- package/profiles/oracle/profile.json +5 -2
- package/profiles/protocol-builder/SOUL.md +13 -6
- package/profiles/protocol-builder/profile.json +3 -1
- package/public/index.html +12 -0
- package/skills/balance/SKILL.md +176 -0
- package/skills/oracle-multichain-nft-launch/SKILL.md +338 -0
- package/skills/oracle-multichain-token-launch/SKILL.md +300 -0
- package/src/agent-auth.mjs +15 -0
- package/src/data/catalog.mjs +27 -3
- package/src/data/desk-data.mjs +34 -4
- package/src/data/providers/magiceden-sol.mjs +21 -2
- package/src/data/providers/nft-gallery.mjs +163 -0
- package/src/data/providers/nft-portfolio.mjs +494 -0
- package/src/data/providers/opensea-nft.mjs +272 -0
- package/src/data/providers/portfolio-history.mjs +394 -0
- package/src/data/providers/portfolio.mjs +594 -0
- package/src/data/providers/satflow.mjs +1 -0
- package/src/exec-policy.mjs +5 -0
- package/src/gmx-attestation.mjs +1 -0
- package/src/index.mjs +1 -0
- package/src/prepare-envelope.mjs +66 -10
- package/src/vault-attestation.mjs +1 -0
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
// OpenSea NFT floors — uses OPENSEA_API_KEY from env or ~/.config/locals-only/opensea.env
|
|
2
2
|
|
|
3
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { getAddress } from "ethers";
|
|
4
5
|
import { httpJson } from "../http.mjs";
|
|
5
6
|
import { resolveProviderEndpoint, credentialedHeaders } from "../provider-endpoint.mjs";
|
|
7
|
+
import { stampPrepared } from "../../prepare-envelope.mjs";
|
|
6
8
|
|
|
7
9
|
export const OPENSEA_API = "https://api.opensea.io/api/v2";
|
|
8
10
|
|
|
11
|
+
export const OPENSEA_ACCOUNT_CHAINS = Object.freeze([
|
|
12
|
+
"blast",
|
|
13
|
+
"base",
|
|
14
|
+
"ethereum",
|
|
15
|
+
"zora",
|
|
16
|
+
"arbitrum",
|
|
17
|
+
"sei",
|
|
18
|
+
"avalanche",
|
|
19
|
+
"polygon",
|
|
20
|
+
"optimism",
|
|
21
|
+
"ape_chain",
|
|
22
|
+
"flow",
|
|
23
|
+
"b3",
|
|
24
|
+
"soneium",
|
|
25
|
+
"ronin",
|
|
26
|
+
"bera_chain",
|
|
27
|
+
"solana",
|
|
28
|
+
"shape",
|
|
29
|
+
"unichain",
|
|
30
|
+
"gunzilla",
|
|
31
|
+
"abstract",
|
|
32
|
+
"animechain",
|
|
33
|
+
"hyperevm",
|
|
34
|
+
"somnia",
|
|
35
|
+
"monad",
|
|
36
|
+
"hyperliquid",
|
|
37
|
+
"megaeth",
|
|
38
|
+
"ink",
|
|
39
|
+
"robinhood",
|
|
40
|
+
"stablechain",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const OPENSEA_CHAIN_SET = new Set(OPENSEA_ACCOUNT_CHAINS);
|
|
44
|
+
const OPENSEA_NON_EVM_CHAINS = new Set(["solana", "flow"]);
|
|
45
|
+
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
46
|
+
|
|
9
47
|
function loadKeyFromDisk() {
|
|
10
48
|
const p = process.env.OPENSEA_ENV_FILE || `${process.env.HOME || ""}/.config/locals-only/opensea.env`;
|
|
11
49
|
if (!existsSync(p)) return "";
|
|
@@ -42,6 +80,81 @@ function headers(opts = {}) {
|
|
|
42
80
|
return credentialedHeaders({ accept: "application/json" }, { "x-api-key": apiKey(opts) }, endpoint(opts).trusted);
|
|
43
81
|
}
|
|
44
82
|
|
|
83
|
+
function accountChain(value) {
|
|
84
|
+
const chain = String(value || "ethereum").trim().toLowerCase();
|
|
85
|
+
if (!OPENSEA_CHAIN_SET.has(chain)) throw new Error(`opensea: unsupported account chain ${chain}`);
|
|
86
|
+
return chain;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function evmAddress(value, label = "address") {
|
|
90
|
+
try {
|
|
91
|
+
return getAddress(String(value || "").trim().toLowerCase());
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`opensea: invalid ${label}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function accountAddress(value, chain = null) {
|
|
98
|
+
const address = String(value || "").trim();
|
|
99
|
+
if (!address) throw new Error("opensea: account address required");
|
|
100
|
+
if (chain && !OPENSEA_NON_EVM_CHAINS.has(chain)) return evmAddress(address, "account address");
|
|
101
|
+
if (!chain && /^0x[0-9a-fA-F]{40}$/.test(address)) return evmAddress(address, "account address");
|
|
102
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,64}$/.test(address)) throw new Error("opensea: invalid account address");
|
|
103
|
+
return address;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function positiveDecimal(value, label) {
|
|
107
|
+
const text = String(value ?? "").trim();
|
|
108
|
+
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text) || Number(text) <= 0) {
|
|
109
|
+
throw new Error(`opensea: ${label} must be a positive plain decimal`);
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function positiveInteger(value, label) {
|
|
115
|
+
const n = Number(value);
|
|
116
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`opensea: ${label} must be a positive integer`);
|
|
117
|
+
return n;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isoTime(value, label) {
|
|
121
|
+
const text = String(value || "").trim();
|
|
122
|
+
const ms = Date.parse(text);
|
|
123
|
+
if (!text || !Number.isFinite(ms)) throw new Error(`opensea: ${label} must be an ISO 8601 timestamp`);
|
|
124
|
+
return { text: new Date(ms).toISOString(), ms };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeAccountNft(raw = {}, chain, owner) {
|
|
128
|
+
return {
|
|
129
|
+
chain,
|
|
130
|
+
owner,
|
|
131
|
+
contract: raw.contract || null,
|
|
132
|
+
tokenId: raw.identifier == null ? null : String(raw.identifier),
|
|
133
|
+
tokenStandard: raw.token_standard || null,
|
|
134
|
+
collection: raw.collection || null,
|
|
135
|
+
name: raw.name || null,
|
|
136
|
+
description: raw.description || null,
|
|
137
|
+
imageUrl: raw.display_image_url || raw.image_url || raw.original_image_url || null,
|
|
138
|
+
originalImageUrl: raw.original_image_url || raw.image_url || null,
|
|
139
|
+
animationUrl: raw.display_animation_url || raw.original_animation_url || null,
|
|
140
|
+
metadataUrl: raw.metadata_url || null,
|
|
141
|
+
marketplaceUrl: raw.opensea_url || null,
|
|
142
|
+
estimatedValueUsd: raw.estimated_value_usd != null && Number.isFinite(Number(raw.estimated_value_usd))
|
|
143
|
+
? Number(raw.estimated_value_usd)
|
|
144
|
+
: null,
|
|
145
|
+
spam: raw.is_disabled === true || raw.is_nsfw === true,
|
|
146
|
+
disabled: raw.is_disabled === true,
|
|
147
|
+
nsfw: raw.is_nsfw === true,
|
|
148
|
+
traits: Array.isArray(raw.traits) ? raw.traits : [],
|
|
149
|
+
updatedAt: raw.updated_at || null,
|
|
150
|
+
acquisitionCost: null,
|
|
151
|
+
itemPnl: {
|
|
152
|
+
status: "unavailable",
|
|
153
|
+
reason: "OpenSea account inventory does not expose trustworthy per-item acquisition cost",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
45
158
|
/** Shared request context for the multichain scanner. */
|
|
46
159
|
export function openseaContext(opts = {}) {
|
|
47
160
|
return {
|
|
@@ -97,3 +210,162 @@ export async function openseaFloor(slug, opts = {}) {
|
|
|
97
210
|
},
|
|
98
211
|
};
|
|
99
212
|
}
|
|
213
|
+
|
|
214
|
+
/** Paginated account NFT inventory for one OpenSea-supported chain. */
|
|
215
|
+
export async function openseaAccountNfts(args = {}, opts = {}) {
|
|
216
|
+
if (!apiKey(opts)) throw new Error("OPENSEA_API_KEY required");
|
|
217
|
+
const chain = accountChain(args.chain);
|
|
218
|
+
const owner = accountAddress(args.address || args.owner, chain);
|
|
219
|
+
const pageSize = Math.min(Math.max(Number(args.pageSize ?? args.limit ?? 200) || 200, 1), 200);
|
|
220
|
+
const maxPages = Math.min(Math.max(Number(args.maxPages ?? 5) || 5, 1), 25);
|
|
221
|
+
const nfts = [];
|
|
222
|
+
let next = args.next ? String(args.next) : null;
|
|
223
|
+
let pages = 0;
|
|
224
|
+
|
|
225
|
+
do {
|
|
226
|
+
const url = new URL(`${base(opts)}/chain/${encodeURIComponent(chain)}/account/${encodeURIComponent(owner)}/nfts`);
|
|
227
|
+
url.searchParams.set("limit", String(pageSize));
|
|
228
|
+
if (args.collection) url.searchParams.set("collection", String(args.collection));
|
|
229
|
+
if (next) url.searchParams.set("next", next);
|
|
230
|
+
const raw = await httpJson(url.toString(), {
|
|
231
|
+
headers: headers(opts),
|
|
232
|
+
fetchImpl: opts.fetchImpl,
|
|
233
|
+
timeoutMs: opts.timeoutMs ?? 20_000,
|
|
234
|
+
});
|
|
235
|
+
for (const item of Array.isArray(raw?.nfts) ? raw.nfts : []) {
|
|
236
|
+
nfts.push(normalizeAccountNft(item, chain, owner));
|
|
237
|
+
}
|
|
238
|
+
next = raw?.next ? String(raw.next) : null;
|
|
239
|
+
pages += 1;
|
|
240
|
+
} while (next && pages < maxPages);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
provider: "opensea-nft",
|
|
244
|
+
source: "opensea-account-inventory",
|
|
245
|
+
chain,
|
|
246
|
+
owner,
|
|
247
|
+
count: nfts.length,
|
|
248
|
+
pages,
|
|
249
|
+
complete: !next,
|
|
250
|
+
next,
|
|
251
|
+
nfts,
|
|
252
|
+
exec: false,
|
|
253
|
+
readOnly: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** OpenSea-indexed account PnL. This is account-level, not per-NFT cost basis. */
|
|
258
|
+
export async function openseaAccountPnl(args = {}, opts = {}) {
|
|
259
|
+
if (!apiKey(opts)) throw new Error("OPENSEA_API_KEY required");
|
|
260
|
+
const address = accountAddress(args.address || args.owner);
|
|
261
|
+
const raw = await httpJson(`${base(opts)}/account/${encodeURIComponent(address)}/pnl`, {
|
|
262
|
+
headers: headers(opts),
|
|
263
|
+
fetchImpl: opts.fetchImpl,
|
|
264
|
+
timeoutMs: opts.timeoutMs ?? 20_000,
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
provider: "opensea-nft",
|
|
268
|
+
source: "opensea-indexed-account-pnl",
|
|
269
|
+
address,
|
|
270
|
+
scope: "OpenSea-indexed account trading PnL across supported currencies, not per-item NFT cost basis",
|
|
271
|
+
methodology: "provider-indexed",
|
|
272
|
+
realizedPnlUsd: raw?.realized_pnl_usd ?? null,
|
|
273
|
+
unrealizedPnlUsd: raw?.unrealized_pnl_usd ?? null,
|
|
274
|
+
totalPnlUsd: raw?.total_pnl_usd ?? null,
|
|
275
|
+
netInvestedUsd: raw?.net_invested_usd ?? null,
|
|
276
|
+
currentValueUsd: raw?.current_value_usd ?? null,
|
|
277
|
+
returnPercentage: raw?.return_percentage ?? null,
|
|
278
|
+
itemLevelPnlAvailable: false,
|
|
279
|
+
raw,
|
|
280
|
+
exec: false,
|
|
281
|
+
readOnly: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeListingItem(raw = {}) {
|
|
286
|
+
const chain = accountChain(raw.chain);
|
|
287
|
+
if (OPENSEA_NON_EVM_CHAINS.has(chain)) {
|
|
288
|
+
throw new Error("opensea: listing actions currently require an EVM NFT chain");
|
|
289
|
+
}
|
|
290
|
+
const start = raw.startTime || raw.start_time
|
|
291
|
+
? isoTime(raw.startTime || raw.start_time, "startTime")
|
|
292
|
+
: { text: new Date().toISOString(), ms: Date.now() };
|
|
293
|
+
const end = isoTime(raw.endTime || raw.end_time, "endTime");
|
|
294
|
+
if (end.ms <= start.ms) throw new Error("opensea: endTime must be after startTime");
|
|
295
|
+
const tokenId = String(raw.tokenId ?? raw.token_id ?? "").trim();
|
|
296
|
+
if (!/^\d+$/.test(tokenId)) throw new Error("opensea: tokenId must be an unsigned integer string");
|
|
297
|
+
return {
|
|
298
|
+
chain,
|
|
299
|
+
contract: evmAddress(raw.contract, "NFT contract"),
|
|
300
|
+
token_id: tokenId,
|
|
301
|
+
quantity: positiveInteger(raw.quantity ?? 1, "quantity"),
|
|
302
|
+
price: {
|
|
303
|
+
amount: positiveDecimal(raw.price?.amount ?? raw.amount ?? raw.priceAmount, "price amount"),
|
|
304
|
+
currency: evmAddress(raw.price?.currency ?? raw.currency ?? ZERO_ADDRESS, "price currency"),
|
|
305
|
+
},
|
|
306
|
+
start_time: start.text,
|
|
307
|
+
end_time: end.text,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function containsApprovalStep(steps = []) {
|
|
312
|
+
return steps.some((step) => /approv|setapprovalforall/i.test(JSON.stringify(step)));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Ask OpenSea for approval and Seaport signing actions. Nothing is signed,
|
|
317
|
+
* submitted, or broadcast. The user's wallet handles every returned action.
|
|
318
|
+
*/
|
|
319
|
+
export async function openseaPrepareList(args = {}, opts = {}) {
|
|
320
|
+
if (!apiKey(opts)) throw new Error("OPENSEA_API_KEY required");
|
|
321
|
+
if (args.userConfirmed !== true) {
|
|
322
|
+
throw new Error("opensea: userConfirmed=true required after NFT, marketplace, price, currency, and expiry review");
|
|
323
|
+
}
|
|
324
|
+
const seller = evmAddress(args.seller || args.address || args.owner, "seller");
|
|
325
|
+
const sourceItems = Array.isArray(args.items) && args.items.length ? args.items : [args];
|
|
326
|
+
if (sourceItems.length > 50) throw new Error("opensea: at most 50 listing items per preparation");
|
|
327
|
+
const items = sourceItems.map(normalizeListingItem);
|
|
328
|
+
const chains = new Set(items.map((item) => item.chain));
|
|
329
|
+
if (chains.size !== 1) throw new Error("opensea: all prepared listing items must be on one chain");
|
|
330
|
+
const useCreatorFee = args.useCreatorFee !== false;
|
|
331
|
+
const request = {
|
|
332
|
+
address: seller,
|
|
333
|
+
items,
|
|
334
|
+
use_creator_fee: useCreatorFee,
|
|
335
|
+
};
|
|
336
|
+
if (args.taker) request.taker = evmAddress(args.taker, "taker");
|
|
337
|
+
|
|
338
|
+
const raw = await httpJson(`${base(opts)}/listings/actions`, {
|
|
339
|
+
method: "POST",
|
|
340
|
+
headers: headers(opts),
|
|
341
|
+
body: request,
|
|
342
|
+
fetchImpl: opts.fetchImpl,
|
|
343
|
+
timeoutMs: opts.timeoutMs ?? 25_000,
|
|
344
|
+
retries: 0,
|
|
345
|
+
dedupe: false,
|
|
346
|
+
});
|
|
347
|
+
const steps = Array.isArray(raw?.steps) ? raw.steps : [];
|
|
348
|
+
if (!steps.length) throw new Error("opensea: listing action response contained no wallet steps");
|
|
349
|
+
|
|
350
|
+
return stampPrepared({
|
|
351
|
+
provider: "opensea-nft",
|
|
352
|
+
marketplace: "opensea",
|
|
353
|
+
chain: items[0].chain,
|
|
354
|
+
kind: "nft-list-actions",
|
|
355
|
+
prepareReady: true,
|
|
356
|
+
executionReady: false,
|
|
357
|
+
signingReady: false,
|
|
358
|
+
broadcastReady: false,
|
|
359
|
+
requiresUserSignature: true,
|
|
360
|
+
requiresSeparateApproval: containsApprovalStep(steps),
|
|
361
|
+
seller,
|
|
362
|
+
items,
|
|
363
|
+
useCreatorFee,
|
|
364
|
+
taker: request.taker || null,
|
|
365
|
+
feeDisclosure: useCreatorFee
|
|
366
|
+
? "Creator fees requested. Inspect the returned Seaport consideration recipients and wallet simulation before signing."
|
|
367
|
+
: "Creator fees disabled by explicit request. Marketplace protocol fees may still apply and must be inspected before signing.",
|
|
368
|
+
steps,
|
|
369
|
+
note: "Execute approvals separately, then sign the Seaport order in the user's wallet. Oracle does not submit or broadcast the listing.",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { portfolioBalance, resolvePortfolioAddresses } from "./portfolio.mjs";
|
|
7
|
+
import { nftInventory } from "./nft-portfolio.mjs";
|
|
8
|
+
|
|
9
|
+
const MAX_HISTORY_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const MAX_HISTORY_LIMIT = 1000;
|
|
11
|
+
|
|
12
|
+
function finiteNumber(value) {
|
|
13
|
+
if (value == null || value === "") return null;
|
|
14
|
+
const number = Number(value);
|
|
15
|
+
return Number.isFinite(number) ? number : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function roundedUsd(value) {
|
|
19
|
+
return Number.isFinite(value) ? Number(value.toFixed(2)) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isoDate(value, label) {
|
|
23
|
+
if (value == null || value === "") return null;
|
|
24
|
+
const time = Date.parse(String(value));
|
|
25
|
+
if (!Number.isFinite(time)) throw new Error(`${label} must be an ISO 8601 timestamp`);
|
|
26
|
+
return new Date(time).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function boundedInteger(value, fallback, min, max, label) {
|
|
30
|
+
if (value == null || value === "") return fallback;
|
|
31
|
+
const number = Number(value);
|
|
32
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
33
|
+
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
34
|
+
}
|
|
35
|
+
return number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function canonicalAddresses(addresses = {}) {
|
|
39
|
+
return ["evm", "solana", "bitcoin", "hyperliquid"]
|
|
40
|
+
.map((family) => `${family}:${String(addresses[family] || "").trim().toLowerCase()}`)
|
|
41
|
+
.join("|");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function portfolioIdForAddresses(addresses = {}) {
|
|
45
|
+
const digest = createHash("sha256").update(canonicalAddresses(addresses)).digest("hex").slice(0, 20);
|
|
46
|
+
return `portfolio_${digest}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolvePortfolioHistoryFile(opts = {}) {
|
|
50
|
+
if (opts.historyFile) return path.resolve(String(opts.historyFile));
|
|
51
|
+
const env = opts.env || process.env;
|
|
52
|
+
if (env.ORACLE_PORTFOLIO_HISTORY_FILE) {
|
|
53
|
+
return path.resolve(String(env.ORACLE_PORTFOLIO_HISTORY_FILE));
|
|
54
|
+
}
|
|
55
|
+
const hermesHome = String(env.HERMES_HOME || "").trim() || path.join(os.homedir(), ".hermes");
|
|
56
|
+
return path.join(path.resolve(hermesHome), "state", "oracle", "portfolio-history.jsonl");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function knownLiquidValue(balance) {
|
|
60
|
+
const value = finiteNumber(balance?.valuation?.knownUsd);
|
|
61
|
+
if (value == null) return null;
|
|
62
|
+
if (Number(balance?.valuation?.pricedItems || 0) > 0 || balance?.valuation?.complete === true) {
|
|
63
|
+
return roundedUsd(value);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function knownNftValue(nfts) {
|
|
69
|
+
if (!nfts || nfts.status === "unavailable" || nfts.status === "not-requested") return null;
|
|
70
|
+
const value = finiteNumber(nfts?.valuation?.estimatedCurrentValueUsd);
|
|
71
|
+
if (value == null) return null;
|
|
72
|
+
if (Number(nfts?.valuation?.valuedItems || 0) > 0 || nfts?.valuation?.complete === true) {
|
|
73
|
+
return roundedUsd(value);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function liquidBreakdown(balance) {
|
|
79
|
+
const rows = [];
|
|
80
|
+
for (const chain of balance?.chains || []) {
|
|
81
|
+
if (chain.family === "hyperliquid") {
|
|
82
|
+
const spot = (chain.spot?.balances || []).reduce((sum, item) => {
|
|
83
|
+
const value = finiteNumber(item.usdValue);
|
|
84
|
+
return value == null ? sum : sum + value;
|
|
85
|
+
}, 0);
|
|
86
|
+
const perps = finiteNumber(chain.perps?.accountValueUsd);
|
|
87
|
+
const knownUsd = roundedUsd(spot + (perps ?? 0));
|
|
88
|
+
if (knownUsd !== 0 || perps != null || (chain.spot?.balances || []).some((item) => finiteNumber(item.usdValue) != null)) {
|
|
89
|
+
rows.push({ family: "hyperliquid", chainId: null, name: chain.name || "Hyperliquid", knownUsd });
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = finiteNumber(chain.native?.usdValue);
|
|
94
|
+
if (value == null) continue;
|
|
95
|
+
rows.push({
|
|
96
|
+
family: chain.family || "evm",
|
|
97
|
+
chainId: chain.chainId ?? null,
|
|
98
|
+
name: chain.name || chain.native?.symbol || "Unknown",
|
|
99
|
+
knownUsd: roundedUsd(value),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function coverageSummary(balance, nfts, includeNfts) {
|
|
106
|
+
const nftRows = Array.isArray(nfts?.coverage) ? nfts.coverage : [];
|
|
107
|
+
return {
|
|
108
|
+
balances: balance?.coverage || null,
|
|
109
|
+
nfts: {
|
|
110
|
+
requested: includeNfts,
|
|
111
|
+
ok: nftRows.filter((row) => row.status === "ok").length,
|
|
112
|
+
partial: nftRows.filter((row) => row.status === "partial").length,
|
|
113
|
+
unavailable: nftRows.filter((row) => row.status === "unavailable").length,
|
|
114
|
+
notConfigured: nftRows.filter((row) => row.status === "not-configured").length,
|
|
115
|
+
status: !includeNfts
|
|
116
|
+
? "not-requested"
|
|
117
|
+
: nfts?.status === "unavailable"
|
|
118
|
+
? "unavailable"
|
|
119
|
+
: nfts?.valuation?.complete
|
|
120
|
+
? "ok"
|
|
121
|
+
: "partial",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function appendSnapshot(snapshot, opts) {
|
|
127
|
+
const file = resolvePortfolioHistoryFile(opts);
|
|
128
|
+
const line = `${JSON.stringify(snapshot)}\n`;
|
|
129
|
+
if (Buffer.byteLength(line) > 32 * 1024) throw new Error("portfolio history snapshot exceeds 32 KiB");
|
|
130
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
131
|
+
const flags = constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | (constants.O_NOFOLLOW || 0);
|
|
132
|
+
const handle = await open(file, flags, 0o600);
|
|
133
|
+
try {
|
|
134
|
+
await handle.writeFile(line, "utf8");
|
|
135
|
+
} finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
await chmod(file, 0o600);
|
|
139
|
+
return file;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function portfolioSnapshot(args = {}, opts = {}) {
|
|
143
|
+
const includeNfts = args.includeNfts !== false;
|
|
144
|
+
const balanceImpl = opts.portfolioBalanceImpl || portfolioBalance;
|
|
145
|
+
const nftImpl = opts.nftInventoryImpl || nftInventory;
|
|
146
|
+
const balancePromise = balanceImpl(args, opts);
|
|
147
|
+
const nftsPromise = includeNfts
|
|
148
|
+
? nftImpl({ ...args, includePnl: false }, opts).catch((error) => ({
|
|
149
|
+
status: "unavailable",
|
|
150
|
+
error: String(error?.message || error).slice(0, 300),
|
|
151
|
+
}))
|
|
152
|
+
: Promise.resolve({ status: "not-requested" });
|
|
153
|
+
const [balance, nfts] = await Promise.all([balancePromise, nftsPromise]);
|
|
154
|
+
const liquidKnownUsd = knownLiquidValue(balance);
|
|
155
|
+
const nftEstimatedValueUsd = knownNftValue(nfts);
|
|
156
|
+
const components = [liquidKnownUsd, nftEstimatedValueUsd].filter((value) => value != null);
|
|
157
|
+
const knownUsd = components.length ? roundedUsd(components.reduce((sum, value) => sum + value, 0)) : null;
|
|
158
|
+
const complete = Boolean(
|
|
159
|
+
balance?.valuation?.complete &&
|
|
160
|
+
(!includeNfts || nfts?.valuation?.complete === true),
|
|
161
|
+
);
|
|
162
|
+
const nowValue = typeof opts.now === "function" ? opts.now() : new Date();
|
|
163
|
+
const recordedAt = new Date(nowValue).toISOString();
|
|
164
|
+
const addresses = balance?.addresses || resolvePortfolioAddresses(args, opts.env || process.env);
|
|
165
|
+
const portfolioId = portfolioIdForAddresses(addresses);
|
|
166
|
+
const warnings = [...(balance?.warnings || [])];
|
|
167
|
+
if (includeNfts && nfts?.status === "unavailable") warnings.push(`NFT inventory unavailable: ${nfts.error}`);
|
|
168
|
+
if (includeNfts && nfts?.valuation && nfts.valuation.complete !== true) {
|
|
169
|
+
warnings.push("NFT estimated value is incomplete and is not an executable bid.");
|
|
170
|
+
}
|
|
171
|
+
const snapshot = {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
id: `snapshot_${randomUUID()}`,
|
|
174
|
+
portfolioId,
|
|
175
|
+
recordedAt,
|
|
176
|
+
sourceQueriedAt: {
|
|
177
|
+
balances: balance?.queriedAt || null,
|
|
178
|
+
nfts: nfts?.generatedAt || null,
|
|
179
|
+
},
|
|
180
|
+
configuredFamilies: Object.fromEntries(
|
|
181
|
+
["evm", "solana", "bitcoin", "hyperliquid"].map((family) => [family, Boolean(addresses?.[family])]),
|
|
182
|
+
),
|
|
183
|
+
valuation: {
|
|
184
|
+
knownUsd,
|
|
185
|
+
liquidKnownUsd,
|
|
186
|
+
nftEstimatedValueUsd,
|
|
187
|
+
complete,
|
|
188
|
+
label: complete
|
|
189
|
+
? "complete known portfolio value"
|
|
190
|
+
: "known priced value, including provider-estimated NFTs when available, not a complete portfolio total",
|
|
191
|
+
liquidPricedItems: Number(balance?.valuation?.pricedItems || 0),
|
|
192
|
+
liquidUnpricedNonzeroItems: Number(balance?.valuation?.unpricedNonzeroItems || 0),
|
|
193
|
+
nftValuedItems: Number(nfts?.valuation?.valuedItems || 0),
|
|
194
|
+
nftUnvaluedItems: Number(nfts?.valuation?.unvaluedItems || 0),
|
|
195
|
+
},
|
|
196
|
+
breakdown: {
|
|
197
|
+
liquid: liquidBreakdown(balance),
|
|
198
|
+
nfts: {
|
|
199
|
+
knownUsd: nftEstimatedValueUsd,
|
|
200
|
+
visibleItems: Number(nfts?.inventory?.visibleCount || 0),
|
|
201
|
+
flaggedItems: Number(nfts?.inventory?.flaggedCount || 0),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
coverage: coverageSummary(balance, nfts, includeNfts),
|
|
205
|
+
warnings: [...new Set(warnings)].slice(0, 50),
|
|
206
|
+
};
|
|
207
|
+
const historyFile = await appendSnapshot(snapshot, opts);
|
|
208
|
+
return {
|
|
209
|
+
provider: "portfolio",
|
|
210
|
+
operation: "snapshot",
|
|
211
|
+
readOnly: true,
|
|
212
|
+
localObservationRecorded: true,
|
|
213
|
+
historyFile,
|
|
214
|
+
snapshot,
|
|
215
|
+
balance,
|
|
216
|
+
nfts,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readSnapshots(opts) {
|
|
221
|
+
const file = resolvePortfolioHistoryFile(opts);
|
|
222
|
+
try {
|
|
223
|
+
const data = await readFile(file);
|
|
224
|
+
if (data.byteLength > MAX_HISTORY_BYTES) {
|
|
225
|
+
throw new Error(`portfolio history exceeds ${MAX_HISTORY_BYTES} bytes`);
|
|
226
|
+
}
|
|
227
|
+
const snapshots = [];
|
|
228
|
+
let corruptLines = 0;
|
|
229
|
+
for (const line of data.toString("utf8").split("\n")) {
|
|
230
|
+
if (!line.trim()) continue;
|
|
231
|
+
try {
|
|
232
|
+
const row = JSON.parse(line);
|
|
233
|
+
if (row?.schemaVersion === 1 && row?.portfolioId && row?.recordedAt) snapshots.push(row);
|
|
234
|
+
else corruptLines += 1;
|
|
235
|
+
} catch {
|
|
236
|
+
corruptLines += 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { file, snapshots, corruptLines };
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === "ENOENT") return { file, snapshots: [], corruptLines: 0 };
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function requestedPortfolioId(args, opts) {
|
|
247
|
+
if (args.allPortfolios === true) return null;
|
|
248
|
+
if (args.portfolioId) return String(args.portfolioId);
|
|
249
|
+
const addresses = resolvePortfolioAddresses(args, opts.env || process.env);
|
|
250
|
+
return Object.values(addresses).some(Boolean) ? portfolioIdForAddresses(addresses) : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function portfolioHistory(args = {}, opts = {}) {
|
|
254
|
+
const limit = boundedInteger(args.limit, 100, 1, MAX_HISTORY_LIMIT, "portfolio history limit");
|
|
255
|
+
const order = String(args.order || "desc").toLowerCase();
|
|
256
|
+
if (!new Set(["asc", "desc"]).has(order)) throw new Error("portfolio history order must be asc or desc");
|
|
257
|
+
const since = isoDate(args.since, "portfolio history since");
|
|
258
|
+
const until = isoDate(args.until, "portfolio history until");
|
|
259
|
+
const portfolioId = requestedPortfolioId(args, opts);
|
|
260
|
+
const stored = await readSnapshots(opts);
|
|
261
|
+
let matched = stored.snapshots.filter((row) => {
|
|
262
|
+
if (portfolioId && row.portfolioId !== portfolioId) return false;
|
|
263
|
+
if (since && row.recordedAt < since) return false;
|
|
264
|
+
if (until && row.recordedAt > until) return false;
|
|
265
|
+
return true;
|
|
266
|
+
});
|
|
267
|
+
matched.sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt)));
|
|
268
|
+
const totalMatching = matched.length;
|
|
269
|
+
matched = matched.slice(-limit);
|
|
270
|
+
if (order === "desc") matched.reverse();
|
|
271
|
+
const valued = matched.filter((row) => finiteNumber(row?.valuation?.knownUsd) != null);
|
|
272
|
+
return {
|
|
273
|
+
provider: "portfolio",
|
|
274
|
+
operation: "history",
|
|
275
|
+
readOnly: true,
|
|
276
|
+
historyFile: stored.file,
|
|
277
|
+
portfolioId,
|
|
278
|
+
order,
|
|
279
|
+
totalStored: stored.snapshots.length,
|
|
280
|
+
totalMatching,
|
|
281
|
+
corruptLines: stored.corruptLines,
|
|
282
|
+
snapshots: matched,
|
|
283
|
+
stats: {
|
|
284
|
+
returned: matched.length,
|
|
285
|
+
valuedSnapshots: valued.length,
|
|
286
|
+
unpricedSnapshots: matched.length - valued.length,
|
|
287
|
+
firstRecordedAt: matched.length ? [...matched].sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt)))[0].recordedAt : null,
|
|
288
|
+
lastRecordedAt: matched.length ? [...matched].sort((a, b) => String(a.recordedAt).localeCompare(String(b.recordedAt))).at(-1).recordedAt : null,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function money(value) {
|
|
294
|
+
return `$${Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function xml(value) {
|
|
298
|
+
return String(value)
|
|
299
|
+
.replaceAll("&", "&")
|
|
300
|
+
.replaceAll("<", "<")
|
|
301
|
+
.replaceAll(">", ">")
|
|
302
|
+
.replaceAll('"', """)
|
|
303
|
+
.replaceAll("'", "'");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sampled(points, maxPoints) {
|
|
307
|
+
if (points.length <= maxPoints) return points;
|
|
308
|
+
const output = [];
|
|
309
|
+
for (let i = 0; i < maxPoints; i += 1) {
|
|
310
|
+
output.push(points[Math.round((i * (points.length - 1)) / (maxPoints - 1))]);
|
|
311
|
+
}
|
|
312
|
+
return output;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function graphSvg(points, portfolioId) {
|
|
316
|
+
const width = 1200;
|
|
317
|
+
const height = 675;
|
|
318
|
+
const left = 100;
|
|
319
|
+
const right = 1140;
|
|
320
|
+
const top = 100;
|
|
321
|
+
const bottom = 570;
|
|
322
|
+
const values = points.map((point) => point.value);
|
|
323
|
+
let min = Math.min(...values);
|
|
324
|
+
let max = Math.max(...values);
|
|
325
|
+
if (min === max) {
|
|
326
|
+
const padding = Math.max(1, Math.abs(min) * 0.05);
|
|
327
|
+
min -= padding;
|
|
328
|
+
max += padding;
|
|
329
|
+
} else {
|
|
330
|
+
const padding = (max - min) * 0.1;
|
|
331
|
+
min = Math.max(0, min - padding);
|
|
332
|
+
max += padding;
|
|
333
|
+
}
|
|
334
|
+
const x = (index) => points.length === 1 ? (left + right) / 2 : left + ((right - left) * index) / (points.length - 1);
|
|
335
|
+
const y = (value) => bottom - ((value - min) / (max - min)) * (bottom - top);
|
|
336
|
+
const pathData = points.map((point, index) => `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`).join(" ");
|
|
337
|
+
const grids = Array.from({ length: 5 }, (_, index) => {
|
|
338
|
+
const ratio = index / 4;
|
|
339
|
+
const yy = top + ratio * (bottom - top);
|
|
340
|
+
const value = max - ratio * (max - min);
|
|
341
|
+
return `<line x1="${left}" y1="${yy}" x2="${right}" y2="${yy}" stroke="#26313c" stroke-width="1"/><text x="${left - 14}" y="${yy + 5}" text-anchor="end" fill="#8795a5" font-size="15">${money(value)}</text>`;
|
|
342
|
+
}).join("");
|
|
343
|
+
const circles = points.map((point, index) => `<circle cx="${x(index)}" cy="${y(point.value)}" r="5" fill="#b8f0ff"><title>${xml(point.recordedAt)}: ${money(point.value)}</title></circle>`).join("");
|
|
344
|
+
const firstDate = xml(points[0].recordedAt.slice(0, 10));
|
|
345
|
+
const lastDate = xml(points.at(-1).recordedAt.slice(0, 10));
|
|
346
|
+
const portfolioLabel = xml(portfolioId || "configured portfolio");
|
|
347
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="1200" height="675" fill="#0b1016"/><text x="60" y="50" fill="#f4f7fa" font-size="28" font-family="Inter,Arial,sans-serif" font-weight="700">Oracle Portfolio Value</text><text x="60" y="78" fill="#8795a5" font-size="15" font-family="Inter,Arial,sans-serif">Known priced value, not a complete total unless every snapshot says complete · ${portfolioLabel}</text>${grids}<line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}" stroke="#526171" stroke-width="1"/><path d="${pathData}" fill="none" stroke="#b8f0ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>${circles}<text x="${left}" y="610" fill="#8795a5" font-size="15">${firstDate}</text><text x="${right}" y="610" text-anchor="end" fill="#8795a5" font-size="15">${lastDate}</text><text x="60" y="650" fill="#526171" font-size="13">NFT values are provider estimates, not executable bids. Unavailable values are omitted, never plotted as zero.</text></svg>`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function portfolioValueGraph(args = {}, opts = {}) {
|
|
351
|
+
if (args.allPortfolios === true) {
|
|
352
|
+
throw new Error("portfolio value graph requires one portfolioId or configured public-address portfolio");
|
|
353
|
+
}
|
|
354
|
+
const maxPoints = boundedInteger(args.maxPoints, 200, 2, 500, "portfolio graph maxPoints");
|
|
355
|
+
const history = await portfolioHistory({ ...args, order: "asc", limit: args.limit ?? MAX_HISTORY_LIMIT }, opts);
|
|
356
|
+
if (!history.portfolioId) {
|
|
357
|
+
throw new Error("portfolio value graph requires one portfolioId or configured public-address portfolio");
|
|
358
|
+
}
|
|
359
|
+
const known = history.snapshots
|
|
360
|
+
.map((snapshot) => ({
|
|
361
|
+
recordedAt: snapshot.recordedAt,
|
|
362
|
+
value: finiteNumber(snapshot?.valuation?.knownUsd),
|
|
363
|
+
complete: snapshot?.valuation?.complete === true,
|
|
364
|
+
}))
|
|
365
|
+
.filter((point) => point.value != null);
|
|
366
|
+
if (!known.length) throw new Error("portfolio value graph requires at least one snapshot with known priced value");
|
|
367
|
+
const points = sampled(known, maxPoints);
|
|
368
|
+
const start = points[0].value;
|
|
369
|
+
const end = points.at(-1).value;
|
|
370
|
+
const changeUsd = roundedUsd(end - start);
|
|
371
|
+
const changePct = start === 0 ? null : Number((((end - start) / start) * 100).toFixed(2));
|
|
372
|
+
const svg = graphSvg(points, history.portfolioId);
|
|
373
|
+
return {
|
|
374
|
+
provider: "portfolio",
|
|
375
|
+
operation: "valueGraph",
|
|
376
|
+
readOnly: true,
|
|
377
|
+
portfolioId: history.portfolioId,
|
|
378
|
+
mimeType: "image/svg+xml",
|
|
379
|
+
dataBase64: Buffer.from(svg).toString("base64"),
|
|
380
|
+
byteLength: Buffer.byteLength(svg),
|
|
381
|
+
summary: {
|
|
382
|
+
points: points.length,
|
|
383
|
+
omittedUnavailablePoints: history.snapshots.length - known.length,
|
|
384
|
+
startRecordedAt: points[0].recordedAt,
|
|
385
|
+
endRecordedAt: points.at(-1).recordedAt,
|
|
386
|
+
startKnownUsd: start,
|
|
387
|
+
endKnownUsd: end,
|
|
388
|
+
changeUsd,
|
|
389
|
+
changePct,
|
|
390
|
+
completeSnapshots: points.filter((point) => point.complete).length,
|
|
391
|
+
label: "known priced value history; incomplete snapshots are not complete portfolio totals",
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|