nansen-cli 1.30.2 → 1.31.1

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.31.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#443](https://github.com/nansen-ai/nansen-cli/pull/443) [`0ee84db`](https://github.com/nansen-ai/nansen-cli/commit/0ee84db6cdab06054c6ae79c6d871bae0b4f3edb) Thanks [@araa47](https://github.com/araa47)! - Skip native gas pre-check for trades >= $10 USD, where gasless/solver-paid routes (e.g. Relay) are viable. When gas is insufficient on smaller trades, the error now also suggests increasing the trade value as an alternative to topping up gas.
8
+
9
+ ## 1.31.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#437](https://github.com/nansen-ai/nansen-cli/pull/437) [`5ec7bd3`](https://github.com/nansen-ai/nansen-cli/commit/5ec7bd33f173cd712f9f592599e32b2a0d28fe0f) Thanks [@gulshngill](https://github.com/gulshngill)! - Add `nansen research` command with 11 subcommands for historical/point-in-time analytics: dex-trades, pnl-leaderboard, token-flow-summary, token-quant-scores, top-holders, who-bought-sold, smart-money-balances, token-screener, wallet-balances, tx-lookup, wallet-transactions. Labels and metrics resolve at the requested date rather than current state — useful for backtesting and historical research.
14
+
15
+ ### Patch Changes
16
+
17
+ - [#440](https://github.com/nansen-ai/nansen-cli/pull/440) [`701dad4`](https://github.com/nansen-ai/nansen-cli/commit/701dad49f54323cf2b455376b1af3b012e2e0b71) Thanks [@kome12](https://github.com/kome12)! - Fix `research historical-token-screener` schema to mark `--to-date` as required (matching CLI and API behavior)
18
+
19
+ - [#442](https://github.com/nansen-ai/nansen-cli/pull/442) [`6edbb68`](https://github.com/nansen-ai/nansen-cli/commit/6edbb686b52e00b8725470a3f4409ff15f2ccb95) Thanks [@kome12](https://github.com/kome12)! - `research historical-token-flow-summary` now errors immediately when `--page` or `--limit` are passed (the endpoint returns a single aggregated row and does not support pagination). `research historical-smart-money-balances` now errors when `--sort` or `--order-by` are passed (the endpoint does not support ordering). Previously both flags were silently dropped.
20
+
3
21
  ## 1.30.2
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.30.2",
3
+ "version": "1.31.1",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -66,6 +66,7 @@
66
66
  "@changesets/cli": "^2.29.4",
67
67
  "@eslint/js": "^10.0.1",
68
68
  "@vitest/coverage-v8": "^4.0.18",
69
+ "clawhub": "^0.9.0",
69
70
  "eslint": "^10.0.2",
70
71
  "globals": "^17.4.0",
71
72
  "vitest": "^4.0.18"
package/src/api.js CHANGED
@@ -1363,6 +1363,151 @@ export class NansenAPI {
1363
1363
  });
1364
1364
  }
1365
1365
 
