@oracle-agent/oracle 0.3.3 → 0.3.5

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.
@@ -1,49 +1,11 @@
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";
5
4
  import { httpJson } from "../http.mjs";
6
5
  import { resolveProviderEndpoint, credentialedHeaders } from "../provider-endpoint.mjs";
7
- import { stampPrepared } from "../../prepare-envelope.mjs";
8
6
 
9
7
  export const OPENSEA_API = "https://api.opensea.io/api/v2";
10
8
 
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
-
47
9
  function loadKeyFromDisk() {
48
10
  const p = process.env.OPENSEA_ENV_FILE || `${process.env.HOME || ""}/.config/locals-only/opensea.env`;
49
11
  if (!existsSync(p)) return "";
@@ -80,81 +42,6 @@ function headers(opts = {}) {
80
42
  return credentialedHeaders({ accept: "application/json" }, { "x-api-key": apiKey(opts) }, endpoint(opts).trusted);
81
43
  }
82
44
 
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
-
158
45
  /** Shared request context for the multichain scanner. */
159
46
  export function openseaContext(opts = {}) {
160
47
  return {
@@ -210,162 +97,3 @@ export async function openseaFloor(slug, opts = {}) {
210
97
  },
211
98
  };
212
99
  }
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
- }
@@ -330,7 +330,6 @@ export async function satflowPrepareList(args = {}, opts = {}) {
330
330
  kind: "list-intent",
331
331
  requiresUserSignature: true,
332
332
  marketplace: "satflow",
333
- listingRequest: body,
334
333
  intent: data,
335
334
  unsignedPsbt: data?.unsignedListingPSBTBase64 || data?.unsigned_psbt || null,
336
335
  note: "Sign listing PSBTs in a Bitcoin wallet; listing broadcast is outside the data plane.",
@@ -29,6 +29,22 @@ export const UNI_V3_CHAINS = {
29
29
  weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
30
30
  usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
31
31
  },
32
+ // Robinhood Chain. Addresses mirror rhbot server/swap.js SWAP_CONFIG (the vetted
33
+ // source) and were re-proved on the live chain before being added here:
34
+ // eth_chainId == 4663, eth_getCode non-empty for every address below, and a real
35
+ // quoteExactInputSingle returned 1 WETH -> 1866.58 USDG at fee 100 (2026-07-31).
36
+ //
37
+ // Why this entry matters: LI.FI / ParaSwap / CoW do not index 4663, so the router's
38
+ // aggregator-only candidate list returned "no usable route" for every RH swap. The
39
+ // on-chain QuoterV2 is the only source that can price this chain. `usdc` is USDG
40
+ // (Global Dollar, 6-dec) — the stable quote RH pools actually use.
41
+ 4663: {
42
+ name: "robinhood",
43
+ quoter: "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7",
44
+ router: "0xcaf681a66d020601342297493863e78c959e5cb2",
45
+ weth: "0x0bd7d308f8e1639fab988df18a8011f41eacad73",
46
+ usdc: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
47
+ },
32
48
  };
33
49
 
34
50
  export const UNI_V3_FEE_TIERS = [100, 500, 3000, 10000];
@@ -274,7 +290,7 @@ export async function uniV3PrepareExactIn(q = {}, opts = {}) {
274
290
  value: nativeInput ? quote.amountIn : "0",
275
291
  slippageGuard: bindAutoSlippageGuardToCall(quote.autoSlippage, { chainId, venue: meta.router, data }),
276
292
  },
277
- });
293
+ }, { provider: "uniswap-v3", kind: "univ3-swap-tx" });
278
294
  }
279
295
 
