nansen-cli 1.32.1 → 1.34.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#459](https://github.com/nansen-ai/nansen-cli/pull/459) [`37e6725`](https://github.com/nansen-ai/nansen-cli/commit/37e6725aeeb3b83eb29c4650908b8dbb522ed316) Thanks [@dependabot](https://github.com/apps/dependabot)! - Drop support for Node.js 18 (EOL since April 2025). The minimum supported version is now Node.js 20, matching our test toolchain (vitest 4.x requires Node 20+).
8
+
9
+ - [#460](https://github.com/nansen-ai/nansen-cli/pull/460) [`aac4bbe`](https://github.com/nansen-ai/nansen-cli/commit/aac4bbe18312edb48c91df60ab555f9c1d8334ce) Thanks [@gulshngill](https://github.com/gulshngill)! - Add trader_type, sectors_filter, sm_label_filter, and trader_label_filter filters to `nansen research perp screener` (ECINT-6680).
10
+
11
+ New CLI options:
12
+
13
+ - `--trader-type <type>` — filter by trader type: all, sm, whale, public_figure, high_winrate_hl_perps_trader
14
+ - `--sectors-filter <sectors>` — comma-separated sector:subcategory pairs, e.g. "Crypto:AI,TradFi:Stocks"
15
+ - `--sm-label-filter <labels>` — comma-separated Nansen SM labels (applies when trader-type is all or sm)
16
+ - `--trader-label-filter <labels>` — comma-separated HL perps trader labels (applies when trader-type is all or sm)
17
+
18
+ ## 1.33.0
19
+
20
+ ### Minor Changes
21
+
22
+ - [#457](https://github.com/nansen-ai/nansen-cli/pull/457) [`8149564`](https://github.com/nansen-ai/nansen-cli/commit/8149564f181dc7bfc9c66488ba2373df6f1aab5d) Thanks [@gulshngill](https://github.com/gulshngill)! - x402 on BNB Smart Chain: support all four stablecoins the API now advertises (U, USD1, USDT, USDC) and add Permit2 payment signing. Payments route on the 402's `extra.assetTransferMethod` — `eip3009` keeps the existing gasless flow (U, USD1), while `permit2-exact` (USDT, USDC on BSC) signs a Permit2 `PermitWitnessTransferFrom` against the spender contract advertised in the 402. Permit2 entries are skipped with an actionable message when the wallet hasn't made the one-time `approve(Permit2, …)` for the token. Post-payment balance warnings now check the exact token paid with (per-token decimals) instead of one hardcoded token per network.
23
+
3
24
  ## 1.32.1
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.32.1",
3
+ "version": "1.34.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",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "homepage": "https://github.com/nansen-ai/nansen-cli#readme",
61
61
  "engines": {
62
- "node": ">=18.0.0"
62
+ "node": ">=20.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@changesets/changelog-github": "^0.5.1",
@@ -30,3 +30,62 @@ nansen research perp leaderboard --days 7 --limit 20
30
30
  nansen research smart-money perp-trades --limit 20
31
31
  # → token_symbol, side, action (Open/Close), value_usd, price_usd, trader_address_label
32
32
  ```
33
+
34
+ ## New Filters (ECINT-6680)
35
+
36
+ ### `--trader-type`
37
+ Filter by trader type. Accepted values: `all` (default), `sm`, `whale`, `public_figure`, `high_winrate_hl_perps_trader`.
38
+
39
+ ```bash
40
+ # Show only whale traders
41
+ nansen research perp screener --trader-type whale --limit 10
42
+
43
+ # Show only smart money traders
44
+ nansen research perp screener --trader-type sm --limit 20
45
+
46
+ # Show high win-rate HL perps traders
47
+ nansen research perp screener --trader-type high_winrate_hl_perps_trader --limit 20
48
+ ```
49
+
50
+ ### `--sm-label-filter`
51
+ Comma-separated Nansen SM labels to filter by. Only applies when `--trader-type` is `all` or `sm`.
52
+
53
+ ```bash
54
+ # Filter to a specific SM label
55
+ nansen research perp screener --trader-type sm --sm-label-filter "30D Smart Trader"
56
+
57
+ # Multiple labels
58
+ nansen research perp screener --sm-label-filter "30D Smart Trader,Smart LP"
59
+ ```
60
+
61
+ ### `--trader-label-filter`
62
+ Comma-separated HL perps trader labels to filter by. Only applies when `--trader-type` is `all` or `sm`.
63
+
64
+ ```bash
65
+ # Filter to HL Perps Whale label
66
+ nansen research perp screener --trader-label-filter "HL Perps Whale"
67
+ ```
68
+
69
+ ### `--sectors-filter`
70
+ Comma-separated `category:subcategory` pairs to filter coins by sector.
71
+
72
+ ```bash
73
+ # Filter to AI and DeFi crypto sectors
74
+ nansen research perp screener --sectors-filter "Crypto:AI,Crypto:DeFi" --trader-type whale
75
+
76
+ # Combine with trader type and limit
77
+ nansen research perp screener --sectors-filter "Crypto:AI,Crypto:DeFi" --trader-type whale --limit 10 --sort volume:desc
78
+ ```
79
+
80
+ ## Combined Examples
81
+
82
+ ```bash
83
+ # Whale traders in AI crypto, sorted by volume
84
+ nansen research perp screener --trader-type whale --sectors-filter "Crypto:AI" --sort volume:desc --limit 10
85
+
86
+ # Smart money with specific label, last 7 days
87
+ nansen research perp screener --trader-type sm --sm-label-filter "30D Smart Trader" --days 7 --limit 20
88
+
89
+ # All traders in TradFi stocks sector
90
+ nansen research perp screener --sectors-filter "TradFi:Stocks" --sort open_interest:desc --limit 20
91
+ ```
package/src/api.js CHANGED
@@ -493,7 +493,7 @@ export class NansenAPI {
493
493
  * an attemptX402Payment() method so adding a new payment provider only requires
494
494
  * touching that one method, not hunting inside the retry loop.
495
495
  */
496
- async _x402Retry(signature, walletLabel, network, url, body, options = {}) {
496
+ async _x402Retry(signature, walletLabel, network, url, body, options = {}, asset = null) {
497
497
  const paidResponse = await fetch(url, {
498
498
  method: 'POST',
499
499
  headers: {
@@ -514,7 +514,7 @@ export class NansenAPI {
514
514
  if (network) {
515
515
  try {
516
516
  const { checkX402Balance } = await import('./x402.js');
517
- const result = await checkX402Balance(network);
517
+ const result = await checkX402Balance(network, asset);
518
518
  if (result !== null && result.balance < 0.25) {
519
519
  console.error(`[x402] Warning: ${result.symbol} balance low ($${result.balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
520
520
  }
@@ -651,8 +651,8 @@ export class NansenAPI {
651
651
  // 1. Try local wallet with fallback across payment networks
652
652
  try {
653
653
  const { createPaymentSignatures } = await import('./x402.js');
654
- for await (const { signature, network } of createPaymentSignatures(response, url)) {
655
- const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options);
654
+ for await (const { signature, network, asset } of createPaymentSignatures(response, url)) {
655
+ const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options, asset);
656
656
  if (result !== null) return result;
657
657
  // This payment option was rejected, try next
658
658
  }
@@ -1187,13 +1187,18 @@ export class NansenAPI {
1187
1187
  // ============= Perp Endpoints =============
1188
1188
 
1189
1189
  async perpScreener(params = {}) {
1190
- const { filters = {}, orderBy, pagination, days = 30 } = params;
1191
- return this.request('/api/v1/perp-screener', {
1190
+ const { filters = {}, orderBy, pagination, days = 30, traderType, sectorsFilter, smLabelFilter, traderLabelFilter } = params;
1191
+ const body = {
1192
1192
  date: buildDateRange(days),
1193
1193
  filters,
1194
1194
  order_by: orderBy,
1195
1195
  pagination
1196
- });
1196
+ };
1197
+ if (traderType !== undefined) body.filters.trader_type = traderType;
1198
+ if (sectorsFilter !== undefined) body.filters.sectors_filter = sectorsFilter;
1199
+ if (smLabelFilter !== undefined) body.filters.sm_label_filter = smLabelFilter;
1200
+ if (traderLabelFilter !== undefined) body.filters.trader_label_filter = traderLabelFilter;
1201
+ return this.request('/api/v1/perp-screener', body);
1197
1202
  }
1198
1203
 
1199
1204
  async perpLeaderboard(params = {}) {
package/src/cli.js CHANGED
@@ -1367,7 +1367,19 @@ export function buildCommands(deps = {}) {
1367
1367
  const days = options.days ? parseInt(options.days) : 30;
1368
1368
 
1369
1369
  const handlers = {
1370
- 'screener': () => apiInstance.perpScreener({ filters, orderBy, pagination, days }),
1370
+ 'screener': () => {
1371
+ const traderType = options['trader-type'];
1372
+ const sectorsFilter = options['sectors-filter']
1373
+ ? options['sectors-filter'].split(',').map(s => s.trim()).filter(Boolean)
1374
+ : undefined;
1375
+ const smLabelFilter = options['sm-label-filter']
1376
+ ? options['sm-label-filter'].split(',').map(s => s.trim()).filter(Boolean)
1377
+ : undefined;
1378
+ const traderLabelFilter = options['trader-label-filter']
1379
+ ? options['trader-label-filter'].split(',').map(s => s.trim()).filter(Boolean)
1380
+ : undefined;
1381
+ return apiInstance.perpScreener({ filters, orderBy, pagination, days, traderType, sectorsFilter, smLabelFilter, traderLabelFilter });
1382
+ },
1371
1383
  'leaderboard': () => {
1372
1384
  const withLabels = resolveBooleanOption(options, flags, 'premium-labels');
1373
1385
  return apiInstance.perpLeaderboard({ filters, orderBy, pagination, days, withLabels });
package/src/schema.json CHANGED
@@ -522,7 +522,11 @@
522
522
  "options": {
523
523
  "market-cap": {
524
524
  "description": "Filter by market cap group",
525
- "enum": ["lowcap", "midcap", "largecap"]
525
+ "enum": [
526
+ "lowcap",
527
+ "midcap",
528
+ "largecap"
529
+ ]
526
530
  },
527
531
  "limit": {
528
532
  "default": 25
@@ -555,6 +559,25 @@
555
559
  "options": {
556
560
  "days": {
557
561
  "default": 30
562
+ },
563
+ "trader-type": {
564
+ "description": "Filter by trader type. One of: all, sm, whale, public_figure, high_winrate_hl_perps_trader. Defaults to all.",
565
+ "enum": [
566
+ "all",
567
+ "sm",
568
+ "whale",
569
+ "public_figure",
570
+ "high_winrate_hl_perps_trader"
571
+ ]
572
+ },
573
+ "sectors-filter": {
574
+ "description": "Comma-separated sector:subcategory pairs to filter by, e.g. \"Crypto:AI,TradFi:Stocks\"."
575
+ },
576
+ "sm-label-filter": {
577
+ "description": "Comma-separated Nansen SM labels to filter by, e.g. \"30D Smart Trader\". Only applies when trader-type is all or sm."
578
+ },
579
+ "trader-label-filter": {
580
+ "description": "Comma-separated HL perps trader labels to filter by, e.g. \"HL Perps Whale\". Only applies when trader-type is all or sm."
558
581
  }
559
582
  }
560
583
  },
@@ -616,8 +639,12 @@
616
639
  "endpoint": "/api/v1/prediction-market/market-screener",
617
640
  "description": "Get Prediction Market Screener",
618
641
  "options": {
619
- "query": { "default": "" },
620
- "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
642
+ "query": {
643
+ "default": ""
644
+ },
645
+ "sort-by": {
646
+ "description": "Deprecated: use --sort field:dir instead"
647
+ },
621
648
  "tags": {},
622
649
  "min-liquidity": {},
623
650
  "max-liquidity": {},
@@ -638,8 +665,12 @@
638
665
  "endpoint": "/api/v1/prediction-market/event-screener",
639
666
  "description": "Get Prediction Market Event Screener",
640
667
  "options": {
641
- "query": { "default": "" },
642
- "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
668
+ "query": {
669
+ "default": ""
670
+ },
671
+ "sort-by": {
672
+ "description": "Deprecated: use --sort field:dir instead"
673
+ },
643
674
  "tags": {},
644
675
  "min-liquidity": {},
645
676
  "max-liquidity": {},
@@ -717,104 +748,218 @@
717
748
  "endpoint": "/api/v1beta1/tgm/historical-dex-trades",
718
749
  "description": "Historical DEX trades for a token at a point in time",
719
750
  "options": {
720
- "token-address": { "required": true, "description": "Token address" },
721
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
722
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
723
- "chain": { "default": "solana", "description": "Chain" }
751
+ "token-address": {
752
+ "required": true,
753
+ "description": "Token address"
754
+ },
755
+ "from-date": {
756
+ "required": true,
757
+ "description": "Start of date range (YYYY-MM-DD)"
758
+ },
759
+ "to-date": {
760
+ "required": true,
761
+ "description": "End of date range (YYYY-MM-DD)"
762
+ },
763
+ "chain": {
764
+ "default": "solana",
765
+ "description": "Chain"
766
+ }
724
767
  }
725
768
  },
726
769
  "historical-pnl-leaderboard": {
727
770
  "endpoint": "/api/v1beta1/tgm/historical-pnl-leaderboard",
728
771
  "description": "Historical PnL leaderboard for a token",
729
772
  "options": {
730
- "token-address": { "required": true, "description": "Token address" },
731
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
732
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
733
- "chain": { "default": "solana", "description": "Chain" }
773
+ "token-address": {
774
+ "required": true,
775
+ "description": "Token address"
776
+ },
777
+ "from-date": {
778
+ "required": true,
779
+ "description": "Start of date range (YYYY-MM-DD)"
780
+ },
781
+ "to-date": {
782
+ "required": true,
783
+ "description": "End of date range (YYYY-MM-DD)"
784
+ },
785
+ "chain": {
786
+ "default": "solana",
787
+ "description": "Chain"
788
+ }
734
789
  }
735
790
  },
736
791
  "historical-token-flow-summary": {
737
792
  "endpoint": "/api/v1beta1/tgm/historical-token-flow-summary",
738
793
  "description": "Historical token flow summary (no pagination)",
739
794
  "options": {
740
- "token-address": { "required": true, "description": "Token address" },
741
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
742
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
743
- "chain": { "default": "solana", "description": "Chain" }
795
+ "token-address": {
796
+ "required": true,
797
+ "description": "Token address"
798
+ },
799
+ "from-date": {
800
+ "required": true,
801
+ "description": "Start of date range (YYYY-MM-DD)"
802
+ },
803
+ "to-date": {
804
+ "required": true,
805
+ "description": "End of date range (YYYY-MM-DD)"
806
+ },
807
+ "chain": {
808
+ "default": "solana",
809
+ "description": "Chain"
810
+ }
744
811
  }
745
812
  },
746
813
  "historical-token-quant-scores": {
747
814
  "endpoint": "/api/v1beta1/tgm/historical-token-quant-scores",
748
815
  "description": "Historical token quantitative scores at a snapshot date",
749
816
  "options": {
750
- "token-address": { "required": true, "description": "Token address" },
751
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
752
- "chain": { "default": "solana", "description": "Chain" }
817
+ "token-address": {
818
+ "required": true,
819
+ "description": "Token address"
820
+ },
821
+ "as-of-date": {
822
+ "required": true,
823
+ "description": "Snapshot date (YYYY-MM-DD)"
824
+ },
825
+ "chain": {
826
+ "default": "solana",
827
+ "description": "Chain"
828
+ }
753
829
  }
754
830
  },
755
831
  "historical-top-holders": {
756
832
  "endpoint": "/api/v1beta1/tgm/historical-top-holders",
757
833
  "description": "Historical top holders of a token at a snapshot date",
758
834
  "options": {
759
- "token-address": { "required": true, "description": "Token address" },
760
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
761
- "chain": { "default": "solana", "description": "Chain" }
835
+ "token-address": {
836
+ "required": true,
837
+ "description": "Token address"
838
+ },
839
+ "as-of-date": {
840
+ "required": true,
841
+ "description": "Snapshot date (YYYY-MM-DD)"
842
+ },
843
+ "chain": {
844
+ "default": "solana",
845
+ "description": "Chain"
846
+ }
762
847
  }
763
848
  },
764
849
  "historical-who-bought-sold": {
765
850
  "endpoint": "/api/v1beta1/tgm/historical-who-bought-sold",
766
851
  "description": "Historical buyers/sellers of a token",
767
852
  "options": {
768
- "token-address": { "required": true, "description": "Token address" },
769
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
770
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
771
- "buy-or-sell": { "default": "BUY", "description": "BUY or SELL" },
772
- "chain": { "default": "solana", "description": "Chain" }
853
+ "token-address": {
854
+ "required": true,
855
+ "description": "Token address"
856
+ },
857
+ "from-date": {
858
+ "required": true,
859
+ "description": "Start of date range (YYYY-MM-DD)"
860
+ },
861
+ "to-date": {
862
+ "required": true,
863
+ "description": "End of date range (YYYY-MM-DD)"
864
+ },
865
+ "buy-or-sell": {
866
+ "default": "BUY",
867
+ "description": "BUY or SELL"
868
+ },
869
+ "chain": {
870
+ "default": "solana",
871
+ "description": "Chain"
872
+ }
773
873
  }
774
874
  },
775
875
  "historical-smart-money-balances": {
776
876
  "endpoint": "/api/v1beta1/smart-money/historical-token-balances",
777
877
  "description": "Historical smart money token balances at a snapshot date (no order_by)",
778
878
  "options": {
779
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
780
- "chains": { "default": "solana", "description": "Comma-separated chains" }
879
+ "as-of-date": {
880
+ "required": true,
881
+ "description": "Snapshot date (YYYY-MM-DD)"
882
+ },
883
+ "chains": {
884
+ "default": "solana",
885
+ "description": "Comma-separated chains"
886
+ }
781
887
  }
782
888
  },
783
889
  "historical-token-screener": {
784
890
  "endpoint": "/api/v1beta1/token-screener/historical",
785
891
  "description": "Historical token screener over a trailing window",
786
892
  "options": {
787
- "timeframe-days": { "required": true, "type": "number", "description": "Trailing window size in days" },
788
- "to-date": { "required": true, "description": "End date for the window (YYYY-MM-DD)" },
789
- "chains": { "default": "solana", "description": "Comma-separated chains" }
893
+ "timeframe-days": {
894
+ "required": true,
895
+ "type": "number",
896
+ "description": "Trailing window size in days"
897
+ },
898
+ "to-date": {
899
+ "required": true,
900
+ "description": "End date for the window (YYYY-MM-DD)"
901
+ },
902
+ "chains": {
903
+ "default": "solana",
904
+ "description": "Comma-separated chains"
905
+ }
790
906
  }
791
907
  },
792
908
  "historical-wallet-balances": {
793
909
  "endpoint": "/api/v1beta1/profiler/address/historical-token-balances",
794
910
  "description": "Historical token balances for a wallet at a snapshot date",
795
911
  "options": {
796
- "address": { "required": true, "description": "Wallet address" },
797
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
798
- "chain": { "default": "ethereum", "description": "Chain" }
912
+ "address": {
913
+ "required": true,
914
+ "description": "Wallet address"
915
+ },
916
+ "as-of-date": {
917
+ "required": true,
918
+ "description": "Snapshot date (YYYY-MM-DD)"
919
+ },
920
+ "chain": {
921
+ "default": "ethereum",
922
+ "description": "Chain"
923
+ }
799
924
  }
800
925
  },
801
926
  "historical-tx-lookup": {
802
927
  "endpoint": "/api/v1beta1/profiler/historical-transaction-lookup",
803
928
  "description": "Lookup a historical transaction by hash",
804
929
  "options": {
805
- "transaction-hash": { "required": true, "description": "Transaction hash (0x-prefixed, 66 chars)" },
806
- "as-of-date": { "required": true, "description": "Reference date for label and pricing resolution (YYYY-MM-DD)" },
807
- "block-timestamp": { "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) — skips slow hash-resolution step if provided" },
808
- "chain": { "default": "ethereum", "description": "Chain (ethereum, bnb, base)" }
930
+ "transaction-hash": {
931
+ "required": true,
932
+ "description": "Transaction hash (0x-prefixed, 66 chars)"
933
+ },
934
+ "as-of-date": {
935
+ "required": true,
936
+ "description": "Reference date for label and pricing resolution (YYYY-MM-DD)"
937
+ },
938
+ "block-timestamp": {
939
+ "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) \u2014 skips slow hash-resolution step if provided"
940
+ },
941
+ "chain": {
942
+ "default": "ethereum",
943
+ "description": "Chain (ethereum, bnb, base)"
944
+ }
809
945
  }
810
946
  },
811
947
  "historical-wallet-transactions": {
812
948
  "endpoint": "/api/v1beta1/profiler/address/historical-transactions",
813
949
  "description": "Historical transactions for a wallet at a snapshot date",
814
950
  "options": {
815
- "address": { "required": true, "description": "Wallet address" },
816
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
817
- "chain": { "default": "ethereum", "description": "Chain" }
951
+ "address": {
952
+ "required": true,
953
+ "description": "Wallet address"
954
+ },
955
+ "as-of-date": {
956
+ "required": true,
957
+ "description": "Snapshot date (YYYY-MM-DD)"
958
+ },
959
+ "chain": {
960
+ "default": "ethereum",
961
+ "description": "Chain"
962
+ }
818
963
  }
819
964
  }
820
965
  }
@@ -971,7 +1116,7 @@
971
1116
  },
972
1117
  "to-chain": {
973
1118
  "type": "string",
974
- "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay."
1119
+ "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported \u2014 swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay."
975
1120
  },
976
1121
  "from": {
977
1122
  "type": "string",
@@ -1047,7 +1192,7 @@
1047
1192
  },
1048
1193
  "aggregator": {
1049
1194
  "type": "string",
1050
- "description": "lifi or relay. Overrides auto-detection use when polling from a different machine or after the 30-day local record TTL has expired."
1195
+ "description": "lifi or relay. Overrides auto-detection \u2014 use when polling from a different machine or after the 30-day local record TTL has expired."
1051
1196
  }
1052
1197
  }
1053
1198
  },
@@ -1101,7 +1246,9 @@
1101
1246
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1102
1247
  }
1103
1248
  },
1104
- "chains": ["solana"],
1249
+ "chains": [
1250
+ "solana"
1251
+ ],
1105
1252
  "prerequisites": [
1106
1253
  "A Solana wallet must be configured. Run: nansen wallet create"
1107
1254
  ]
@@ -1141,7 +1288,9 @@
1141
1288
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1142
1289
  }
1143
1290
  },
1144
- "chains": ["solana"]
1291
+ "chains": [
1292
+ "solana"
1293
+ ]
1145
1294
  },
1146
1295
  "cancel": {
1147
1296
  "description": "Cancel an open limit order",
@@ -1156,7 +1305,9 @@
1156
1305
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1157
1306
  }
1158
1307
  },
1159
- "chains": ["solana"]
1308
+ "chains": [
1309
+ "solana"
1310
+ ]
1160
1311
  },
1161
1312
  "update": {
1162
1313
  "description": "Update trigger price or slippage on an existing order",
@@ -1179,7 +1330,9 @@
1179
1330
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1180
1331
  }
1181
1332
  },
