nansen-cli 1.43.1 → 1.44.0
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/CHANGELOG.md +66 -0
- package/README.md +34 -0
- package/package.json +1 -1
- package/src/api.js +184 -23
- package/src/bridge.js +103 -5
- package/src/cli.js +42 -52
- package/src/commands/completion.js +652 -0
- package/src/commands/mcp.js +19 -3
- package/src/commands/research.js +199 -25
- package/src/hl-client.js +20 -8
- package/src/limit-order.js +31 -20
- package/src/mcp-verify.js +66 -1
- package/src/perp.js +72 -24
- package/src/query-options.js +32 -0
- package/src/schema.json +631 -131
- package/src/semver.js +26 -0
- package/src/telemetry.js +118 -19
- package/src/trading.js +196 -22
- package/src/update-check.js +13 -6
- package/src/x402.js +8 -1
package/src/commands/research.js
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nansen CLI - Research command
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* metrics at the requested date rather than current state — useful for
|
|
6
|
-
* backtesting and historical research.
|
|
4
|
+
* Direct API analytics, including historical/point-in-time research.
|
|
7
5
|
*/
|
|
8
6
|
|
|
9
7
|
import { NansenError, ErrorCode } from '../api.js';
|
|
8
|
+
import { parseSort } from '../query-options.js';
|
|
10
9
|
|
|
11
|
-
//
|
|
10
|
+
// Research subcommands validate --page strictly. The shared helper in
|
|
11
|
+
// src/query-options.js clamps an invalid page to 1 for the category commands,
|
|
12
|
+
// so keep a local variant here that rejects it instead.
|
|
12
13
|
function buildPagination(options) {
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
if (options.limit === undefined && options.page === undefined) return undefined;
|
|
15
|
+
const pagination = { page: 1 };
|
|
16
|
+
if (options.page !== undefined) {
|
|
17
|
+
const page = Number(options.page);
|
|
18
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
19
|
+
throw new NansenError('--page must be a positive integer', ErrorCode.INVALID_PARAMS);
|
|
20
|
+
}
|
|
21
|
+
pagination.page = page;
|
|
22
|
+
}
|
|
23
|
+
if (options.limit !== undefined) {
|
|
24
|
+
const perPage = Number(options.limit);
|
|
25
|
+
if (!Number.isInteger(perPage) || perPage < 1) {
|
|
26
|
+
throw new NansenError('--limit must be a positive integer', ErrorCode.INVALID_PARAMS);
|
|
27
|
+
}
|
|
28
|
+
pagination.per_page = perPage;
|
|
29
|
+
}
|
|
30
|
+
return pagination;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const SUBCOMMANDS = [
|
|
@@ -38,9 +42,16 @@ const SUBCOMMANDS = [
|
|
|
38
42
|
'historical-wallet-balances',
|
|
39
43
|
'historical-tx-lookup',
|
|
40
44
|
'historical-wallet-transactions',
|
|
45
|
+
'historical-token-ohlcv',
|
|
41
46
|
];
|
|
42
47
|
|
|
43
48
|
export const RESEARCH_HISTORICAL_SUBCOMMANDS = new Set(SUBCOMMANDS);
|
|
49
|
+
export const RESEARCH_SUBCOMMANDS = new Set(['chain-rank', 'token-sectors', 'address-premium-labels', 'smart-money-pnl-leaderboard', 'position-intelligence', 'perp-pnl-summary', 'transaction-with-token-transfer-lookup', ...SUBCOMMANDS]);
|
|
50
|
+
|
|
51
|
+
const CHAIN_RANK_TIMEFRAMES = new Set([7, 30, 365]);
|
|
52
|
+
const CHAIN_RANK_CHAIN_TYPES = new Set(['all', 'evm']);
|
|
53
|
+
|
|
54
|
+
const SM_PNL_TIMEFRAME_DAYS = [1, 7, 30, 90, 180];
|
|
44
55
|
|
|
45
56
|
function requireOptions(options, required) {
|
|
46
57
|
const missing = required.filter(name => !options[name]);
|
|
@@ -58,11 +69,20 @@ function resolveDateRange(options) {
|
|
|
58
69
|
|
|
59
70
|
function parseTimeframeDays(value) {
|
|
60
71
|
if (value === undefined || value === null || value === '') return undefined;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
throw new NansenError('--timeframe-days must be
|
|
72
|
+
const trimmed = String(value).trim();
|
|
73
|
+
if (!/^[1-9]\d*$/.test(trimmed)) {
|
|
74
|
+
throw new NansenError('--timeframe-days must be a positive integer', ErrorCode.INVALID_PARAMS);
|
|
64
75
|
}
|
|
65
|
-
return
|
|
76
|
+
return parseInt(trimmed, 10);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseBooleanOption(options, flags, key) {
|
|
80
|
+
const value = options[key] ?? flags[key];
|
|
81
|
+
if (value === undefined) return undefined;
|
|
82
|
+
if (typeof value === 'boolean') return value;
|
|
83
|
+
if (value === 'true' || value === '1') return true;
|
|
84
|
+
if (value === 'false' || value === '0') return false;
|
|
85
|
+
throw new NansenError(`--${key} must be true or false`, ErrorCode.INVALID_PARAMS);
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
function parseChains(options) {
|
|
@@ -75,9 +95,17 @@ function parseChains(options) {
|
|
|
75
95
|
return undefined;
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
const HELP_TOP = `nansen research —
|
|
98
|
+
const HELP_TOP = `nansen research — Direct API analytics
|
|
79
99
|
|
|
80
100
|
SUBCOMMANDS:
|
|
101
|
+
chain-rank Rank chains by growth metrics
|
|
102
|
+
token-sectors List token sectors available for filtering
|
|
103
|
+
address-premium-labels Get all labels for an address, including premium labels
|
|
104
|
+
smart-money-pnl-leaderboard Rank smart money wallets by PnL
|
|
105
|
+
position-intelligence Aggregate Hyperliquid positions by trader cohort
|
|
106
|
+
perp-pnl-summary Summarize realized Hyperliquid PnL for an address
|
|
107
|
+
transaction-with-token-transfer-lookup
|
|
108
|
+
Look up a transaction and its token/NFT transfers
|
|
81
109
|
historical-dex-trades Historical DEX trades for a token
|
|
82
110
|
historical-pnl-leaderboard Historical PnL leaderboard for a token
|
|
83
111
|
historical-token-flow-summary Historical token flow summary
|
|
@@ -89,6 +117,7 @@ SUBCOMMANDS:
|
|
|
89
117
|
historical-wallet-balances Historical token balances for a wallet
|
|
90
118
|
historical-tx-lookup Lookup a historical transaction by hash
|
|
91
119
|
historical-wallet-transactions Historical transactions for a wallet
|
|
120
|
+
historical-token-ohlcv Historical token OHLCV candles
|
|
92
121
|
|
|
93
122
|
COMMON OPTIONS:
|
|
94
123
|
--from-date <YYYY-MM-DD> Start of date range (for range-based subcommands)
|
|
@@ -102,6 +131,38 @@ COMMON OPTIONS:
|
|
|
102
131
|
Run: nansen research <subcommand> --help`;
|
|
103
132
|
|
|
104
133
|
const SUB_HELP = {
|
|
134
|
+
'chain-rank': `nansen research chain-rank — Rank chains by growth metrics
|
|
135
|
+
|
|
136
|
+
USAGE:
|
|
137
|
+
nansen research chain-rank [--timeframe-days 7|30|365] [--chain-type all|evm]`,
|
|
138
|
+
'token-sectors': `nansen research token-sectors — List token sectors available for filtering
|
|
139
|
+
|
|
140
|
+
USAGE:
|
|
141
|
+
nansen research token-sectors`,
|
|
142
|
+
'address-premium-labels': `nansen research address-premium-labels — Get all labels for an address, including premium labels
|
|
143
|
+
|
|
144
|
+
USAGE:
|
|
145
|
+
nansen research address-premium-labels --address <addr> [--chain <chain>] [--page <n>] [--limit <n>]`,
|
|
146
|
+
'smart-money-pnl-leaderboard': `nansen research smart-money-pnl-leaderboard — Rank smart money wallets by PnL
|
|
147
|
+
|
|
148
|
+
USAGE:
|
|
149
|
+
nansen research smart-money-pnl-leaderboard [--chains c1,c2] [--timeframe-days 1|7|30|90|180] [--filters '<json>'] [--sort <field[:asc|desc]>] [--page <n>] [--limit <n>]`,
|
|
150
|
+
'position-intelligence': `nansen research position-intelligence — Aggregate Hyperliquid positions by trader cohort
|
|
151
|
+
|
|
152
|
+
USAGE:
|
|
153
|
+
nansen research position-intelligence --symbol <symbol>
|
|
154
|
+
|
|
155
|
+
NOTE: --token-address is accepted as an alias for --symbol (the API request field is token_address).`,
|
|
156
|
+
'perp-pnl-summary': `nansen research perp-pnl-summary — Summarize realized Hyperliquid PnL for an address
|
|
157
|
+
|
|
158
|
+
USAGE:
|
|
159
|
+
nansen research perp-pnl-summary --address <addr> --from-date <date> --to-date <date>`,
|
|
160
|
+
'transaction-with-token-transfer-lookup': `nansen research transaction-with-token-transfer-lookup — Look up a transaction and its token/NFT transfers
|
|
161
|
+
|
|
162
|
+
USAGE:
|
|
163
|
+
nansen research transaction-with-token-transfer-lookup --transaction-hash <hash> [--chain <chain>] [--block-timestamp "YYYY-MM-DD HH:MM:SS"]
|
|
164
|
+
|
|
165
|
+
NOTE: --block-timestamp is required for bitcoin, tron, ton, starknet, and sui.`,
|
|
105
166
|
'historical-dex-trades': `nansen research historical-dex-trades — Historical DEX trades for a token
|
|
106
167
|
|
|
107
168
|
USAGE:
|
|
@@ -152,6 +213,10 @@ NOTE: Providing --block-timestamp skips a slow hash-resolution step and returns
|
|
|
152
213
|
|
|
153
214
|
USAGE:
|
|
154
215
|
nansen research historical-wallet-transactions --address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
|
|
216
|
+
'historical-token-ohlcv': `nansen research historical-token-ohlcv — Historical token OHLCV candles
|
|
217
|
+
|
|
218
|
+
USAGE:
|
|
219
|
+
nansen research historical-token-ohlcv --token-address <addr> --from-date <date> --timeframe <5m|15m|30m|1h|1d|1w> (--as-of-date <date> | --as-of-ts <timestamp>) [--chain <chain>] [--apply-blacklist-filter <true|false>]`,
|
|
155
220
|
};
|
|
156
221
|
|
|
157
222
|
export function buildResearchCommands(deps = {}) {
|
|
@@ -166,9 +231,9 @@ export function buildResearchCommands(deps = {}) {
|
|
|
166
231
|
return;
|
|
167
232
|
}
|
|
168
233
|
|
|
169
|
-
if (!
|
|
234
|
+
if (!RESEARCH_SUBCOMMANDS.has(sub)) {
|
|
170
235
|
throw new NansenError(
|
|
171
|
-
`Unknown research subcommand: ${sub}. Available: ${
|
|
236
|
+
`Unknown research subcommand: ${sub}. Available: ${[...RESEARCH_SUBCOMMANDS].join(', ')}`,
|
|
172
237
|
ErrorCode.UNKNOWN,
|
|
173
238
|
);
|
|
174
239
|
}
|
|
@@ -178,12 +243,121 @@ export function buildResearchCommands(deps = {}) {
|
|
|
178
243
|
return;
|
|
179
244
|
}
|
|
180
245
|
|
|
246
|
+
if (sub === 'token-sectors') return apiInstance.tokenSectors();
|
|
247
|
+
|
|
181
248
|
const orderBy = parseSort(options.sort, options['order-by']);
|
|
182
249
|
const pagination = buildPagination(options);
|
|
183
250
|
const filters = options.filters || {};
|
|
184
251
|
const { fromDate, toDate } = resolveDateRange(options);
|
|
185
252
|
const asOfDate = options['as-of-date'];
|
|
186
253
|
|
|
254
|
+
if (sub === 'chain-rank') {
|
|
255
|
+
const timeFrame = parseTimeframeDays(options['timeframe-days']) ?? 7;
|
|
256
|
+
if (!CHAIN_RANK_TIMEFRAMES.has(timeFrame)) {
|
|
257
|
+
throw new NansenError(
|
|
258
|
+
`--timeframe-days must be one of: ${[...CHAIN_RANK_TIMEFRAMES].join(', ')}`,
|
|
259
|
+
ErrorCode.INVALID_PARAMS,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const chainType = options['chain-type'] || 'all';
|
|
263
|
+
if (!CHAIN_RANK_CHAIN_TYPES.has(chainType)) {
|
|
264
|
+
throw new NansenError(
|
|
265
|
+
`--chain-type must be one of: ${[...CHAIN_RANK_CHAIN_TYPES].join(', ')}`,
|
|
266
|
+
ErrorCode.INVALID_PARAMS,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return apiInstance.chainRank({ timeFrame, chainType });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (sub === 'address-premium-labels') {
|
|
273
|
+
requireOptions({ address: options.address }, ['address']);
|
|
274
|
+
return apiInstance.addressPremiumLabels({
|
|
275
|
+
address: options.address,
|
|
276
|
+
chain: options.chain || 'all',
|
|
277
|
+
pagination,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (sub === 'smart-money-pnl-leaderboard') {
|
|
282
|
+
const timeframe = parseTimeframeDays(options['timeframe-days']) ?? 7;
|
|
283
|
+
if (!SM_PNL_TIMEFRAME_DAYS.includes(timeframe)) {
|
|
284
|
+
throw new NansenError(
|
|
285
|
+
`--timeframe-days must be one of: ${SM_PNL_TIMEFRAME_DAYS.join(', ')}`,
|
|
286
|
+
ErrorCode.INVALID_PARAMS,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return apiInstance.smartMoneyPnlLeaderboard({
|
|
290
|
+
chains: parseChains(options) || ['solana'],
|
|
291
|
+
timeframe,
|
|
292
|
+
filters, orderBy, pagination,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (sub === 'position-intelligence') {
|
|
297
|
+
const symbol = String(options.symbol || options['token-address'] || options.token || '').trim();
|
|
298
|
+
if (!symbol) {
|
|
299
|
+
throw new NansenError(
|
|
300
|
+
'Required: --symbol (or --token-address)',
|
|
301
|
+
ErrorCode.MISSING_PARAM,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return apiInstance.tokenPositionIntelligence({ tokenAddress: symbol });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (sub === 'perp-pnl-summary') {
|
|
308
|
+
requireOptions(
|
|
309
|
+
{ address: options.address, 'from-date': fromDate, 'to-date': toDate },
|
|
310
|
+
['address', 'from-date', 'to-date'],
|
|
311
|
+
);
|
|
312
|
+
return apiInstance.addressPerpPnlSummary({ address: options.address, fromDate, toDate });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (sub === 'historical-token-ohlcv') {
|
|
316
|
+
const tokenAddress = options['token-address'] || options.token;
|
|
317
|
+
const asOfTs = options['as-of-ts'];
|
|
318
|
+
requireOptions(
|
|
319
|
+
{ 'token-address': tokenAddress, 'from-date': fromDate, timeframe: options.timeframe },
|
|
320
|
+
['token-address', 'from-date', 'timeframe'],
|
|
321
|
+
);
|
|
322
|
+
if (!asOfDate && !asOfTs) {
|
|
323
|
+
throw new NansenError(
|
|
324
|
+
'Provide one of --as-of-date or --as-of-ts',
|
|
325
|
+
ErrorCode.MISSING_PARAM,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (asOfDate && asOfTs) {
|
|
329
|
+
throw new NansenError(
|
|
330
|
+
'--as-of-date and --as-of-ts are mutually exclusive',
|
|
331
|
+
ErrorCode.INVALID_PARAMS,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return apiInstance.researchHistoricalTokenOhlcv({
|
|
335
|
+
tokenAddress,
|
|
336
|
+
chain: options.chain || 'solana',
|
|
337
|
+
fromDate,
|
|
338
|
+
asOfDate,
|
|
339
|
+
asOfTs,
|
|
340
|
+
timeframe: options.timeframe,
|
|
341
|
+
applyBlacklistFilter: parseBooleanOption(options, flags, 'apply-blacklist-filter'),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (sub === 'transaction-with-token-transfer-lookup') {
|
|
346
|
+
const chain = options.chain || 'ethereum';
|
|
347
|
+
const blockTimestamp = options['block-timestamp'];
|
|
348
|
+
requireOptions({ 'transaction-hash': options['transaction-hash'] }, ['transaction-hash']);
|
|
349
|
+
// Chains the API rejects without block_timestamp. Every other enum value,
|
|
350
|
+
// including 'all', near, and injective, accepts a timestamp-less lookup.
|
|
351
|
+
if (['bitcoin', 'tron', 'ton', 'starknet', 'sui'].includes(chain)) {
|
|
352
|
+
requireOptions({ 'block-timestamp': blockTimestamp }, ['block-timestamp']);
|
|
353
|
+
}
|
|
354
|
+
return apiInstance.transactionWithTokenTransferLookup({
|
|
355
|
+
transactionHash: options['transaction-hash'],
|
|
356
|
+
chain,
|
|
357
|
+
blockTimestamp,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
187
361
|
// Range-based token endpoints (require --from-date + --to-date)
|
|
188
362
|
const rangeTokenHandlers = {
|
|
189
363
|
'historical-dex-trades': () => apiInstance.researchDexTrades({
|
package/src/hl-client.js
CHANGED
|
@@ -30,6 +30,15 @@ export {
|
|
|
30
30
|
|
|
31
31
|
import { hlApiUrl } from "./hl-env.js";
|
|
32
32
|
|
|
33
|
+
function exchangeError(message, code, exchangeResult) {
|
|
34
|
+
const error = new CommandError(message, code);
|
|
35
|
+
// Keep only a structured action response available to the caller so it can
|
|
36
|
+
// emit authoritative per-leg outcomes before rethrowing. Opaque HTTP bodies
|
|
37
|
+
// are not action results and must remain indeterminate.
|
|
38
|
+
if (exchangeResult !== undefined) error.exchangeResult = exchangeResult;
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
// Port of perp_execute.py::extract_action_errors. HL returns top-level
|
|
34
43
|
// status "ok" even when individual actions are rejected:
|
|
35
44
|
// {"status":"ok","response":{"data":{"statuses":[{"error":"..."}]}}}
|
|
@@ -130,9 +139,9 @@ export async function submitExchange(
|
|
|
130
139
|
typeof data === "string"
|
|
131
140
|
? data
|
|
132
141
|
: data.response || data.error || JSON.stringify(data);
|
|
133
|
-
throw
|
|
142
|
+
throw exchangeError(
|
|
134
143
|
`Hyperliquid error (HTTP ${response.status}): ${detail}`,
|
|
135
|
-
"HL_HTTP_ERROR"
|
|
144
|
+
"HL_HTTP_ERROR",
|
|
136
145
|
);
|
|
137
146
|
}
|
|
138
147
|
|
|
@@ -144,23 +153,26 @@ export async function submitExchange(
|
|
|
144
153
|
typeof responseBody === "string"
|
|
145
154
|
? responseBody
|
|
146
155
|
: "Hyperliquid rejected the action";
|
|
147
|
-
throw
|
|
156
|
+
throw exchangeError(
|
|
148
157
|
`Hyperliquid rejected the action: ${reason}`,
|
|
149
|
-
"HL_ACTION_REJECTED"
|
|
158
|
+
"HL_ACTION_REJECTED",
|
|
159
|
+
data,
|
|
150
160
|
);
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
const actionResults = extractActionErrors(responseBody, action);
|
|
154
164
|
if (actionResults.failed.length > 0 && actionResults.succeeded.length > 0) {
|
|
155
|
-
throw
|
|
165
|
+
throw exchangeError(
|
|
156
166
|
`Hyperliquid partially filled the action: succeeded ${actionResults.succeeded.join(", ")}; failed ${actionResults.failed.map(({ leg, error }) => `${leg}: ${error}`).join("; ")}`,
|
|
157
|
-
"PARTIAL_FILL"
|
|
167
|
+
"PARTIAL_FILL",
|
|
168
|
+
data,
|
|
158
169
|
);
|
|
159
170
|
}
|
|
160
171
|
if (actionResults.failed.length > 0) {
|
|
161
|
-
throw
|
|
172
|
+
throw exchangeError(
|
|
162
173
|
`Hyperliquid rejected the action: ${actionResults.failed.map(({ error }) => error).join("; ")}`,
|
|
163
|
-
"HL_ACTION_REJECTED"
|
|
174
|
+
"HL_ACTION_REJECTED",
|
|
175
|
+
data,
|
|
164
176
|
);
|
|
165
177
|
}
|
|
166
178
|
|
package/src/limit-order.js
CHANGED
|
@@ -396,23 +396,34 @@ export function parseExpiry(expiryStr) {
|
|
|
396
396
|
const match = expiryStr.match(/^(\d+)(h|d)$/i);
|
|
397
397
|
if (match) {
|
|
398
398
|
const value = parseInt(match[1], 10);
|
|
399
|
+
if (!Number.isFinite(value)) {
|
|
400
|
+
throw new Error(`Invalid expiry "${expiryStr}". Duration must be finite.`);
|
|
401
|
+
}
|
|
399
402
|
if (value <= 0) {
|
|
400
403
|
throw new Error(`Invalid expiry "${expiryStr}". Duration must be greater than 0.`);
|
|
401
404
|
}
|
|
402
405
|
const unit = match[2].toLowerCase();
|
|
403
406
|
const ms = unit === 'h' ? value * 3600 * 1000 : value * 24 * 3600 * 1000;
|
|
404
|
-
|
|
407
|
+
const expiresAt = Date.now() + ms;
|
|
408
|
+
if (!Number.isFinite(expiresAt)) {
|
|
409
|
+
throw new Error(`Invalid expiry "${expiryStr}". Duration is too large.`);
|
|
410
|
+
}
|
|
411
|
+
return expiresAt;
|
|
405
412
|
}
|
|
406
413
|
|
|
407
414
|
// Try as raw epoch ms — must be in the future, or the order expires on arrival.
|
|
408
415
|
const num = Number(expiryStr);
|
|
409
|
-
if (
|
|
416
|
+
if (Number.isFinite(num)) {
|
|
410
417
|
if (num <= Date.now()) {
|
|
411
418
|
throw new Error(`Expiry "${expiryStr}" is in the past. Provide a future time (e.g. "24h", "7d", or a future epoch in ms).`);
|
|
412
419
|
}
|
|
413
420
|
return num;
|
|
414
421
|
}
|
|
415
422
|
|
|
423
|
+
if (!Number.isNaN(num)) {
|
|
424
|
+
throw new Error(`Invalid expiry "${expiryStr}". Provide a finite future epoch in milliseconds or a duration such as "24h" or "7d".`);
|
|
425
|
+
}
|
|
426
|
+
|
|
416
427
|
throw new Error(`Invalid expiry format: "${expiryStr}". Use "24h", "7d", "30d", or epoch ms.`);
|
|
417
428
|
}
|
|
418
429
|
|
|
@@ -569,6 +580,22 @@ EXAMPLES:
|
|
|
569
580
|
return;
|
|
570
581
|
}
|
|
571
582
|
|
|
583
|
+
const price = Number(triggerPrice);
|
|
584
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
585
|
+
log('Error: --trigger-price must be a finite positive number.');
|
|
586
|
+
exit(1);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let expiresAt;
|
|
591
|
+
try {
|
|
592
|
+
expiresAt = parseExpiry(expiresStr);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
log(`Error: ${err.message}`);
|
|
595
|
+
exit(1);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
572
599
|
// Amount is always in human-readable token units (e.g. 1.5 = 1.5 SOL)
|
|
573
600
|
// Converted to base units (lamports) internally
|
|
574
601
|
let amountBaseUnits;
|
|
@@ -594,13 +621,6 @@ EXAMPLES:
|
|
|
594
621
|
return;
|
|
595
622
|
}
|
|
596
623
|
|
|
597
|
-
const price = Number(triggerPrice);
|
|
598
|
-
if (isNaN(price) || price <= 0) {
|
|
599
|
-
log('Error: --trigger-price must be a positive number (USD price).');
|
|
600
|
-
exit(1);
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
624
|
if (triggerCondition !== 'above' && triggerCondition !== 'below') {
|
|
605
625
|
log('Error: --trigger-condition must be "above" or "below".');
|
|
606
626
|
exit(1);
|
|
@@ -619,15 +639,6 @@ EXAMPLES:
|
|
|
619
639
|
}
|
|
620
640
|
}
|
|
621
641
|
|
|
622
|
-
let expiresAt;
|
|
623
|
-
try {
|
|
624
|
-
expiresAt = parseExpiry(expiresStr);
|
|
625
|
-
} catch (err) {
|
|
626
|
-
log(`Error: ${err.message}`);
|
|
627
|
-
exit(1);
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
642
|
const triggerMint = resolveTokenAddress(triggerMintRaw, 'solana');
|
|
632
643
|
|
|
633
644
|
const tmValidation = validateTokenAddress(triggerMint, 'solana');
|
|
@@ -899,8 +910,8 @@ EXAMPLES:
|
|
|
899
910
|
const updateBody = { orderType: 'single' };
|
|
900
911
|
if (triggerPrice != null) {
|
|
901
912
|
const price = Number(triggerPrice);
|
|
902
|
-
if (
|
|
903
|
-
log('Error: --trigger-price must be a positive number.');
|
|
913
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
914
|
+
log('Error: --trigger-price must be a finite positive number.');
|
|
904
915
|
exit(1);
|
|
905
916
|
return;
|
|
906
917
|
}
|
package/src/mcp-verify.js
CHANGED
|
@@ -12,6 +12,37 @@ class McpRequestError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})';
|
|
16
|
+
const LOOPBACK_IPV4 = new RegExp(`^127\\.${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}$`);
|
|
17
|
+
|
|
18
|
+
// IPv4-mapped IPv6 loopback (127.0.0.0/8). Node's URL canonicalizes
|
|
19
|
+
// ::ffff:127.x.x.x to ::ffff:7fNN:NNNN (the 127 becomes the 7f high byte), so
|
|
20
|
+
// match that form; non-loopback mapped addresses fall outside the 7f prefix.
|
|
21
|
+
const LOOPBACK_IPV4_MAPPED = /^::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}$/i;
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname) {
|
|
24
|
+
if (!hostname) return false;
|
|
25
|
+
const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
26
|
+
if (host === 'localhost' || host === '::1') return true;
|
|
27
|
+
if (LOOPBACK_IPV4_MAPPED.test(host)) return true;
|
|
28
|
+
return LOOPBACK_IPV4.test(host);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Classify a destination URL for the credential-disclosure guard: its origin
|
|
33
|
+
* (for messaging), protocol, and whether the host is loopback.
|
|
34
|
+
*/
|
|
35
|
+
function classifyDestination(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
return { origin: parsed.origin, protocol: parsed.protocol, loopback: isLoopbackHostname(parsed.hostname) };
|
|
39
|
+
} catch {
|
|
40
|
+
// An unparseable URL never reaches an authenticated fetch (tools/list fails
|
|
41
|
+
// first), so this is only for messaging; treat it as an unsafe destination.
|
|
42
|
+
return { origin: url, protocol: null, loopback: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
function responseContentType(response) {
|
|
16
47
|
return (
|
|
17
48
|
response.headers?.get?.('content-type')
|
|
@@ -185,6 +216,7 @@ export async function runMcpVerifyChecks({
|
|
|
185
216
|
fetchFn = fetch,
|
|
186
217
|
timeoutMs = 30_000,
|
|
187
218
|
devConfigPath,
|
|
219
|
+
sendApiKey = false,
|
|
188
220
|
} = {}) {
|
|
189
221
|
const checks = [];
|
|
190
222
|
const auth = resolveAuthConfig(env, devConfigPath);
|
|
@@ -205,8 +237,39 @@ export async function runMcpVerifyChecks({
|
|
|
205
237
|
));
|
|
206
238
|
}
|
|
207
239
|
|
|
240
|
+
// A saved key is trusted for the default Nansen endpoint only. Forwarding it
|
|
241
|
+
// to a caller-supplied URL requires explicit per-invocation consent, and no
|
|
242
|
+
// key may travel in cleartext to a public host.
|
|
243
|
+
let keyWithheld = null;
|
|
208
244
|
if (key && url !== DEFAULT_MCP_URL) {
|
|
209
|
-
|
|
245
|
+
const dest = classifyDestination(url);
|
|
246
|
+
if (dest.protocol === 'http:' && !dest.loopback) {
|
|
247
|
+
keyWithheld = check(
|
|
248
|
+
'mcp-url',
|
|
249
|
+
'error',
|
|
250
|
+
`Refusing to send the API key over plain HTTP to a non-loopback host (${dest.origin})`,
|
|
251
|
+
'Use an https:// URL. Plain HTTP is only permitted for localhost/loopback development.',
|
|
252
|
+
);
|
|
253
|
+
} else if (dest.protocol !== 'https:' && !(dest.protocol === 'http:' && dest.loopback)) {
|
|
254
|
+
// Anything not provably secure — an unparseable URL or a non-http(s)
|
|
255
|
+
// scheme — is refused rather than relied on to fail later in fetch.
|
|
256
|
+
keyWithheld = check(
|
|
257
|
+
'mcp-url',
|
|
258
|
+
'error',
|
|
259
|
+
`Refusing to send the API key to an unsupported MCP URL (${dest.origin})`,
|
|
260
|
+
'Use an https:// URL, or a loopback http:// URL for local development.',
|
|
261
|
+
);
|
|
262
|
+
} else if (!explicitKey && !sendApiKey) {
|
|
263
|
+
keyWithheld = check(
|
|
264
|
+
'mcp-url',
|
|
265
|
+
'error',
|
|
266
|
+
`Saved API key withheld from custom MCP URL (${dest.origin})`,
|
|
267
|
+
`Re-run with --send-api-key to authorize sending your saved key to ${dest.origin}, or pass --api-key <key> to supply one explicitly.`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
checks.push(check('mcp-url', 'warn', `Sending the API key to custom MCP host: ${dest.origin}`));
|
|
271
|
+
}
|
|
272
|
+
if (keyWithheld) checks.push(keyWithheld);
|
|
210
273
|
}
|
|
211
274
|
|
|
212
275
|
let serverReady = false;
|
|
@@ -239,6 +302,8 @@ export async function runMcpVerifyChecks({
|
|
|
239
302
|
|
|
240
303
|
if (!key) {
|
|
241
304
|
checks.push(skippedAuth('no API key was provided'));
|
|
305
|
+
} else if (keyWithheld) {
|
|
306
|
+
checks.push(skippedAuth('the API key was withheld from this custom URL (see above)'));
|
|
242
307
|
} else if (!serverReady) {
|
|
243
308
|
checks.push(skippedAuth('tools/list did not establish server reachability'));
|
|
244
309
|
} else {
|