nansen-cli 1.30.1 → 1.31.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#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)
12
+
13
+ - [#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.
14
+
15
+ ## 1.30.2
16
+
17
+ ### Patch Changes
18
+
19
+ - [#431](https://github.com/nansen-ai/nansen-cli/pull/431) [`c2c033b`](https://github.com/nansen-ai/nansen-cli/commit/c2c033b86f9dab59df9497ce971ef6f267ee3669) Thanks [@MarcLlopart](https://github.com/MarcLlopart)! - Pass backend quoteId in execute requests for BI correlation
20
+
3
21
  ## 1.30.1
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.1",
3
+ "version": "1.31.0",
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
@@ -336,6 +336,22 @@ export function validateTokenAddress(tokenAddress, chain = 'solana') {
336
336
  return validateAddress(tokenAddress, chain);
337
337
  }
338
338
 
339
+ /**
340
+ * Throw if address is present but invalid.
341
+ */
342
+ function requireValidAddress(address, chain) {
343
+ const v = validateAddress(address, chain);
344
+ if (!v.valid) throw new NansenError(v.error, v.code);
345
+ }
346
+
347
+ /**
348
+ * Throw if token address is present but invalid.
349
+ */
350
+ function requireValidToken(tokenAddress, chain) {
351
+ const v = validateTokenAddress(tokenAddress, chain);
352
+ if (!v.valid) throw new NansenError(v.error, v.code);
353
+ }
354
+
339
355
  function loadConfig() {
340
356
  // Base config from files, then env vars override individual fields
341
357
  let config = null;
@@ -791,10 +807,7 @@ export class NansenAPI {
791
807
 
792
808
  async addressBalance(params = {}) {
793
809
  const { address, entityName, chain = 'all', hideSpamToken = true, filters = {}, orderBy } = params;
794
- if (address) {
795
- const validation = validateAddress(address, chain);
796
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
797
- }
810
+ if (address) requireValidAddress(address, chain);
798
811
  return this.request('/api/v1/profiler/address/current-balance', {
799
812
  address,
800
813
  entity_name: entityName,
@@ -807,10 +820,7 @@ export class NansenAPI {
807
820
 
808
821
  async addressLabels(params = {}) {
809
822
  const { address, chain = 'ethereum', pagination = { page: 1, per_page: 100 } } = params;
810
- if (address) {
811
- const validation = validateAddress(address, chain);
812
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
813
- }
823
+ if (address) requireValidAddress(address, chain);
814
824
  return this.request('/api/beta/profiler/address/labels', {
815
825
  parameters: { address, chain },
816
826
  pagination
@@ -819,10 +829,7 @@ export class NansenAPI {
819
829
 
820
830
  async addressTransactions(params = {}) {
821
831
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30, date } = params;
822
- if (address) {
823
- const validation = validateAddress(address, chain);
824
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
825
- }
832
+ if (address) requireValidAddress(address, chain);
826
833
  const dateRange = date || buildDateRange(days);
827
834
  return this.request('/api/v1/profiler/address/transactions', {
828
835
  address,
@@ -836,10 +843,7 @@ export class NansenAPI {
836
843
 
837
844
  async addressPnl(params = {}) {
838
845
  const { address, chain = 'ethereum', date, days = 30, filters = {}, orderBy, pagination } = params;
839
- if (address) {
840
- const validation = validateAddress(address, chain);
841
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
842
- }
846
+ if (address) requireValidAddress(address, chain);
843
847
  const dateRange = date || buildDateRange(days);
844
848
  return this.request('/api/v1/profiler/address/pnl', {
845
849
  address,
@@ -899,10 +903,7 @@ export class NansenAPI {
899
903
 
900
904
  async addressHistoricalBalances(params = {}) {
901
905
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
902
- if (address) {
903
- const validation = validateAddress(address, chain);
904
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
905
- }
906
+ if (address) requireValidAddress(address, chain);
906
907
  return this.request('/api/v1/profiler/address/historical-balances', {
907
908
  address,
908
909
  chain,
@@ -915,10 +916,7 @@ export class NansenAPI {
915
916
 
916
917
  async addressRelatedWallets(params = {}) {
917
918
  const { address, chain = 'ethereum', orderBy, pagination } = params;
918
- if (address) {
919
- const validation = validateAddress(address, chain);
920
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
921
- }
919
+ if (address) requireValidAddress(address, chain);
922
920
  return this.request('/api/v1/profiler/address/related-wallets', {
923
921
  address,
924
922
  chain,
@@ -929,10 +927,7 @@ export class NansenAPI {
929
927
 
930
928
  async addressCounterparties(params = {}) {
931
929
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
932
- if (address) {
933
- const validation = validateAddress(address, chain);
934
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
935
- }
930
+ if (address) requireValidAddress(address, chain);
936
931
  return this.request('/api/v1/profiler/address/counterparties', {
937
932
  address,
938
933
  chain,
@@ -947,10 +942,7 @@ export class NansenAPI {
947
942
  // Note: pnl-summary endpoint is non-paginated (returns aggregate stats, not a list).
948
943
  // Pagination param intentionally omitted from this request.
949
944
  const { address, chain = 'ethereum', orderBy, days = 30 } = params;
950
- if (address) {
951
- const validation = validateAddress(address, chain);
952
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
953
- }
945
+ if (address) requireValidAddress(address, chain);
954
946
  return this.request('/api/v1/profiler/address/pnl-summary', {
955
947
  address,
956
948
  chain,
@@ -996,10 +988,7 @@ export class NansenAPI {
996
988
 
997
989
  async tokenHolders(params = {}) {
998
990
  const { tokenAddress, chain = 'solana', labelType = 'all_holders', filters = {}, orderBy, pagination, withLabels } = params;
999
- if (tokenAddress) {
1000
- const validation = validateTokenAddress(tokenAddress, chain);
1001
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1002
- }
991
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1003
992
  const body = {
1004
993
  token_address: tokenAddress,
1005
994
  chain,
@@ -1014,10 +1003,7 @@ export class NansenAPI {
1014
1003
 
1015
1004
  async tokenFlows(params = {}) {
1016
1005
  const { tokenAddress, chain = 'solana', label, filters = {}, orderBy, pagination, days = 30, date } = params;
1017
- if (tokenAddress) {
1018
- const validation = validateTokenAddress(tokenAddress, chain);
1019
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1020
- }
1006
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1021
1007
  const dateRange = date || buildDateRange(days);
1022
1008
  return this.request('/api/v1/tgm/flows', {
1023
1009
  token_address: tokenAddress,
@@ -1032,10 +1018,7 @@ export class NansenAPI {
1032
1018
 
1033
1019
  async tokenDexTrades(params = {}) {
1034
1020
  const { tokenAddress, chain = 'solana', onlySmartMoney = false, filters = {}, orderBy, pagination, days = 7 } = params;
1035
- if (tokenAddress) {
1036
- const validation = validateTokenAddress(tokenAddress, chain);
1037
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1038
- }
1021
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1039
1022
  // Apply smart money filter via filters object
1040
1023
  if (onlySmartMoney) {
1041
1024
  filters.include_smart_money_labels = filters.include_smart_money_labels ||
@@ -1054,10 +1037,7 @@ export class NansenAPI {
1054
1037
 
1055
1038
  async tokenPnlLeaderboard(params = {}) {
1056
1039
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 30, withLabels } = params;
1057
- if (tokenAddress) {
1058
- const validation = validateTokenAddress(tokenAddress, chain);
1059
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1060
- }
1040
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1061
1041
  const body = {
1062
1042
  token_address: tokenAddress,
1063
1043
  chain,
@@ -1072,10 +1052,7 @@ export class NansenAPI {
1072
1052
 
1073
1053
  async tokenWhoBoughtSold(params = {}) {
1074
1054
  const { tokenAddress, chain = 'solana', buyOrSell = 'BUY', filters = {}, orderBy, pagination, days = 30, date } = params;
1075
- if (tokenAddress) {
1076
- const validation = validateTokenAddress(tokenAddress, chain);
1077
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1078
- }
1055
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1079
1056
  const dateRange = date || buildDateRange(days);
1080
1057
  return this.request('/api/v1/tgm/who-bought-sold', {
1081
1058
  token_address: tokenAddress,
@@ -1090,10 +1067,7 @@ export class NansenAPI {
1090
1067
 
1091
1068
  async tokenFlowIntelligence(params = {}) {
1092
1069
  const { tokenAddress, chain = 'solana', timeframe = '1d' } = params;
1093
- if (tokenAddress) {
1094
- const validation = validateTokenAddress(tokenAddress, chain);
1095
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1096
- }
1070
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1097
1071
  return this.request('/api/v1/tgm/flow-intelligence', {
1098
1072
  token_address: tokenAddress,
1099
1073
  chain,
@@ -1103,10 +1077,7 @@ export class NansenAPI {
1103
1077
 
1104
1078
  async tokenTransfers(params = {}) {
1105
1079
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 7 } = params;
1106
- if (tokenAddress) {
1107
- const validation = validateTokenAddress(tokenAddress, chain);
1108
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1109
- }
1080
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1110
1081
  return this.request('/api/v1/tgm/transfers', {
1111
1082
  token_address: tokenAddress,
1112
1083
  chain,
@@ -1120,10 +1091,7 @@ export class NansenAPI {
1120
1091
  async tokenJupDca(params = {}) {
1121
1092
  const { tokenAddress, filters = {}, orderBy, pagination } = params;
1122
1093
  // JUP DCA is Solana-only
1123
- if (tokenAddress) {
1124
- const validation = validateTokenAddress(tokenAddress, 'solana');
1125
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1126
- }
1094
+ if (tokenAddress) requireValidToken(tokenAddress, 'solana');
1127
1095
  return this.request('/api/v1/tgm/jup-dca', {
1128
1096
  token_address: tokenAddress,
1129
1097
  filters,
@@ -1168,10 +1136,7 @@ export class NansenAPI {
1168
1136
 
1169
1137
  async tokenIndicators(params = {}) {
1170
1138
  const { tokenAddress, chain = 'ethereum' } = params;
1171
- if (tokenAddress) {
1172
- const validation = validateTokenAddress(tokenAddress, chain);
1173
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1174
- }
1139
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1175
1140
  return this.request('/api/v1/tgm/indicators', {
1176
1141
  token_address: tokenAddress,
1177
1142
  chain
@@ -1180,10 +1145,7 @@ export class NansenAPI {
1180
1145
 
1181
1146
  async tokenOhlcv(params = {}) {
1182
1147
  const { tokenAddress, chain = 'solana', timeframe } = params;
1183
- if (tokenAddress) {
1184
- const validation = validateTokenAddress(tokenAddress, chain);
1185
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1186
- }
1148
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1187
1149
  return this.request('/api/v1/tgm/token-ohlcv', {
1188
1150
  token_address: tokenAddress,
1189
1151
  chain,
@@ -1193,10 +1155,7 @@ export class NansenAPI {
1193
1155
 
1194
1156
  async tokenInformation(params = {}) {
1195
1157
  const { tokenAddress, chain = 'solana', timeframe = '1d' } = params;
1196
- if (tokenAddress) {
1197
- const validation = validateTokenAddress(tokenAddress, chain);
1198
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1199
- }
1158
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1200
1159
  return this.request('/api/v1/tgm/token-information', {
1201
1160
  token_address: tokenAddress,
1202
1161
  chain,
@@ -1279,8 +1238,7 @@ export class NansenAPI {
1279
1238
  async pmTradesByAddress(params = {}) {
1280
1239
  const { address, orderBy, pagination } = params;
1281
1240
  // Polymarket runs exclusively on Polygon
1282
- const validation = validateAddress(address, 'polygon');
1283
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1241
+ requireValidAddress(address, 'polygon');
1284
1242
  return this.request('/api/v1/prediction-market/trades-by-address', {
1285
1243
  address,
1286
1244
  order_by: orderBy,
@@ -1351,8 +1309,7 @@ export class NansenAPI {
1351
1309
  async pmPnlByAddress(params = {}) {
1352
1310
  const { address, orderBy, pagination } = params;
1353
1311
  // Polymarket runs exclusively on Polygon
1354
- const validation = validateAddress(address, 'polygon');
1355
- if (!validation.valid) throw new NansenError(validation.error, validation.code);
1312
+ requireValidAddress(address, 'polygon');
1356
1313
  return this.request('/api/v1/prediction-market/pnl-by-address', {
1357
1314
  address,
1358
1315
  order_by: orderBy,
@@ -1406,6 +1363,151 @@ export class NansenAPI {
1406
1363
  });
1407
1364
  }
1408
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
+
1409
1511
  // ============= Smart Alert Endpoints =============
1410
1512
 
1411
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
  },
package/src/trading.js CHANGED
@@ -104,6 +104,14 @@ function getQuotesDir() {
104
104
  return path.join(configDir, 'quotes');
105
105
  }
106
106
 
107
+ // Resolve a filename inside the quotes dir, rejecting path traversal.
108
+ function safeQuotesPath(filename) {
109
+ const base = path.resolve(getQuotesDir());
110
+ const target = path.resolve(base, filename);
111
+ if (path.relative(base, target).startsWith('..')) return null;
112
+ return target;
113
+ }
114
+
107
115
  // ============= Trading API Client =============
108
116
 
109
117
  /**
@@ -151,6 +159,7 @@ export async function getQuote(params) {
151
159
  * @param {object} params
152
160
  * @param {string} params.signedTransaction - Base64 (Solana) or 0x hex (EVM)
153
161
  * @param {string} [params.chain] - Target chain name
162
+ * @param {string} [params.quoteId] - Backend quote ID for BI correlation
154
163
  * @param {string} [params.requestId] - Optional Jupiter request ID (Solana only)
155
164
  * @param {boolean} [params.simulate] - Run pre-broadcast simulation
156
165
  * @returns {Promise<object>} Execution result
@@ -337,10 +346,12 @@ const TX_RECORD_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
337
346
  */
338
347
  export function saveTxRecord(txHash, { aggregator, requestId, fromChain, toChain }) {
339
348
  if (!txHash) return;
340
- const dir = getQuotesDir();
349
+ const filePath = safeQuotesPath(`tx-${txHash}.json`);
350
+ if (!filePath) return;
351
+ const dir = path.dirname(filePath);
341
352
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
342
353
  const data = { txHash, aggregator, requestId, fromChain, toChain, timestamp: Date.now() };
343
- fs.writeFileSync(path.join(dir, `tx-${txHash}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
354
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
344
355
  }
345
356
 
346
357
  /**
@@ -348,7 +359,8 @@ export function saveTxRecord(txHash, { aggregator, requestId, fromChain, toChain
348
359
  */
349
360
  export function loadTxRecord(txHash) {
350
361
  if (!txHash) return null;
351
- const filePath = path.join(getQuotesDir(), `tx-${txHash}.json`);
362
+ const filePath = safeQuotesPath(`tx-${txHash}.json`);
363
+ if (!filePath) return null;
352
364
  if (!fs.existsSync(filePath)) return null;
353
365
  try {
354
366
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -391,8 +403,8 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
391
403
  * Load a saved quote by ID.
392
404
  */
393
405
  export function loadQuote(quoteId) {
394
- const filePath = path.join(getQuotesDir(), `${quoteId}.json`);
395
- if (!fs.existsSync(filePath)) {
406
+ const filePath = safeQuotesPath(`${quoteId}.json`);
407
+ if (!filePath || !fs.existsSync(filePath)) {
396
408
  throw new Error(`Quote "${quoteId}" not found. Quotes expire after 1 hour.`);
397
409
  }
398
410
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -2006,12 +2018,20 @@ EXAMPLES:
2006
2018
  chain,
2007
2019
  simulate: !noSimulate && !gasless,
2008
2020
  };
2021
+
2022
+ // Prefer the backend quote id saved from /quote; per-aggregator ids can
2023
+ // appear on individual quote metadata and are only a fallback.
2024
+ const backendQuoteId =
2025
+ quoteData.response?.metadata?.quoteId ?? currentQuote.metadata?.quoteId;
2026
+ if (backendQuoteId) {
2027
+ execParams.quoteId = backendQuoteId;
2028
+ }
2009
2029
  // The backend's /execute schema is strict; sending fields it doesn't expect
2010
2030
  // for the (chain × aggregator × gasless) combination causes 502s or
2011
2031
  // "Unrecognized keys" rejections. The matrix we've validated against the
2012
2032
  // live backend:
2013
- // - EVM signed (any aggregator): no extra fields. requestId/aggregator
2014
- // trigger schema errors.
2033
+ // - EVM signed (any aggregator): no aggregator/requestId fields.
2034
+ // Those trigger schema errors.
2015
2035
  // - Solana signed (Jupiter/OKX): include requestId for Jupiter Ultra
2016
2036
  // intent resolution.
2017
2037
  // - Solana signed (Relay): omit requestId — backend tries to look it up