1366
+ // ============= Research (Historical) Endpoints =============
1367
+ // Historical/point-in-time analytics: labels and metrics are resolved at the
1368
+ // requested date rather than current state. Useful for backtesting and historical
1369
+ // research. Some endpoints use a { from, to } date range; others use a single
1370
+ // as_of_date snapshot; the token-screener uses timeframe_days + optional to_date.
1371
+
1372
+ async researchDexTrades(params = {}) {
1373
+ const { tokenAddress, chain = 'solana', fromDate, toDate, filters = {}, orderBy, pagination } = params;
1374
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1375
+ return this.request('/api/v1beta1/tgm/historical-dex-trades', {
1376
+ token_address: tokenAddress,
1377
+ chain,
1378
+ date_range: { from: fromDate, to: toDate },
1379
+ filters,
1380
+ order_by: orderBy,
1381
+ pagination,
1382
+ });
1383
+ }
1384
+
1385
+ async researchPnlLeaderboard(params = {}) {
1386
+ const { tokenAddress, chain = 'solana', fromDate, toDate, filters = {}, orderBy, pagination } = params;
1387
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1388
+ return this.request('/api/v1beta1/tgm/historical-pnl-leaderboard', {
1389
+ token_address: tokenAddress,
1390
+ chain,
1391
+ date_range: { from: fromDate, to: toDate },
1392
+ filters,
1393
+ order_by: orderBy,
1394
+ pagination,
1395
+ });
1396
+ }
1397
+
1398
+ async researchTokenFlowSummary(params = {}) {
1399
+ // API does not support pagination on this endpoint.
1400
+ const { tokenAddress, chain = 'solana', fromDate, toDate, filters = {}, orderBy } = params;
1401
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1402
+ return this.request('/api/v1beta1/tgm/historical-token-flow-summary', {
1403
+ token_address: tokenAddress,
1404
+ chain,
1405
+ date_range: { from: fromDate, to: toDate },
1406
+ filters,
1407
+ order_by: orderBy,
1408
+ });
1409
+ }
1410
+
1411
+ async researchTokenQuantScores(params = {}) {
1412
+ const { tokenAddress, chain = 'solana', asOfDate, filters = {}, orderBy, pagination } = params;
1413
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1414
+ return this.request('/api/v1beta1/tgm/historical-token-quant-scores', {
1415
+ token_address: tokenAddress,
1416
+ chain,
1417
+ as_of_date: asOfDate,
1418
+ filters,
1419
+ order_by: orderBy,
1420
+ pagination,
1421
+ });
1422
+ }
1423
+
1424
+ async researchTopHolders(params = {}) {
1425
+ const { tokenAddress, chain = 'solana', asOfDate, filters = {}, orderBy, pagination } = params;
1426
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1427
+ return this.request('/api/v1beta1/tgm/historical-top-holders', {
1428
+ token_address: tokenAddress,
1429
+ chain,
1430
+ as_of_date: asOfDate,
1431
+ filters,
1432
+ order_by: orderBy,
1433
+ pagination,
1434
+ });
1435
+ }
1436
+
1437
+ async researchWhoBoughtSold(params = {}) {
1438
+ const { tokenAddress, chain = 'solana', fromDate, toDate, buyOrSell = 'BUY', filters = {}, orderBy, pagination } = params;
1439
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1440
+ return this.request('/api/v1beta1/tgm/historical-who-bought-sold', {
1441
+ token_address: tokenAddress,
1442
+ chain,
1443
+ buy_or_sell: buyOrSell,
1444
+ date_range: { from: fromDate, to: toDate },
1445
+ filters,
1446
+ order_by: orderBy,
1447
+ pagination,
1448
+ });
1449
+ }
1450
+
1451
+ async researchSmartMoneyBalances(params = {}) {
1452
+ // API does not support order_by on this endpoint.
1453
+ const { chains = ['solana'], asOfDate, filters = {}, pagination } = params;
1454
+ return this.request('/api/v1beta1/smart-money/historical-token-balances', {
1455
+ chains,
1456
+ as_of_date: asOfDate,
1457
+ filters,
1458
+ pagination,
1459
+ });
1460
+ }
1461
+
1462
+ async researchTokenScreener(params = {}) {
1463
+ const { chains = ['solana'], timeframeDays, toDate, filters = {}, orderBy, pagination } = params;
1464
+ return this.request('/api/v1beta1/token-screener/historical', {
1465
+ chains,
1466
+ timeframe_days: timeframeDays,
1467
+ to_date: toDate,
1468
+ filters,
1469
+ order_by: orderBy,
1470
+ pagination,
1471
+ });
1472
+ }
1473
+
1474
+ async researchWalletBalances(params = {}) {
1475
+ const { address, chain = 'ethereum', asOfDate, filters = {}, orderBy, pagination } = params;
1476
+ if (address) requireValidAddress(address, chain);
1477
+ return this.request('/api/v1beta1/profiler/address/historical-token-balances', {
1478
+ address,
1479
+ chain,
1480
+ as_of_date: asOfDate,
1481
+ filters,
1482
+ order_by: orderBy,
1483
+ pagination,
1484
+ });
1485
+ }
1486
+
1487
+ async researchTxLookup(params = {}) {
1488
+ const { txHash, chain = 'ethereum', asOfDate, blockTimestamp } = params;
1489
+ const body = {
1490
+ transaction_hash: txHash,
1491
+ chain,
1492
+ as_of_date: asOfDate,
1493
+ };
1494
+ if (blockTimestamp) body.block_timestamp = blockTimestamp;
1495
+ return this.request('/api/v1beta1/profiler/historical-transaction-lookup', body);
1496
+ }
1497
+
1498
+ async researchWalletTransactions(params = {}) {
1499
+ const { address, chain = 'ethereum', asOfDate, filters = {}, orderBy, pagination } = params;
1500
+ if (address) requireValidAddress(address, chain);
1501
+ return this.request('/api/v1beta1/profiler/address/historical-transactions', {
1502
+ address,
1503
+ chain,
1504
+ as_of_date: asOfDate,
1505
+ filters,
1506
+ order_by: orderBy,
1507
+ pagination,
1508
+ });
1509
+ }
1510
+
1366
1511
  // ============= Smart Alert Endpoints =============
1367
1512
 
