@oracle-agent/oracle 0.3.2 → 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.
@@ -40,6 +40,9 @@ import * as balancer from "./providers/balancer.mjs";
40
40
  import * as pendle from "./providers/pendle.mjs";
41
41
  import * as odos from "./providers/odos.mjs";
42
42
  import * as blockscout from "./providers/blockscout.mjs";
43
+ import * as portfolio from "./providers/portfolio.mjs";
44
+ import * as portfolioHistory from "./providers/portfolio-history.mjs";
45
+ import * as nftPortfolio from "./providers/nft-portfolio.mjs";
43
46
  import * as paraswap from "./providers/paraswap.mjs";
44
47
 
45
48
  const OPS = {
@@ -90,6 +93,20 @@ const OPS = {
90
93
  erc20Balance: (o, a = {}) => rpc.erc20BalanceOf(a, o),
91
94
  transactionReceipt: (o, a = {}) => rpc.transactionReceipt(a, o),
92
95
  },
96
+ portfolio: {
97
+ health: (o) => portfolio.portfolioHealth(o),
98
+ balances: (o, a = {}) => portfolio.portfolioBalance(a, o),
99
+ snapshot: (o, a = {}) => portfolioHistory.portfolioSnapshot(a, o),
100
+ history: (o, a = {}) => portfolioHistory.portfolioHistory(a, o),
101
+ valueGraph: (o, a = {}) => portfolioHistory.portfolioValueGraph(a, o),
102
+ },
103
+ "nft-portfolio": {
104
+ health: (o) => nftPortfolio.nftHealth(o),
105
+ inventory: (o, a = {}) => nftPortfolio.nftInventory(a, o),
106
+ gallery: (o, a = {}) => nftPortfolio.nftPortfolioGallery(a, o),
107
+ pnl: (o, a = {}) => nftPortfolio.nftPnl(a, o),
108
+ prepareList: (o, a = {}) => nftPortfolio.nftPrepareList(a, o),
109
+ },
93
110
  "solana-rpc": {
94
111
  health: (o) => solana.solanaHealth(o),
95
112
  latestBlockhash: (o, a = {}) => solana.solanaLatestBlockhash(a, o),
@@ -213,6 +230,9 @@ const OPS = {
213
230
  health: (o) => osnft.openseaHealth(o),
214
231
  collection: (o, a = {}) => osnft.openseaCollection(a.slug, o),
215
232
  floor: (o, a = {}) => osnft.openseaFloor(a.slug, o),
233
+ accountNfts: (o, a = {}) => osnft.openseaAccountNfts(a, o),
234
+ accountPnl: (o, a = {}) => osnft.openseaAccountPnl(a, o),
235
+ prepareList: (o, a = {}) => osnft.openseaPrepareList(a, o),
216
236
  },
217
237
 
218
238
  "hl-outcome": {
@@ -570,6 +590,20 @@ export const data = {
570
590
  call: (chainId, method, params, o) =>
571
591
  dataCall("evm-rpc", "call", { chainId, method, params }, o),
572
592
  },
593
+ portfolio: {
594
+ balance: (a, o) => dataCall("portfolio", "balances", a || {}, o),
595
+ snapshot: (a, o) => dataCall("portfolio", "snapshot", a || {}, o),
596
+ history: (a, o) => dataCall("portfolio", "history", a || {}, o),
597
+ valueGraph: (a, o) => dataCall("portfolio", "valueGraph", a || {}, o),
598
+ },
599
+ nft: {
600
+ inventory: (a, o) => dataCall("nft-portfolio", "inventory", a || {}, o),
601
+ gallery: (a, o) => dataCall("nft-portfolio", "gallery", a || {}, o),
602
+ pnl: (a, o) => dataCall("nft-portfolio", "pnl", a || {}, o),
603
+ prepareList: (a, o) => dataCall("nft-portfolio", "prepareList", a || {}, o),
604
+ floor: (slug, o) => dataCall("opensea-nft", "floor", { slug }, o),
605
+ collection: (slug, o) => dataCall("opensea-nft", "collection", { slug }, o),
606
+ },
573
607
  solana: {
574
608
  health: (o) => dataCall("solana-rpc", "health", {}, o),
575
609
  latestBlockhash: (a, o) => dataCall("solana-rpc", "latestBlockhash", a || {}, o),
@@ -633,10 +667,6 @@ export const data = {
633
667
  cow: {
634
668
  quote: (a, o) => dataCall("cowswap", "quote", a || {}, o),
635
669
  },
636
- nft: {
637
- floor: (slug, o) => dataCall("opensea-nft", "floor", { slug }, o),
638
- collection: (slug, o) => dataCall("opensea-nft", "collection", { slug }, o),
639
- },
640
670
  hlWs: {
641
671
  allMids: (o) => dataCall("hl-ws", "allMids", {}, o),
642
672
  l2Book: (coin, o) => dataCall("hl-ws", "l2Book", { coin }, o),
@@ -89,6 +89,18 @@ function lamports(sol) {
89
89
  return Math.round(positiveNumber(sol, "priceSol") * LAMPORTS_PER_SOL);
90
90
  }
91
91
 
92
+ function listingExpiry(value) {
93
+ if (value == null || value === "") return null;
94
+ const expiry = Number(value);
95
+ if (!Number.isSafeInteger(expiry) || expiry < 0) {
96
+ throw new Error("magiceden: expiry must be a whole Unix timestamp in seconds or 0");
97
+ }
98
+ if (expiry !== 0 && expiry <= Math.floor(Date.now() / 1000)) {
99
+ throw new Error("magiceden: expiry must be in the future or 0 for no expiry");
100
+ }
101
+ return expiry;
102
+ }
103
+
92
104
  export async function magicEdenSolHealth(opts = {}) {
93
105
  try {
94
106
  const stats = await magicEdenSolStats({ symbol: "mad_lads" }, opts);
@@ -256,20 +268,26 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
256
268
  const tokenATA = solanaPubkey(args.tokenATA || args.tokenAddress, "tokenATA");
257
269
  const auctionHouse = solanaPubkey(args.auctionHouse, "auctionHouse");
258
270
  const priceSol = positiveNumber(args.priceSol ?? args.price, "priceSol");
271
+ const expiry = listingExpiry(args.expiry);
259
272
  const url = new URL(`${base(opts)}/instructions/sell`);
260
273
  url.searchParams.set("seller", seller);
261
274
  url.searchParams.set("auctionHouseAddress", auctionHouse);
262
275
  url.searchParams.set("tokenMint", tokenMint);
263
276
  url.searchParams.set("tokenAccount", tokenATA);
264
277
  url.searchParams.set("price", String(priceSol));
278
+ if (args.sellerReferral) url.searchParams.set("sellerReferral", solanaPubkey(args.sellerReferral, "sellerReferral"));
279
+ if (expiry != null) url.searchParams.set("expiry", String(expiry));
265
280
  const raw = await httpJson(url.toString(), {
266
281
  headers: headers(opts),
267
282
  fetchImpl: opts.fetchImpl,
268
283
  timeoutMs: opts.timeoutMs ?? 15_000,
269
284
  });
270
285
  const data = raw?.v0?.tx?.data ?? raw?.tx?.data ?? null;
271
- const transaction = Array.isArray(data) ? Buffer.from(data).toString("base64") : null;
272
- if (!transaction) throw new Error("magiceden: sell returned no transaction payload");
286
+ if (!Array.isArray(data)) {
287
+ if (raw?.txSigned) throw new Error("magiceden: refused a pre-signed transaction payload from the API");
288
+ throw new Error("magiceden: sell returned no unsigned transaction payload");
289
+ }
290
+ const transaction = Buffer.from(data).toString("base64");
273
291
  return stampPrepared({
274
292
  provider: "magiceden-sol",
275
293
  chain: "solana-mainnet-beta",
@@ -282,6 +300,7 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
282
300
  seller,
283
301
  tokenMint,
284
302
  priceSol,
303
+ expiry,
285
304
  transaction,
286
305
  transactionEncoding: "base64",
287
306
  raw,
@@ -0,0 +1,163 @@
1
+ // Raster-only NFT contact sheets returned as SVG image content.
2
+
3
+ import { timed } from "../http.mjs";
4
+
5
+ const ALLOWED_IMAGE_HOSTS = Object.freeze([
6
+ "i.seadn.io",
7
+ "openseauserdata.com",
8
+ "arweave.net",
9
+ "ipfs.io",
10
+ "nftstorage.link",
11
+ "dweb.link",
12
+ "cloudflare-ipfs.com",
13
+ "ordinals.com",
14
+ "ordinals.g.com",
15
+ "cdn.helius-rpc.com",
16
+ "shdw-drive.genesysgo.net",
17
+ ]);
18
+
19
+ const RASTER_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
20
+
21
+ function allowedHost(hostname) {
22
+ const host = String(hostname || "").toLowerCase();
23
+ return ALLOWED_IMAGE_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
24
+ }
25
+
26
+ function gatewayUrl(value) {
27
+ const text = String(value || "").trim();
28
+ if (text.startsWith("ipfs://")) return `https://ipfs.io/ipfs/${text.slice(7).replace(/^ipfs\//, "")}`;
29
+ if (text.startsWith("ar://")) return `https://arweave.net/${text.slice(5)}`;
30
+ return text;
31
+ }
32
+
33
+ function safeImageUrl(value) {
34
+ const text = gatewayUrl(value);
35
+ let url;
36
+ try {
37
+ url = new URL(text);
38
+ } catch {
39
+ return null;
40
+ }
41
+ if (url.protocol !== "https:" || url.username || url.password || !allowedHost(url.hostname)) return null;
42
+ return url;
43
+ }
44
+
45
+ async function fetchRaster(value, opts = {}) {
46
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
47
+ if (typeof fetchImpl !== "function") throw new Error("gallery fetch unavailable");
48
+ let url = safeImageUrl(value);
49
+ if (!url) throw new Error("image host is not on the raster gallery allowlist");
50
+ const maxBytes = Math.min(Math.max(Number(opts.maxImageBytes ?? 1_500_000), 50_000), 4_000_000);
51
+
52
+ for (let redirect = 0; redirect <= 3; redirect += 1) {
53
+ const attempt = await timed(() => fetchImpl(url.toString(), {
54
+ method: "GET",
55
+ redirect: "manual",
56
+ headers: { accept: "image/jpeg,image/png,image/webp" },
57
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 12_000),
58
+ }));
59
+ if (!attempt.ok) throw new Error(attempt.error);
60
+ const response = attempt.data;
61
+ if (response.status >= 300 && response.status < 400) {
62
+ const location = response.headers?.get?.("location");
63
+ if (!location) throw new Error("image redirect missing location");
64
+ const next = safeImageUrl(new URL(location, url).toString());
65
+ if (!next) throw new Error("image redirect left the host allowlist");
66
+ url = next;
67
+ continue;
68
+ }
69
+ if (!response.ok) throw new Error(`image HTTP ${response.status}`);
70
+ const mimeType = String(response.headers?.get?.("content-type") || "").split(";", 1)[0].toLowerCase();
71
+ if (!RASTER_MIME.has(mimeType)) throw new Error(`unsafe or unsupported image type ${mimeType || "unknown"}`);
72
+ const length = Number(response.headers?.get?.("content-length") || 0);
73
+ if (length > maxBytes) throw new Error("image exceeds gallery byte cap");
74
+ const bytes = Buffer.from(await response.arrayBuffer());
75
+ if (!bytes.length || bytes.length > maxBytes) throw new Error("image exceeds gallery byte cap");
76
+ return { mimeType, data: bytes.toString("base64"), sourceUrl: url.toString(), bytes: bytes.length };
77
+ }
78
+ throw new Error("too many image redirects");
79
+ }
80
+
81
+ function xml(value) {
82
+ return String(value ?? "")
83
+ .replaceAll("&", "&amp;")
84
+ .replaceAll("<", "&lt;")
85
+ .replaceAll(">", "&gt;")
86
+ .replaceAll('"', "&quot;")
87
+ .replaceAll("'", "&apos;");
88
+ }
89
+
90
+ function label(item, index) {
91
+ return item.name || item.collection || item.tokenId || `NFT ${index + 1}`;
92
+ }
93
+
94
+ export async function nftGallery(args = {}, opts = {}) {
95
+ const allItems = Array.isArray(args.items) ? args.items : [];
96
+ if (!allItems.length) throw new Error("nft gallery requires inventory items");
97
+ const pageSize = Math.min(Math.max(Number(args.pageSize ?? 12) || 12, 1), 12);
98
+ const page = Math.max(Number(args.page ?? 1) || 1, 1);
99
+ const totalPages = Math.max(Math.ceil(allItems.length / pageSize), 1);
100
+ if (page > totalPages) throw new Error(`nft gallery page ${page} exceeds ${totalPages}`);
101
+ const items = allItems.slice((page - 1) * pageSize, page * pageSize);
102
+ const loaded = [];
103
+ let totalBytes = 0;
104
+ const maxTotalBytes = Math.min(Math.max(Number(args.maxTotalBytes ?? 8_000_000), 250_000), 12_000_000);
105
+
106
+ for (const item of items) {
107
+ if (totalBytes >= maxTotalBytes) {
108
+ loaded.push({ item, image: null, error: "gallery page byte cap reached" });
109
+ continue;
110
+ }
111
+ try {
112
+ const image = await fetchRaster(item.imageUrl || item.originalImageUrl, {
113
+ ...opts,
114
+ maxImageBytes: Math.min(Number(args.maxImageBytes ?? 1_500_000), maxTotalBytes - totalBytes),
115
+ });
116
+ totalBytes += image.bytes;
117
+ loaded.push({ item, image, error: null });
118
+ } catch (error) {
119
+ loaded.push({ item, image: null, error: String(error?.message || error).slice(0, 180) });
120
+ }
121
+ }
122
+
123
+ const columns = 3;
124
+ const tileWidth = 340;
125
+ const tileHeight = 400;
126
+ const headerHeight = 76;
127
+ const rows = Math.ceil(items.length / columns);
128
+ const width = columns * tileWidth;
129
+ const height = headerHeight + rows * tileHeight;
130
+ const body = loaded.map(({ item, image, error }, index) => {
131
+ const column = index % columns;
132
+ const row = Math.floor(index / columns);
133
+ const x = column * tileWidth;
134
+ const y = headerHeight + row * tileHeight;
135
+ const art = image
136
+ ? `<image x="${x + 14}" y="${y + 14}" width="312" height="312" preserveAspectRatio="xMidYMid slice" href="data:${image.mimeType};base64,${image.data}"/>`
137
+ : `<rect x="${x + 14}" y="${y + 14}" width="312" height="312" rx="14" fill="#151820"/><text x="${x + 170}" y="${y + 165}" text-anchor="middle" fill="#76808f" font-size="16">image unavailable</text>`;
138
+ const title = xml(label(item, index).slice(0, 34));
139
+ const chain = xml(item.chain || "unknown chain");
140
+ return `${art}<text x="${x + 16}" y="${y + 350}" fill="#f3f5f7" font-size="17" font-weight="600">${title}</text><text x="${x + 16}" y="${y + 376}" fill="#8d98a8" font-size="13">${chain}${error ? " · placeholder" : ""}</text>`;
141
+ }).join("");
142
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#090b10"/><text x="24" y="34" fill="#ffffff" font-size="24" font-weight="700">NFT INVENTORY</text><text x="24" y="58" fill="#8d98a8" font-size="14">page ${page} of ${totalPages} · ${allItems.length} assets · generated ${xml(new Date().toISOString())}</text>${body}</svg>`;
143
+
144
+ return {
145
+ provider: "nft-portfolio",
146
+ operation: "gallery",
147
+ mimeType: "image/svg+xml",
148
+ dataBase64: Buffer.from(svg).toString("base64"),
149
+ page,
150
+ pageSize,
151
+ totalPages,
152
+ totalItems: allItems.length,
153
+ renderedItems: loaded.filter((row) => row.image).length,
154
+ placeholders: loaded.filter((row) => !row.image).map((row) => ({
155
+ assetKey: row.item.assetKey || null,
156
+ name: row.item.name || null,
157
+ sourceUrl: row.item.imageUrl || null,
158
+ error: row.error,
159
+ })),
160
+ rasterOnly: true,
161
+ externalScripts: false,
162
+ };
163
+ }