1182
- "chains": ["solana"]
1333
+ "chains": [
1334
+ "solana"
1335
+ ]
1183
1336
  }
1184
1337
  }
1185
1338
  }
package/src/x402-evm.js CHANGED
@@ -25,6 +25,39 @@ const AUTHORIZATION_TYPES = [
25
25
  { name: 'nonce', type: 'bytes32' },
26
26
  ];
27
27
 
28
+ // ============= Permit2 (permit2-exact transfer method) =============
29
+ // Some tokens (e.g. USDT/USDC on BNB Smart Chain) predate EIP-3009, so
30
+ // facilitators settle them through Uniswap's canonical Permit2 contract
31
+ // instead: the payer signs a PermitWitnessTransferFrom and the facilitator's
32
+ // proxy (requirements.extra.spenderAddress) executes the transfer. Requires a
33
+ // one-time on-chain `token.approve(PERMIT2_ADDRESS, ...)` from the payer.
34
+
35
+ export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
36
+
37
+ // Permit2's EIP-712 domain has no version field.
38
+ const PERMIT2_DOMAIN_TYPES = [
39
+ { name: 'name', type: 'string' },
40
+ { name: 'chainId', type: 'uint256' },
41
+ { name: 'verifyingContract', type: 'address' },
42
+ ];
43
+
44
+ const TOKEN_PERMISSIONS_TYPES = [
45
+ { name: 'token', type: 'address' },
46
+ { name: 'amount', type: 'uint256' },
47
+ ];
48
+
49
+ const WITNESS_TYPES = [
50
+ { name: 'to', type: 'address' },
51
+ { name: 'validAfter', type: 'uint256' },
52
+ ];
53
+
54
+ // encodeType for a nested primary type: referenced struct types are appended
55
+ // in alphabetical order per EIP-712 (TokenPermissions before Witness).
56
+ const PERMIT_WITNESS_TYPE_STRING =
57
+ 'PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,Witness witness)' +
58
+ 'TokenPermissions(address token,uint256 amount)' +
59
+ 'Witness(address to,uint256 validAfter)';
60
+
28
61
  /**
29
62
  * Encode a type string for EIP-712 typeHash.
30
63
  * e.g. "TransferWithAuthorization(address from,address to,uint256 value,...)"
@@ -110,6 +143,38 @@ export function hashTypedData(domain, primaryType, fields, message) {
110
143
  ]));
111
144
  }
112
145
 
146
+ /**
147
+ * Compute the EIP-712 digest for a Permit2 PermitWitnessTransferFrom.
148
+ *
149
+ * Hand-rolled because the generic helpers above only support flat structs:
150
+ * the primary typeHash must cover the full type string including referenced
151
+ * structs, and struct-typed fields encode as their structHash.
152
+ *
153
+ * @param {number} chainId - EVM chain id (Permit2 domain field)
154
+ * @param {object} message - { permitted: {token, amount}, spender, nonce, deadline, witness: {to, validAfter} }
155
+ * @returns {Buffer} 32-byte digest to sign
156
+ */
157
+ export function hashPermit2WitnessTransfer(chainId, message) {
158
+ const domainSeparator = hashStruct('EIP712Domain', PERMIT2_DOMAIN_TYPES, {
159
+ name: 'Permit2',
160
+ chainId,
161
+ verifyingContract: PERMIT2_ADDRESS,
162
+ });
163
+ const structHash = keccak256(Buffer.concat([
164
+ keccak256(Buffer.from(PERMIT_WITNESS_TYPE_STRING, 'utf8')),
165
+ hashStruct('TokenPermissions', TOKEN_PERMISSIONS_TYPES, message.permitted),
166
+ encodeValue('address', message.spender),
167
+ encodeValue('uint256', message.nonce),
168
+ encodeValue('uint256', message.deadline),
169
+ hashStruct('Witness', WITNESS_TYPES, message.witness),
170
+ ]));
171
+ return keccak256(Buffer.concat([
172
+ Buffer.from([0x19, 0x01]),
173
+ domainSeparator,
174
+ structHash,
175
+ ]));
176
+ }
177
+
113
178
  // ============= x402 EVM Payment =============