1368
1513
  async alertsList(params = {}) {
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { buildTradingCommands } from './trading.js';
9
9
  import { buildLimitOrderCommands } from './limit-order.js';
10
10
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
11
11
  import { buildAgentCommands } from './commands/agent.js';
12
+ import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js';
12
13
  import { resolveAddress, isEnsName } from './ens.js';
13
14
  import fs from 'fs';
14
15
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
@@ -1471,19 +1472,25 @@ export function buildCommands(deps = {}) {
1471
1472
  // 'research' delegates to the category handlers defined above
1472
1473
  const RESEARCH_CATEGORIES = new Set(['smart-money', 'profiler', 'token', 'search', 'perp', 'portfolio', 'points', 'prediction-market']);
1473
1474
 
1475
+ const researchHistorical = buildResearchCommands(deps).research;
1476
+
1474
1477
  cmds['research'] = async (args, apiInstance, flags, options) => {
1475
1478
  const rawCategory = args[0];
1476
1479
  if (!rawCategory || rawCategory === 'help') {
1477
1480
  return {
1478
1481
  categories: [...RESEARCH_CATEGORIES],
1482
+ historical: [...RESEARCH_HISTORICAL_SUBCOMMANDS],
1479
1483
  aliases: RESEARCH_CATEGORY_ALIASES,
1480
1484
  description: 'Research and analytics commands',
1481
1485
  example: 'nansen research smart-money netflow --chain solana'
1482
1486
  };
1483
1487
  }
1488
+ if (RESEARCH_HISTORICAL_SUBCOMMANDS.has(rawCategory)) {
1489
+ return researchHistorical(args, apiInstance, flags, options);
1490
+ }
1484
1491
  const category = RESEARCH_CATEGORY_ALIASES[rawCategory] || rawCategory;
1485
1492
  if (!RESEARCH_CATEGORIES.has(category)) {
1486
- throw new NansenError(`Unknown research category: ${rawCategory}. Available: ${[...RESEARCH_CATEGORIES].join(', ')}`, ErrorCode.UNKNOWN);
1493
+ throw new NansenError(`Unknown research category: ${rawCategory}. Available: ${[...RESEARCH_CATEGORIES, ...RESEARCH_HISTORICAL_SUBCOMMANDS].join(', ')}`, ErrorCode.UNKNOWN);
1487
1494
  }
1488
1495
  return cmds[category](args.slice(1), apiInstance, flags, options);
1489
1496
  };
@@ -1712,6 +1719,19 @@ export async function runCLI(rawArgs, deps = {}) {
1712
1719
  if (catSchema.subcommands) {
1713
1720
  lines.push('Subcommands: ' + Object.keys(catSchema.subcommands).join(', '));
1714
1721
  lines.push(`Use: nansen research ${category} <subcommand> --help`);
1722
+ } else if (catSchema.options) {
1723
+ // Leaf historical subcommand: render options + example
1724
+ const params = Object.entries(catSchema.options).map(([name, opt]) => {
1725
+ const parts = [`--${name}`];
1726
+ if (opt.required) parts[0] += '*';
1727
+ if (opt.default !== undefined) parts.push(`(${opt.default})`);
1728
+ return parts.join(' ');
1729
+ });
1730
+ lines.push(`Params (* required): ${params.join(', ')}`);
1731
+ if (catSchema.endpoint) {
1732
+ const cost = getCostForEndpoint(catSchema.endpoint);
1733
+ if (cost) lines.push(`Cost: ${cost.free} credit${cost.free === 1 ? '' : 's'} (Free tier) / ${cost.pro} credit${cost.pro === 1 ? '' : 's'} (Pro tier)`);
1734
+ }
1715
1735
  }
1716
1736
  output(lines.join('\n'));
1717
1737
  notify();
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Nansen CLI - Research command
3
+ *
4
+ * Historical/point-in-time analytics. Each subcommand resolves labels and
5
+ * metrics at the requested date rather than current state — useful for
6
+ * backtesting and historical research.
7
+ */
8
+
9
+ import { NansenError, ErrorCode } from '../api.js';
10
+
11
+ // Local copies of CLI helpers to avoid a circular import with src/cli.js.
12
+ function buildPagination(options) {
13
+ if (!options.limit && !options.page) return undefined;
14
+ return {
15
+ page: Math.max(1, parseInt(options.page, 10) || 1),
16
+ per_page: options.limit,
17
+ };
18
+ }
19
+
20
+ function parseSort(sortOption, orderByOption) {
21
+ if (orderByOption) return orderByOption;
22
+ if (!sortOption) return undefined;
23
+ const parts = String(sortOption).split(':');
24
+ const field = parts[0];
25
+ const direction = (parts[1] || 'desc').toUpperCase();
26
+ return [{ field, direction }];
27
+ }
28
+
29
+ const SUBCOMMANDS = [
30
+ 'historical-dex-trades',
31
+ 'historical-pnl-leaderboard',
32
+ 'historical-token-flow-summary',
33
+ 'historical-token-quant-scores',
34
+ 'historical-top-holders',
35
+ 'historical-who-bought-sold',
36
+ 'historical-smart-money-balances',
37
+ 'historical-token-screener',
38
+ 'historical-wallet-balances',
39
+ 'historical-tx-lookup',
40
+ 'historical-wallet-transactions',
41
+ ];
42
+
43
+ export const RESEARCH_HISTORICAL_SUBCOMMANDS = new Set(SUBCOMMANDS);
44
+
45
+ function requireOptions(options, required) {
46
+ const missing = required.filter(name => !options[name]);
47
+ if (missing.length > 0) {
48
+ throw new NansenError(
49
+ `Required: ${missing.map(n => '--' + n).join(', ')}`,
50
+ ErrorCode.MISSING_PARAM,
51
+ );
52
+ }
53
+ }
54
+
55
+ function resolveDateRange(options) {
56
+ return { fromDate: options['from-date'], toDate: options['to-date'] };
57
+ }
58
+
59
+ function parseTimeframeDays(value) {
60
+ if (value === undefined || value === null || value === '') return undefined;
61
+ const n = parseInt(value, 10);
62
+ if (Number.isNaN(n)) {
63
+ throw new NansenError('--timeframe-days must be an integer', ErrorCode.INVALID_PARAMS);
64
+ }
65
+ return n;
66
+ }
67
+
68
+ function parseChains(options) {
69
+ if (options.chains) {
70
+ return Array.isArray(options.chains)
71
+ ? options.chains
72
+ : String(options.chains).split(',').map(s => s.trim()).filter(Boolean);
73
+ }
74
+ if (options.chain) return [options.chain];
75
+ return undefined;
76
+ }
77
+
78
+ const HELP_TOP = `nansen research — Historical/point-in-time analytics
79
+
80
+ SUBCOMMANDS:
81
+ historical-dex-trades Historical DEX trades for a token
82
+ historical-pnl-leaderboard Historical PnL leaderboard for a token
83
+ historical-token-flow-summary Historical token flow summary
84
+ historical-token-quant-scores Historical token quantitative scores
85
+ historical-top-holders Historical top holders of a token
86
+ historical-who-bought-sold Historical buyers/sellers of a token
87
+ historical-smart-money-balances Historical smart money token balances
88
+ historical-token-screener Historical token screener
89
+ historical-wallet-balances Historical token balances for a wallet
90
+ historical-tx-lookup Lookup a historical transaction by hash
91
+ historical-wallet-transactions Historical transactions for a wallet
92
+
93
+ COMMON OPTIONS:
94
+ --from-date <YYYY-MM-DD> Start of date range (for range-based subcommands)
95
+ --to-date <YYYY-MM-DD> End of date range (for range-based subcommands)
96
+ --as-of-date <YYYY-MM-DD> Snapshot date (for as-of-date subcommands)
97
+ --chain <chain> Chain (default: solana for tokens, ethereum for wallets)
98
+ --page <n> --limit <n> Pagination (not supported by historical-token-flow-summary)
99
+ --sort <field[:asc|desc]> Sort order (not supported by historical-smart-money-balances)
100
+ --filters '<json>' Filters as JSON object
101
+
102
+ Run: nansen research <subcommand> --help`;
103
+
104
+ const SUB_HELP = {
105
+ 'historical-dex-trades': `nansen research historical-dex-trades — Historical DEX trades for a token
106
+
107
+ USAGE:
108
+ nansen research historical-dex-trades --token-address <addr> --from-date <YYYY-MM-DD> --to-date <YYYY-MM-DD> [--chain <chain>]`,
109
+ 'historical-pnl-leaderboard': `nansen research historical-pnl-leaderboard — Historical PnL leaderboard for a token
110
+
111
+ USAGE:
112
+ nansen research historical-pnl-leaderboard --token-address <addr> --from-date <YYYY-MM-DD> --to-date <YYYY-MM-DD> [--chain <chain>]`,
113
+ 'historical-token-flow-summary': `nansen research historical-token-flow-summary — Historical token flow summary
114
+
115
+ USAGE:
116
+ nansen research historical-token-flow-summary --token-address <addr> --from-date <YYYY-MM-DD> --to-date <YYYY-MM-DD> [--chain <chain>]
117
+
118
+ NOTE: This endpoint does not support pagination.`,
119
+ 'historical-token-quant-scores': `nansen research historical-token-quant-scores — Historical token quantitative scores
120
+
121
+ USAGE:
122
+ nansen research historical-token-quant-scores --token-address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
123
+ 'historical-top-holders': `nansen research historical-top-holders — Historical top holders of a token
124
+
125
+ USAGE:
126
+ nansen research historical-top-holders --token-address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
127
+ 'historical-who-bought-sold': `nansen research historical-who-bought-sold — Historical buyers/sellers of a token
128
+
129
+ USAGE:
130
+ nansen research historical-who-bought-sold --token-address <addr> --from-date <YYYY-MM-DD> --to-date <YYYY-MM-DD> [--buy-or-sell BUY|SELL] [--chain <chain>]`,
131
+ 'historical-smart-money-balances': `nansen research historical-smart-money-balances — Historical smart money token balances
132
+
133
+ USAGE:
134
+ nansen research historical-smart-money-balances --as-of-date <YYYY-MM-DD> [--chains c1,c2]
135
+
136
+ NOTE: This endpoint does not support order_by.`,
137
+ 'historical-token-screener': `nansen research historical-token-screener — Historical token screener
138
+
139
+ USAGE:
140
+ nansen research historical-token-screener --timeframe-days <n> --to-date <YYYY-MM-DD> [--chains c1,c2]`,
141
+ 'historical-wallet-balances': `nansen research historical-wallet-balances — Historical token balances for a wallet
142
+
143
+ USAGE:
144
+ nansen research historical-wallet-balances --address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
145
+ 'historical-tx-lookup': `nansen research historical-tx-lookup — Lookup a historical transaction by hash
146
+
147
+ USAGE:
148
+ nansen research historical-tx-lookup --transaction-hash <hash> --as-of-date <YYYY-MM-DD> [--chain <chain>] [--block-timestamp "YYYY-MM-DD HH:MM:SS"]
149
+
150
+ NOTE: Providing --block-timestamp skips a slow hash-resolution step and returns results much faster.`,
151
+ 'historical-wallet-transactions': `nansen research historical-wallet-transactions — Historical transactions for a wallet
152
+
153
+ USAGE:
154
+ nansen research historical-wallet-transactions --address <addr> --as-of-date <YYYY-MM-DD> [--chain <chain>]`,
155
+ };
156
+
157
+ export function buildResearchCommands(deps = {}) {
158
+ const { log = console.log } = deps;
159
+
160
+ return {
161
+ 'research': async (args, apiInstance, flags, options) => {
162
+ const sub = args[0];
163
+
164
+ if (!sub || sub === 'help') {
165
+ log(HELP_TOP);
166
+ return;
167
+ }
168
+
169
+ if (!RESEARCH_HISTORICAL_SUBCOMMANDS.has(sub)) {
170
+ throw new NansenError(
171
+ `Unknown research subcommand: ${sub}. Available: ${SUBCOMMANDS.join(', ')}`,
172
+ ErrorCode.UNKNOWN,
173
+ );
174
+ }
175
+
176
+ if (flags.help || flags.h || args[1] === 'help') {
177
+ log(SUB_HELP[sub] || HELP_TOP);
178
+ return;
179
+ }
180
+
181
+ const orderBy = parseSort(options.sort, options['order-by']);
182
+ const pagination = buildPagination(options);
183
+ const filters = options.filters || {};
184
+ const { fromDate, toDate } = resolveDateRange(options);
185
+ const asOfDate = options['as-of-date'];
186
+
187
+ // Range-based token endpoints (require --from-date + --to-date)
188
+ const rangeTokenHandlers = {
189
+ 'historical-dex-trades': () => apiInstance.researchDexTrades({
190
+ tokenAddress: options['token-address'] || options.token,
191
+ chain: options.chain,
192
+ fromDate, toDate, filters, orderBy, pagination,
193
+ }),
194
+ 'historical-pnl-leaderboard': () => apiInstance.researchPnlLeaderboard({
195
+ tokenAddress: options['token-address'] || options.token,
196
+ chain: options.chain,
197
+ fromDate, toDate, filters, orderBy, pagination,
198
+ }),
199
+ 'historical-token-flow-summary': () => apiInstance.researchTokenFlowSummary({
200
+ tokenAddress: options['token-address'] || options.token,
201
+ chain: options.chain,
202
+ fromDate, toDate, filters, orderBy,
203
+ }),
204
+ 'historical-who-bought-sold': () => apiInstance.researchWhoBoughtSold({
205
+ tokenAddress: options['token-address'] || options.token,
206
+ chain: options.chain,
207
+ buyOrSell: options['buy-or-sell'],
208
+ fromDate, toDate, filters, orderBy, pagination,
209
+ }),
210
+ };
211
+
212
+ if (rangeTokenHandlers[sub]) {
213
+ requireOptions(
214
+ { 'token-address': options['token-address'] || options.token, 'from-date': fromDate, 'to-date': toDate },
215
+ ['token-address', 'from-date', 'to-date'],
216
+ );
217
+ if (sub === 'historical-token-flow-summary' && (options.page || options.limit)) {
218
+ throw new NansenError(
219
+ 'historical-token-flow-summary does not support --page or --limit (endpoint returns a single aggregated row)',
220
+ ErrorCode.INVALID_PARAMS,
221
+ );
222
+ }
223
+ return rangeTokenHandlers[sub]();
224
+ }
225
+
226
+ // As-of-date token endpoints (require --as-of-date)
227
+ const asOfTokenHandlers = {
228
+ 'historical-token-quant-scores': () => apiInstance.researchTokenQuantScores({
229
+ tokenAddress: options['token-address'] || options.token,
230
+ chain: options.chain,
231
+ asOfDate, filters, orderBy, pagination,
232
+ }),
233
+ 'historical-top-holders': () => apiInstance.researchTopHolders({
234
+ tokenAddress: options['token-address'] || options.token,
235
+ chain: options.chain,
236
+ asOfDate, filters, orderBy, pagination,
237
+ }),
238
+ };
239
+
240
+ if (asOfTokenHandlers[sub]) {
241
+ requireOptions(
242
+ { 'token-address': options['token-address'] || options.token, 'as-of-date': asOfDate },
243
+ ['token-address', 'as-of-date'],
244
+ );
245
+ return asOfTokenHandlers[sub]();
246
+ }
247
+
248
+ if (sub === 'historical-smart-money-balances') {
249
+ if (options.sort || options['order-by']) {
250
+ throw new NansenError(
251
+ 'historical-smart-money-balances does not support --sort or --order-by (endpoint does not support ordering)',
252
+ ErrorCode.INVALID_PARAMS,
253
+ );
254
+ }
255
+ requireOptions({ 'as-of-date': asOfDate }, ['as-of-date']);
256
+ return apiInstance.researchSmartMoneyBalances({
257
+ chains: parseChains(options),
258
+ asOfDate, filters, pagination,
259
+ });
260
+ }
261
+
262
+ if (sub === 'historical-token-screener') {
263
+ const timeframeDays = parseTimeframeDays(options['timeframe-days']);
264
+ requireOptions({ 'timeframe-days': timeframeDays, 'to-date': options['to-date'] }, ['timeframe-days', 'to-date']);
265
+ return apiInstance.researchTokenScreener({
266
+ chains: parseChains(options),
267
+ timeframeDays,
268
+ toDate: options['to-date'],
269
+ filters, orderBy, pagination,
270
+ });
271
+ }
272
+
273
+ if (sub === 'historical-wallet-balances') {
274
+ requireOptions(
275
+ { address: options.address, 'as-of-date': asOfDate },
276
+ ['address', 'as-of-date'],
277
+ );
278
+ return apiInstance.researchWalletBalances({
279
+ address: options.address,
280
+ chain: options.chain,
281
+ asOfDate, filters, orderBy, pagination,
282
+ });
283
+ }
284
+
285
+ if (sub === 'historical-tx-lookup') {
286
+ requireOptions(
287
+ { 'transaction-hash': options['transaction-hash'], 'as-of-date': asOfDate },
288
+ ['transaction-hash', 'as-of-date'],
289
+ );
290
+ return apiInstance.researchTxLookup({
291
+ txHash: options['transaction-hash'],
292
+ chain: options.chain,
293
+ asOfDate,
294
+ blockTimestamp: options['block-timestamp'],
295
+ });
296
+ }
297
+
298
+ if (sub === 'historical-wallet-transactions') {
299
+ requireOptions(
300
+ { address: options.address, 'as-of-date': asOfDate },
301
+ ['address', 'as-of-date'],
302
+ );
303
+ return apiInstance.researchWalletTransactions({
304
+ address: options.address,
305
+ chain: options.chain,
306
+ asOfDate, filters, orderBy, pagination,
307
+ });
308
+ }
309
+
310
+ // Should be unreachable because SUBCOMMANDS guarded above.
311
+ throw new NansenError(`Unknown research subcommand: ${sub}`, ErrorCode.UNKNOWN);
312
+ },
313
+ };
314
+ }
package/src/schema.json CHANGED
@@ -697,6 +697,110 @@
697
697
  "description": "Points leaderboard"
698
698
  }
699
699
  }
700
+ },
701
+ "historical-dex-trades": {
702
+ "endpoint": "/api/v1beta1/tgm/historical-dex-trades",
703
+ "description": "Historical DEX trades for a token at a point in time",
704
+ "options": {
705
+ "token-address": { "required": true, "description": "Token address" },
706
+ "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
707
+ "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
708
+ "chain": { "default": "solana", "description": "Chain" }
709
+ }
710
+ },
711
+ "historical-pnl-leaderboard": {
712
+ "endpoint": "/api/v1beta1/tgm/historical-pnl-leaderboard",
713
+ "description": "Historical PnL leaderboard for a token",
714
+ "options": {
715
+ "token-address": { "required": true, "description": "Token address" },
716
+ "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
717
+ "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
718
+ "chain": { "default": "solana", "description": "Chain" }
719
+ }
720
+ },
721
+ "historical-token-flow-summary": {
722
+ "endpoint": "/api/v1beta1/tgm/historical-token-flow-summary",
723
+ "description": "Historical token flow summary (no pagination)",
724
+ "options": {
725
+ "token-address": { "required": true, "description": "Token address" },
726
+ "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
727
+ "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
728
+ "chain": { "default": "solana", "description": "Chain" }
729
+ }
730
+ },
731
+ "historical-token-quant-scores": {
732
+ "endpoint": "/api/v1beta1/tgm/historical-token-quant-scores",
733
+ "description": "Historical token quantitative scores at a snapshot date",
734
+ "options": {
735
+ "token-address": { "required": true, "description": "Token address" },
736
+ "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
737
+ "chain": { "default": "solana", "description": "Chain" }
738
+ }
739
+ },
740
+ "historical-top-holders": {
741
+ "endpoint": "/api/v1beta1/tgm/historical-top-holders",
742
+ "description": "Historical top holders of a token at a snapshot date",
743
+ "options": {
744
+ "token-address": { "required": true, "description": "Token address" },
745
+ "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
746
+ "chain": { "default": "solana", "description": "Chain" }
747
+ }
748
+ },
749
+ "historical-who-bought-sold": {
750
+ "endpoint": "/api/v1beta1/tgm/historical-who-bought-sold",
751
+ "description": "Historical buyers/sellers of a token",
752
+ "options": {
753
+ "token-address": { "required": true, "description": "Token address" },
754
+ "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
755
+ "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
756
+ "buy-or-sell": { "default": "BUY", "description": "BUY or SELL" },
757
+ "chain": { "default": "solana", "description": "Chain" }
758
+ }
759
+ },
760
+ "historical-smart-money-balances": {
761
+ "endpoint": "/api/v1beta1/smart-money/historical-token-balances",
762
+ "description": "Historical smart money token balances at a snapshot date (no order_by)",
763
+ "options": {
764
+ "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
765
+ "chains": { "default": "solana", "description": "Comma-separated chains" }
766
+ }
767
+ },
768
+ "historical-token-screener": {
769
+ "endpoint": "/api/v1beta1/token-screener/historical",
770
+ "description": "Historical token screener over a trailing window",
771
+ "options": {
772
+ "timeframe-days": { "required": true, "type": "number", "description": "Trailing window size in days" },
773
+ "to-date": { "required": true, "description": "End date for the window (YYYY-MM-DD)" },
774
+ "chains": { "default": "solana", "description": "Comma-separated chains" }
775
+ }
776
+ },
777
+ "historical-wallet-balances": {
778
+ "endpoint": "/api/v1beta1/profiler/address/historical-token-balances",
779
+ "description": "Historical token balances for a wallet at a snapshot date",
780
+ "options": {
781
+ "address": { "required": true, "description": "Wallet address" },
782
+ "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
783
+ "chain": { "default": "ethereum", "description": "Chain" }
784
+ }
785
+ },
786
+ "historical-tx-lookup": {
787
+ "endpoint": "/api/v1beta1/profiler/historical-transaction-lookup",
788
+ "description": "Lookup a historical transaction by hash",
789
+ "options": {
790
+ "transaction-hash": { "required": true, "description": "Transaction hash (0x-prefixed, 66 chars)" },
791
+ "as-of-date": { "required": true, "description": "Reference date for label and pricing resolution (YYYY-MM-DD)" },
792
+ "block-timestamp": { "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) — skips slow hash-resolution step if provided" },
793
+ "chain": { "default": "ethereum", "description": "Chain (ethereum, bnb, base)" }
794
+ }
795
+ },
796
+ "historical-wallet-transactions": {
797
+ "endpoint": "/api/v1beta1/profiler/address/historical-transactions",
798
+ "description": "Historical transactions for a wallet at a snapshot date",
799
+ "options": {
800
+ "address": { "required": true, "description": "Wallet address" },
801
+ "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
802
+ "chain": { "default": "ethereum", "description": "Chain" }
803
+ }
700
804
  }
701
805
  }
702
806
  },
@@ -136,6 +136,10 @@ const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
136
136
  const HIGH_PERCENTAGE_THRESHOLD = 95;
137
137
  const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
138
138
 
139
+ // Trades at or above this USD value can use gasless/solver-paid routes (e.g. Relay),
140
+ // so the native gas pre-check is skipped for them.
141
+ export const GASLESS_MIN_TRADE_USD = 10;
142
+
139
143
  /**
140
144
  * Check if an address is USDC or the native token for a chain (case-insensitive for EVM).
141
145
  */
@@ -306,12 +310,19 @@ export async function resolvePercentAmount({ chain, from, walletAddress, percent
306
310
  *
307
311
  * Returns { hasSufficientNative } or throws on validation failure.
308
312
  * Best-effort: if RPC fails, returns passing result.
313
+ *
314
+ * Gasless bypass: trades >= $10 USD can use solver-paid options (e.g. Relay),
315
+ * so the gas check is skipped in that case.
309
316
  */
310
- export async function validateGasBalance({ chain, walletAddress }) {
317
+ export async function validateGasBalance({ chain, walletAddress, tradeValueUsd }) {
311
318
  const normalizedChain = chain.toLowerCase();
312
319
  const minGas = MIN_GAS_AMOUNTS[normalizedChain];
313
320
  if (minGas === undefined) return { hasSufficientNative: true };
314
321
 
322
+ // High-value trades can use gasless/solver-paid routes — skip the check.
323
+ const tradeUsd = parseFloat(tradeValueUsd) || 0;
324
+ if (tradeUsd >= GASLESS_MIN_TRADE_USD) return { hasSufficientNative: true };
325
+
315
326
  const balance = await fetchNativeBalance(normalizedChain, walletAddress);
316
327
 
317
328
  // RPC failure — proceed without validation.
@@ -323,7 +334,7 @@ export async function validateGasBalance({ chain, walletAddress }) {
323
334
 
324
335
  const symbol = NATIVE_SYMBOLS[normalizedChain] || 'native token';
325
336
  throw new Error(
326
- `Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Fund the wallet before trading.`
337
+ `Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Either fund the wallet with ${symbol} or trade a value of $${GASLESS_MIN_TRADE_USD}+ to use gasless options.`
327
338
  );
328
339
  }
329
340
 
package/src/trading.js CHANGED
@@ -159,6 +159,7 @@ export async function getQuote(params) {
159
159
  * @param {object} params
160
160
  * @param {string} params.signedTransaction - Base64 (Solana) or 0x hex (EVM)
161
161
  * @param {string} [params.chain] - Target chain name
162
+ * @param {string} [params.quoteId] - Backend quote ID for BI correlation
162
163
  * @param {string} [params.requestId] - Optional Jupiter request ID (Solana only)
163
164
  * @param {boolean} [params.simulate] - Run pre-broadcast simulation
164
165
  * @returns {Promise<object>} Execution result
@@ -1330,8 +1331,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
1330
1331
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1331
1332
 
1332
1333
  // Gas balance validation — check that the wallet has enough native token for gas.
1334
+ // High-value trades (>= $10) can use gasless/solver-paid routes and bypass this check.
1333
1335
  try {
1334
- await validateGasBalance({ chain, walletAddress });
1336
+ const tradeValueUsd = response.quotes[0]?.inUsdValue;
1337
+ await validateGasBalance({ chain, walletAddress, tradeValueUsd });
1335
1338
  } catch (gasErr) {
1336
1339
  throw new CommandError(`Error: ${gasErr.message}`, 'INSUFFICIENT_GAS');
1337
1340
  }
@@ -2018,16 +2021,19 @@ EXAMPLES:
2018
2021
  simulate: !noSimulate && !gasless,
2019
2022
  };
2020
2023
 
2021
- // Include backend quoteId if available for better BI tracking
2022
- if (currentQuote.metadata?.quoteId) {
2023
- execParams.quoteId = currentQuote.metadata.quoteId;
2024
+ // Prefer the backend quote id saved from /quote; per-aggregator ids can
2025
+ // appear on individual quote metadata and are only a fallback.
2026
+ const backendQuoteId =
2027
+ quoteData.response?.metadata?.quoteId ?? currentQuote.metadata?.quoteId;
2028
+ if (backendQuoteId) {
2029
+ execParams.quoteId = backendQuoteId;
2024
2030
  }
2025
2031
  // The backend's /execute schema is strict; sending fields it doesn't expect
2026
2032
  // for the (chain × aggregator × gasless) combination causes 502s or
2027
2033
  // "Unrecognized keys" rejections. The matrix we've validated against the
2028
2034
  // live backend:
2029
- // - EVM signed (any aggregator): no extra fields. requestId/aggregator
2030
- // trigger schema errors.
2035
+ // - EVM signed (any aggregator): no aggregator/requestId fields.
2036
+ // Those trigger schema errors.
2031
2037
  // - Solana signed (Jupiter/OKX): include requestId for Jupiter Ultra
2032
2038
  // intent resolution.
2033
2039
  // - Solana signed (Relay): omit requestId — backend tries to look it up