@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.
- package/README.md +5 -12
- package/SETUP.md +1 -35
- package/artifacts/specialist-packs/oracle-full-crypto.json +9 -31
- package/bin/oracle-data-mcp.mjs +74 -247
- package/bin/oracle-init.mjs +27 -100
- package/docs/profiles.md +7 -27
- package/package.json +1 -1
- package/profiles/_template/SOUL.md +1 -8
- package/profiles/oracle/SOUL.md +1 -8
- package/profiles/oracle/profile.json +2 -5
- package/profiles/protocol-builder/SOUL.md +6 -13
- package/profiles/protocol-builder/profile.json +1 -3
- package/src/cards.mjs +369 -0
- package/src/data/catalog.mjs +3 -27
- package/src/data/desk-data.mjs +4 -34
- package/src/data/providers/magiceden-sol.mjs +2 -21
- package/src/data/providers/opensea-nft.mjs +0 -272
- package/src/data/providers/satflow.mjs +0 -1
- package/src/data/providers/uniswap-v3.mjs +17 -1
- package/src/exec-policy.mjs +0 -5
- package/src/gmx-attestation.mjs +0 -1
- package/src/router/prepare-route.mjs +23 -0
- package/src/router/route-sources.mjs +37 -0
- package/src/scanner/chains.config.mjs +48 -1
- package/src/vault-attestation.mjs +0 -1
- package/skills/balance/SKILL.md +0 -176
- package/skills/oracle-multichain-nft-launch/SKILL.md +0 -338
- package/skills/oracle-multichain-token-launch/SKILL.md +0 -300
- package/src/data/providers/nft-gallery.mjs +0 -163
- package/src/data/providers/nft-portfolio.mjs +0 -494
- package/src/data/providers/portfolio-history.mjs +0 -394
- package/src/data/providers/portfolio.mjs +0 -594
|
@@ -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 = {}) {
|
package/src/exec-policy.mjs
CHANGED
|
@@ -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)) {
|
package/src/gmx-attestation.mjs
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/skills/balance/SKILL.md
DELETED
|
@@ -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.
|