114
179
 
115
180
  /**
@@ -123,7 +188,13 @@ function getChainId(network) {
123
188
  }
124
189
 
125
190
  /**
126
- * Create an x402 payment payload for EVM (EIP-3009 TransferWithAuthorization).
191
+ * Create an x402 payment payload for EVM.
192
+ *
193
+ * Routes on requirements.extra.assetTransferMethod: absent or "eip3009" signs
194
+ * an EIP-3009 TransferWithAuthorization (gasless); "permit2-exact" signs a
195
+ * Permit2 PermitWitnessTransferFrom (requires a prior one-time
196
+ * token.approve(PERMIT2_ADDRESS, ...)). Other methods throw so the fallback
197
+ * loop tries the next accepts entry.
127
198
  *
128
199
  * @param {object} requirements - Parsed PaymentRequirements from 402 response
129
200
  * @param {string} privateKeyHex - 32-byte EVM private key as hex
@@ -135,6 +206,14 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
135
206
  const chainId = getChainId(requirements.network);
136
207
  const extra = requirements.extra || {};
137
208
 
209
+ const method = extra.assetTransferMethod;
210
+ if (method === 'permit2-exact') {
211
+ return createPermit2ExactPayload(requirements, privateKeyHex, walletAddress, resource);
212
+ }
213
+ if (method && method !== 'eip3009') {
214
+ throw new Error(`Unsupported assetTransferMethod: ${method}`);
215
+ }
216
+
138
217
  // Token name and version from requirements.extra (set by server/facilitator)
139
218
  const tokenName = extra.name;
140
219
  const tokenVersion = extra.version || '1';
@@ -199,6 +278,71 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
199
278
  return Buffer.from(JSON.stringify(payload)).toString('base64');
200
279
  }
201
280
 
281
+ /**
282
+ * Create an x402 payment payload via Permit2 PermitWitnessTransferFrom
283
+ * (assetTransferMethod "permit2-exact").
284
+ *
285
+ * The spender is the facilitator's Permit2 proxy advertised in
286
+ * requirements.extra.spenderAddress; the witness binds the transfer to the
287
+ * merchant wallet (requirements.payTo). Wire-format numeric fields are
288
+ * decimal strings.
289
+ *
290
+ * @param {object} requirements - Parsed PaymentRequirements from 402 response
291
+ * @param {string} privateKeyHex - 32-byte EVM private key as hex
292
+ * @param {string} walletAddress - Signer's EVM address
293
+ * @param {string} resource - Original request URL
294
+ * @returns {string} Base64-encoded PaymentPayload for Payment-Signature header
295
+ */
296
+ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAddress, resource) {
297
+ const chainId = getChainId(requirements.network);
298
+ const extra = requirements.extra || {};
299
+ const spender = extra.spenderAddress;
300
+ if (!spender) {
301
+ throw new Error('spenderAddress missing from requirements.extra (required for permit2-exact)');
302
+ }
303
+
304
+ const payTo = requirements.pay_to || requirements.payTo;
305
+ const now = Math.floor(Date.now() / 1000);
306
+ // 256-bit random nonce — Permit2 uses an unordered nonce bitmap.
307
+ const nonce = BigInt('0x' + crypto.randomBytes(32).toString('hex')).toString();
308
+ const deadline = String(now + 3600);
309
+ const validAfter = String(now - 60); // allow clock skew
310
+
311
+ const message = {
312
+ permitted: { token: requirements.asset, amount: BigInt(requirements.amount) },
313
+ spender,
314
+ nonce: BigInt(nonce),
315
+ deadline: BigInt(deadline),
316
+ witness: { to: payTo, validAfter: BigInt(validAfter) },
317
+ };
318
+
319
+ const msgHash = hashPermit2WitnessTransfer(chainId, message);
320
+ const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
321
+ const signature = '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16);
322
+
323
+ const payload = {
324
+ x402Version: 2,
325
+ payload: {
326
+ permit2Authorization: {
327
+ permitted: { token: requirements.asset, amount: String(requirements.amount) },
328
+ from: walletAddress,
329
+ spender,
330
+ nonce,
331
+ deadline,
332
+ witness: { to: payTo, validAfter },
333
+ },
334
+ signature,
335
+ },
336
+ accepted: requirements,
337
+ };
338
+
339
+ if (resource) {
340
+ payload.resource = { url: resource };
341
+ }
342
+
343
+ return Buffer.from(JSON.stringify(payload)).toString('base64');
344
+ }
345
+
202
346
  /**
203
347
  * Check if a network string is an EVM network.
204
348
  */