280
296
  export async function uniV3Health(opts = {}) {
@@ -294,11 +294,6 @@ function assertLedgerDurable(phase) {
294
294
  * @param {"sign"|"broadcast"} phase
295
295
  */
296
296
  export function enforceTxPolicy(tx = {}, phase = "broadcast") {
297
- const normalizedPhase = String(phase ?? "").trim().toLowerCase();
298
- if (normalizedPhase !== "sign" && normalizedPhase !== "broadcast") {
299
- throw new Error(`policy: unknown phase ${JSON.stringify(phase)}, expected "sign" or "broadcast"`);
300
- }
301
- phase = normalizedPhase;
302
297
  assertLedgerDurable(phase);
303
298
  const chainId = Number(tx.chainId);
304
299
  if (!allowedChains().has(chainId)) {
@@ -158,7 +158,6 @@ export function assertGmxOrderAttestation(attestation, tx = {}, { chainId, nowMs
158
158
 
159
159
  export function assertGmxApprovalAttestation(attestation, tx = {}, guard = {}, { chainId, nowMs = Date.now(), secret } = {}) {
160
160
  if (!attestation || attestation.mode !== "gmx-order-attestation") throw new Error("gmx order attestation required");
161
- assertFreshWindow(attestation, nowMs, "gmx approval attestation");
162
161
  if (Number(attestation.expiresAtMs) <= Number(nowMs)) throw new Error("gmx attestation expired");
163
162
  assertSignature(attestation, secret);
164
163
  const txChainId = Number(tx.chainId ?? chainId);
@@ -45,6 +45,29 @@ export const ARTIFACT = Object.freeze({
45
45
  const DEFAULT_DRIFT_TOLERANCE_BPS = 100;
46
46
 
47
47
  const PREPARERS = {
48
+ // On-chain Uniswap V3. Unlike the aggregators below, this builds calldata from the
49
+ // chain's own router rather than an API's transaction payload — which is what makes
50
+ // chains no aggregator indexes (e.g. Robinhood 4663) preparable at all.
51
+ //
52
+ // The provider stamps requiresUserSignature:true / signingReady:false /
53
+ // broadcastReady:false. Preparing is NOT permission to execute: the wallet is still
54
+ // the only thing that can authorize this, and the unattended daemon surface list
55
+ // (hl, poly) is deliberately unchanged by adding this.
56
+ "uniswap-v3": async ({ chainId, tokenIn, tokenOut, amountIn, taker }, opts) => {
57
+ const { uniV3PrepareExactIn } = await import("../data/providers/uniswap-v3.mjs");
58
+ const p = await uniV3PrepareExactIn(
59
+ { chainId, tokenIn, tokenOut, amountIn, recipient: taker },
60
+ opts,
61
+ );
62
+ return {
63
+ artifactKind: ARTIFACT.TRANSACTION,
64
+ transaction: p.transaction,
65
+ requiresApproval: p.requiresApproval ?? null,
66
+ amountOut: p.quote?.amountOut ?? null,
67
+ minOut: p.quote?.amountOutMinimum ?? null,
68
+ };
69
+ },
70
+
48
71
  lifi: async ({ chainId, tokenIn, tokenOut, amountIn, taker }, opts) => {
49
72
  const { lifiPrepare } = await import("../data/providers/lifi.mjs");
50
73
  const p = await lifiPrepare(
@@ -20,6 +20,7 @@ import { paraswapPrice } from "../data/providers/paraswap.mjs";
20
20
  import { zeroxQuote } from "../data/providers/zerox.mjs";
21
21
  import { oneinchQuote } from "../data/providers/oneinch.mjs";
22
22
  import { cowQuote } from "../data/providers/cowswap.mjs";
23
+ import { uniV3QuoteExactIn, UNI_V3_CHAINS } from "../data/providers/uniswap-v3.mjs";
23
24
  import { llamaPrices } from "../data/providers/defillama.mjs";
24
25
  import { NATIVE_PRICE_KEY } from "./best-execution.mjs";
25
26
 
@@ -188,6 +189,42 @@ export function swapCandidates({
188
189
  },
189
190
  });
190
191
 
192
+ // On-chain Uniswap V3 QuoterV2. UNLIKE every other source in this list, this is
193
+ // not a third-party API — it is an eth_call against the chain itself.
194
+ //
195
+ // Why it exists here: aggregators only index chains they chose to support. On
196
+ // Robinhood Chain (4663) LI.FI/ParaSwap/CoW all return nothing, so the candidate
197
+ // list came back empty and the router reported "no usable route" — which reads
198
+ // like a permission wall but is really zero coverage. A chain with a live V3
199
+ // deployment can always be priced directly, with no API key and no indexer.
200
+ //
201
+ // Kept LAST so it acts as a floor: when aggregators do cover a chain they usually
202
+ // win on route quality, and best-execution still compares net output. This only
203
+ // decides the trade when it is the sole source that answered.
204
+ //
205
+ // Read-only. quoteExactInputSingle is a view call; this cannot sign or broadcast.
206
+ if (UNI_V3_CHAINS[Number(chainId)]) {
207
+ c.push({
208
+ source: "uniswap-v3",
209
+ run: async () => {
210
+ const q = await uniV3QuoteExactIn(
211
+ { chainId, tokenIn, tokenOut, amountIn },
212
+ opts,
213
+ );
214
+ // Gas is deliberately null, not 0: the quoter returns a gasEstimate in UNITS,
215
+ // and without a gas price that is not a USD cost. Reporting 0 would make this
216
+ // source look free and beat correctly-priced aggregators. See CoW above for
217
+ // the one case where 0 is a measured fact rather than a missing number.
218
+ return {
219
+ amountOut: q?.amountOut,
220
+ minOut: q?.minOut ?? q?.amountOutMinimum ?? null,
221
+ gasUsd: null,
222
+ meta: { onchain: true, fee: q?.fee, gasUnits: q?.gasEstimate, quoter: q?.quoter },
223
+ };
224
+ },
225
+ });
226
+ }
227
+
191
228
  return c;
192
229
  }
193
230
 
@@ -215,7 +215,54 @@ export const CHAIN_CONFIGS = Object.freeze([
215
215
  name: "Robinhood Chain",
216
216
  rpcEnv: ["RH_CHAIN_RPC", "ROBINHOOD_RPC_URL"],
217
217
  nativeCurrency: { symbol: "ETH", decimals: 18 },
218
- venues: [],
218
+ // DexScreener does index this chain under the slug "robinhood" (verified live
219
+ // 2026-07-31: a CASHCAT search returns pairs tagged chainId "robinhood"). Without
220
+ // the slug, resolvePools reported UNAVAILABLE on every RH token even though the
221
+ // data was there.
222
+ dexscreenerSlug: "robinhood",
223
+ venues: [
224
+ {
225
+ kind: "quoter",
226
+ address: "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7",
227
+ label: "Uniswap V3 QuoterV2",
228
+ verified: {
229
+ method:
230
+ "functional probe, not a codesize check: quoteExactInputSingle returned a " +
231
+ "live sane price (WETH->USDG fee 100 quoted 1866.58 USDG, and USDG->CASHCAT " +
232
+ "fee 10000 quoted a live amount). A contract that correctly prices known pairs " +
233
+ "IS a working V3 quoter",
234
+ source: "live eth_call against rpc.mainnet.chain.robinhood.com",
235
+ date: "2026-07-31",
236
+ chainId: 4663,
237
+ },
238
+ },
239
+ {
240
+ kind: "router",
241
+ address: "0xcaf681a66d020601342297493863e78c959e5cb2",
242
+ label: "Uniswap V3 SwapRouter02",
243
+ verified: {
244
+ method:
245
+ "eth_getCode returned real bytecode (24497 bytes) on this chain and the paired " +
246
+ "quoter at the same deployment passed a live functional quote",
247
+ source: "live eth_getCode against rpc.mainnet.chain.robinhood.com",
248
+ date: "2026-07-31",
249
+ chainId: 4663,
250
+ },
251
+ },
252
+ {
253
+ kind: "factory",
254
+ address: "0x1f7d7550b1b028f7571e69a784071f0205fd2efa",
255
+ label: "Uniswap V3 Factory",
256
+ verified: {
257
+ method:
258
+ "eth_getCode returned real bytecode (24535 bytes) and a PoolCreated log scan " +
259
+ "over this factory returned 76 pools in ~9000 recent blocks",
260
+ source: "live eth_getLogs against rpc.mainnet.chain.robinhood.com",
261
+ date: "2026-07-31",
262
+ chainId: 4663,
263
+ },
264
+ },
265
+ ],
219
266
  },
220
267
  {
221
268
  key: "base",
@@ -129,7 +129,6 @@ export function assertVaultAttestation(attestation, tx = {}, { chainId, nowMs =
129
129
  export function assertVaultApprovalAttestation(attestation, tx = {}, guard = {}, { chainId, nowMs = Date.now(), secret } = {}) {
130
130
  if (!attestation || attestation.mode !== "vault-attestation") throw new Error("vault attestation required");
131
131
  if (String(attestation.action) !== "deposit") throw new Error("vault approval requires deposit attestation");
132
- assertFreshWindow(attestation, nowMs, "vault approval attestation");
133
132
  if (Number(attestation.expiresAtMs) <= Number(nowMs)) throw new Error("vault attestation expired");
134
133
  const expected = hmac(attestationSecret(secret), canonicalJson(unsigned(attestation)));
135
134
  if (!sameSignature(attestation.signature, expected)) throw new Error("vault attestation signature mismatch");
@@ -1,176 +0,0 @@
1
- ---
2
- name: balance
3
- description: Use when the user says balance, holdings, portfolio, wallet balance, or invokes /balance. Call the deterministic read-only multichain portfolio tool and report coverage honestly.
4
- ---
5
-
6
- # Balance
7
-
8
- Use `portfolio_snapshot` for every fresh balance request. It runs the deterministic
9
- multichain balance and NFT inventory reads once, then records one compact local
10
- observation so the user can build history without a separate tracker.
11
-
12
- Use `portfolio_history` for balance-history requests and `portfolio_value_graph`
13
- for charts. Use `portfolio_balance` only when the user explicitly asks for a
14
- non-recorded read or when local history storage is unavailable.
15
-
16
- This skill is read-only. Public addresses are identifiers, not signing rights.
17
- Never request a private key, seed phrase, wallet export, session key, or signature.
18
-
19
- ## Trigger
20
-
21
- Run this workflow when the user:
22
-
23
- - invokes `/balance`;
24
- - says `balance` by itself;
25
- - asks for holdings, wallet balance, portfolio, net assets, or assets across chains;
26
- - supplies one or more public wallet addresses and asks what they hold.
27
-
28
- ## Address routing
29
-
30
- Parse only public addresses supplied with the request:
31
-
32
- - `0x...` maps to `addresses.evm` and `addresses.hyperliquid` unless the user
33
- explicitly assigns different addresses;
34
- - a Solana base58 public key maps to `addresses.solana`;
35
- - a Bitcoin `bc1`, `1`, or `3` address maps to `addresses.bitcoin`;
36
- - explicit labels such as `evm:`, `solana:`, `bitcoin:`, and `hyperliquid:` win.
37
-
38
- If no addresses are in the request, call `portfolio_snapshot` with an empty object.
39
- The tool uses configured public-address defaults:
40
-
41
- - `ORACLE_EVM_ADDRESS`, with legacy fallback `ORACLE_DEFAULT_ADDRESS`;
42
- - `ORACLE_SOLANA_ADDRESS`;
43
- - `ORACLE_BITCOIN_ADDRESS`;
44
- - `ORACLE_HYPERLIQUID_ADDRESS`, with EVM fallback.
45
-
46
- If every family returns `not-configured`, ask only for the missing public addresses.
47
- Do not call a zero address a user wallet and do not report missing families as zero.
48
-
49
- ## Mandatory call
50
-
51
- Call `portfolio_snapshot`:
52
-
53
- ```json
54
- {
55
- "addresses": {
56
- "evm": "optional public address",
57
- "solana": "optional public key",
58
- "bitcoin": "optional public address",
59
- "hyperliquid": "optional public address"
60
- },
61
- "includeTokens": true,
62
- "includeCollectibles": true,
63
- "includePrices": true,
64
- "includeNfts": true
65
- }
66
- ```
67
-
68
- Omit address fields the user did not supply. Omit `evmChainIds` unless the user
69
- asks for a subset. The default queries every configured EVM chain. Read fungible
70
- and chain details from `result.balance`, NFT holdings from `result.nfts`, and the
71
- combined historical observation from `result.snapshot`.
72
-
73
- Never call a chain write, prepare, sign, send, submit, execute, or broadcast tool
74
- while answering a balance request. The compact profile-local history append made
75
- by `portfolio_snapshot` is allowed and contains no keys or executable payloads.
76
-
77
- ## Coverage contract
78
-
79
- The current aggregator covers:
80
-
81
- - native balances on every configured EVM chain;
82
- - Solana SOL plus SPL Token and Token-2022 accounts;
83
- - Bitcoin BTC plus Runes and inscriptions when an address indexer is configured;
84
- - Hyperliquid HyperCore spot balances and perp account state;
85
- - OpenSea-supported EVM/Solana NFT inventory and Bitcoin inscriptions where the
86
- configured providers expose owner data;
87
- - profile-local snapshots and an SVG known-value history graph.
88
-
89
- It must explicitly report unsupported or unavailable surfaces:
90
-
91
- - EVM token and NFT enumeration is unavailable without an address indexer;
92
- - Solana token accounts are unverified until metadata confirms symbol and spam
93
- status;
94
- - a Solana amount of one with zero decimals is only a collectible candidate;
95
- - Bitcoin Runes and inscriptions require an address-indexed provider;
96
- - Cosmos requires a chain-specific bech32 address and LCD/RPC adapter;
97
- - Sui and Aptos remain unsupported until their balance adapters are installed;
98
- - any provider failure is `unavailable`, never a zero balance;
99
- - NFT prices are provider estimates, not executable bids.
100
-
101
- ## History and graph
102
-
103
- For “balance history”, “portfolio history”, or a date range, call
104
- `portfolio_history` with the same public addresses and the requested `since`,
105
- `until`, and `limit`. Keep `null` values unavailable. Do not interpolate them.
106
-
107
- For “graph”, “chart”, or “show me performance”, call `portfolio_value_graph`.
108
- State the returned `summary.changeUsd`, `summary.changePct`, point count, and
109
- whether any plotted snapshots were incomplete. The image plots known priced
110
- value, not guaranteed liquidation value or net worth.
111
-
112
- ## Response format
113
-
114
- Start with:
115
-
116
- ```text
117
- Balance, <queriedAt>
118
- Known priced value: $<knownUsd> (not a complete total)
119
- Coverage: <ok>/<requestedSurfaces> surfaces live
120
- ```
121
-
122
- If `valuation.complete` is true, you may remove the parenthetical. Do not rename
123
- `knownUsd` to `total`, `net worth`, or `portfolio value` when coverage is partial.
124
-
125
- Then show nonzero assets first, grouped by family:
126
-
127
- ```text
128
- EVM
129
- Ethereum: 0.42 ETH $...
130
- Base: 18.1 ETH $...
131
-
132
- Solana
133
- SOL: ...
134
- SPL: <mint> <amount> unverified
135
-
136
- Bitcoin
137
- BTC: ...
138
- Runes: ...
139
- Inscriptions: ...
140
-
141
- Hyperliquid
142
- Spot: ...
143
- Perps account value: $...
144
- Positions: ...
145
- ```
146
-
147
- Collapse successful zero-native EVM chains into one line:
148
-
149
- ```text
150
- Zero native: Polygon, Optimism, Arbitrum
151
- ```
152
-
153
- End with concise exceptions:
154
-
155
- ```text
156
- Unavailable: <failed providers or unconfigured addresses>
157
- Unpriced: <nonzero assets without a price>
158
- Warnings: <spam, stale, unknown-not-empty, or incomplete discovery warnings>
159
- ```
160
-
161
- Do not dump raw RPC payloads unless the user asks. Keep exact public addresses in
162
- the response only when the user supplied multiple addresses and disambiguation is
163
- necessary.
164
-
165
- ## Truth rules
166
-
167
- - `0` means a provider successfully returned zero.
168
- - `unavailable` means the provider failed or the adapter is absent.
169
- - `not-configured` means the public address is missing.
170
- - `unknownNotEmpty` means the provider cannot tell whether assets exist. Never
171
- convert it to an empty portfolio.
172
- - Every USD figure must carry a price source and query timestamp in the tool
173
- result.
174
- - Do not sum Stable Mainnet native USDT0 with its mirrored ERC-20 representation.
175
- - Do not price unverified or spam tokens by symbol alone.
176
- - Do not hide failed chains to make the summary look complete.