package/src/x402.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Supports EVM (EIP-3009 on Base) and Solana (SPL TransferChecked).
5
5
  */
6
6
 
7
- import { createEvmPaymentPayload, isEvmNetwork } from './x402-evm.js';
7
+ import { createEvmPaymentPayload, isEvmNetwork, PERMIT2_ADDRESS } from './x402-evm.js';
8
8
  import {
9
9
  createSvmPaymentPayload,
10
10
  isSvmNetwork,
@@ -55,12 +55,63 @@ function rankRequirements(requirements) {
55
55
  return ranked;
56
56
  }
57
57
 
58
+ // ERC-20 allowance(owner, spender) selector for the Permit2 preflight.
59
+ const ALLOWANCE_SELECTOR = '0xdd62ed3e';
60
+
61
+ /**
62
+ * Check whether `owner` has approved Permit2 to spend at least `amount` of
63
+ * `token`. Permit2-based payments are doomed without sufficient allowance
64
+ * (never approved, or a finite approval now below the payment amount), so
65
+ * skip those entries early instead of burning a failed verify round-trip.
66
+ * Returns true when the allowance is unknown (RPC failure) — let the server
67
+ * decide rather than block a possibly-valid payment.
68
+ */
69
+ async function hasPermit2Allowance(network, token, owner, amount) {
70
+ const rpc = getEvmRpcUrl(network);
71
+ if (!rpc) return true;
72
+ try {
73
+ const ownerArg = owner.replace(/^0x/, '').toLowerCase().padStart(64, '0');
74
+ const spenderArg = PERMIT2_ADDRESS.replace(/^0x/, '').toLowerCase().padStart(64, '0');
75
+ const resp = await fetch(rpc, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ jsonrpc: '2.0', id: 1,
80
+ method: 'eth_call',
81
+ params: [{ to: token, data: `${ALLOWANCE_SELECTOR}${ownerArg}${spenderArg}` }, 'latest'],
82
+ }),
83
+ });
84
+ const data = await resp.json();
85
+ if (typeof data.result !== 'string') return true;
86
+ return BigInt(data.result) >= BigInt(amount);
87
+ } catch {
88
+ return true;
89
+ }
90
+ }
91
+
58
92
  /**
59
93
  * Build a payment signature for a single requirement.
60
94
  * @returns {string|null} Base64 payment signature, or null on failure
61
95
  */
62
96
  async function buildPaymentForRequirement(requirement, exported, url) {
63
97
  if (isEvmNetwork(requirement.network)) {
98
+ if ((requirement.extra || {}).assetTransferMethod === 'permit2-exact') {
99
+ const approved = await hasPermit2Allowance(
100
+ requirement.network,
101
+ requirement.asset,
102
+ exported.evm.address,
103
+ requirement.amount,
104
+ );
105
+ if (!approved) {
106
+ console.error(
107
+ `[x402] Skipping ${requirement.network} permit2 option: Permit2 ` +
108
+ `(${PERMIT2_ADDRESS}) allowance for token ${requirement.asset} is ` +
109
+ `missing or below the payment amount (${requirement.amount}). ` +
110
+ `Send approve(${PERMIT2_ADDRESS}, <amount>) from the wallet to enable it.`,
111
+ );
112
+ return null;
113
+ }
114
+ }
64
115
  return createEvmPaymentPayload(
65
116
  requirement,
66
117
  exported.evm.privateKey,
@@ -132,7 +183,7 @@ export async function* createPaymentSignatures(response, url, options = {}) {
132
183
  for (const req of ranked) {
133
184
  try {
134
185
  const sig = await buildPaymentForRequirement(req, exported, url);
135
- if (sig) yield { signature: sig, network: req.network };
186
+ if (sig) yield { signature: sig, network: req.network, asset: req.asset };
136
187
  } catch {
137
188
  // This payment option failed to build, try next
138
189
  continue;
@@ -161,17 +212,43 @@ export async function createPaymentSignature(response, url, options = {}) {
161
212
  * advertises in 402 `accepts` entries. `decimals` matters: USDT on BNB Smart
162
213
  * Chain uses 18 decimals, unlike the 6-decimal tokens on Base and X Layer.
163
214
  */
215
+ // RPC endpoint per supported x402 EVM network.
216
+ export const EVM_X402_RPCS = {
217
+ 'eip155:8453': CHAIN_RPCS.base,
218
+ 'eip155:196': CHAIN_RPCS.xlayer,
219
+ 'eip155:56': CHAIN_RPCS.bsc,
220
+ };
221
+
222
+ function getEvmRpcUrl(network) {
223
+ return EVM_X402_RPCS[network] || null;
224
+ }
225
+
226
+ // Known payment tokens per network, in the order servers typically advertise
227
+ // them. A network can accept several stablecoins (BSC accepts four); `decimals`
228
+ // is per token — every BSC stablecoin is an 18-decimal BEP-20 deployment,
229
+ // unlike the 6-decimal tokens on Base and X Layer.
164
230
  export const EVM_X402_TOKENS = {
165
- 'eip155:8453': { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', rpc: CHAIN_RPCS.base, symbol: 'USDC', decimals: 6 }, // Base USDC
166
- 'eip155:196': { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', rpc: CHAIN_RPCS.xlayer, symbol: 'USDT0', decimals: 6 }, // X Layer USDT0
167
- 'eip155:56': { token: '0x55d398326f99059fF775485246999027B3197955', rpc: CHAIN_RPCS.bsc, symbol: 'USDT', decimals: 18 }, // BSC USDT
231
+ 'eip155:8453': [
232
+ { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6 }, // Base USDC
233
+ ],
234
+ 'eip155:196': [
235
+ { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', symbol: 'USDT0', decimals: 6 }, // X Layer USDT0
236
+ ],
237
+ 'eip155:56': [
238
+ { token: '0xcE24439F2D9C6a2289F741120FE202248B666666', symbol: 'U', decimals: 18 }, // United Stables
239
+ { token: '0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d', symbol: 'USD1', decimals: 18 }, // World Liberty Financial USD
240
+ { token: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }, // Tether USD
241
+ { token: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', decimals: 18 }, // Binance-Peg USD Coin
242
+ ],
168
243
  };
169
244
 
170
245
  /**
171
246
  * Check stablecoin balance for x402 payment wallet on the given network.
247
+ * Pass the token contract paid with (`asset`) to check that specific token;
248
+ * otherwise the network's first known token is checked.
172
249
  * Returns `{ balance, symbol }` (USD amount + token symbol) or null if check fails.
173
250
  */
174
- export async function checkX402Balance(network) {
251
+ export async function checkX402Balance(network, asset = null) {
175
252
  try {
176
253
  const { listWallets, exportWallet: _exportWallet } = await import('./wallet.js');
177
254
  const wallets = listWallets();
@@ -204,8 +281,12 @@ export async function checkX402Balance(network) {
204
281
 
205
282
  if (network.startsWith('eip155:')) {
206
283
  // Default to Base USDC if the network is unknown so existing wallets keep working.
207
- const { token, rpc, symbol, decimals } =
208
- EVM_X402_TOKENS[network] || EVM_X402_TOKENS['eip155:8453'];
284
+ const tokens = EVM_X402_TOKENS[network] || EVM_X402_TOKENS['eip155:8453'];
285
+ const entry = (asset
286
+ && tokens.find(t => t.token.toLowerCase() === asset.toLowerCase()))
287
+ || tokens[0];
288
+ const { token, symbol, decimals } = entry;
289
+ const rpc = getEvmRpcUrl(network) || EVM_X402_RPCS['eip155:8453'];
209
290
  const addr = walletInfo.evm.replace('0x', '').toLowerCase().padStart(64, '0');
210
291
  const resp = await fetch(rpc, {
211
292
  method: 'POST',