nansen-cli 1.43.1 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#563](https://github.com/nansen-ai/nansen-cli/pull/563) [`3b3e11b`](https://github.com/nansen-ai/nansen-cli/commit/3b3e11b5d346662bf352cbe2e3f66e54b22213b0) Thanks [@hulk-linus](https://github.com/apps/hulk-linus)! - Emit privacy-preserving per-leg Hyperliquid attribution through the canonical trade-perps outcome events.
8
+
9
+ - [#580](https://github.com/nansen-ai/nansen-cli/pull/580) [`c5085c5`](https://github.com/nansen-ai/nansen-cli/commit/c5085c5d062d848604402949a3e03c87d8b013bd) Thanks [@gulshngill](https://github.com/gulshngill)! - Add `nansen completion <bash|zsh|fish>`, which prints a shell completion script generated from the CLI's own command schema. Completions cover nested subcommands, per-command flags, global flags, and the enum values a flag accepts.
10
+
11
+ - [#597](https://github.com/nansen-ai/nansen-cli/pull/597) [`8453355`](https://github.com/nansen-ai/nansen-cli/commit/845335501837956db2f7a899bf251048bbd706d9) Thanks [@kome12](https://github.com/kome12)! - Security: `nansen mcp verify` no longer sends a saved API key (from `nansen login` / `NANSEN_API_KEY` / config) to a custom `--url` without explicit consent. Forwarding a saved key to a non-default URL now requires `--send-api-key`, and no key is ever sent over plain HTTP to a non-loopback host. An inline `--api-key` is unaffected.
12
+
13
+ Note: verifying a custom `--url` with a saved key now errors unless `--send-api-key` is passed (previously it warned and proceeded).
14
+
15
+ - [#557](https://github.com/nansen-ai/nansen-cli/pull/557) [`5a73b43`](https://github.com/nansen-ai/nansen-cli/commit/5a73b43e4f2fc753c435babcea648b156f7a99a9) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research address-premium-labels` command.
16
+
17
+ - [#555](https://github.com/nansen-ai/nansen-cli/pull/555) [`4285d7f`](https://github.com/nansen-ai/nansen-cli/commit/4285d7fc22fde5bacb5b67eb3bcb6e94bb6a680e) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research chain-rank` command.
18
+
19
+ - [#561](https://github.com/nansen-ai/nansen-cli/pull/561) [`dd8035c`](https://github.com/nansen-ai/nansen-cli/commit/dd8035c09f5c99c08247686c621c5c14c30607cc) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research historical-token-ohlcv` command.
20
+
21
+ - [#560](https://github.com/nansen-ai/nansen-cli/pull/560) [`52f2f09`](https://github.com/nansen-ai/nansen-cli/commit/52f2f095baeaa48c43d5b0f3b1a3b036166266b5) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research perp-pnl-summary` command.
22
+
23
+ - [#559](https://github.com/nansen-ai/nansen-cli/pull/559) [`ed2d7a5`](https://github.com/nansen-ai/nansen-cli/commit/ed2d7a5ac00b51240f5a2d0fb7828131dc5074c8) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research position-intelligence` command.
24
+
25
+ - [#558](https://github.com/nansen-ai/nansen-cli/pull/558) [`e273152`](https://github.com/nansen-ai/nansen-cli/commit/e273152358792203435191a0f30dd307699721aa) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research smart-money-pnl-leaderboard` command.
26
+
27
+ - [#556](https://github.com/nansen-ai/nansen-cli/pull/556) [`3c14359`](https://github.com/nansen-ai/nansen-cli/commit/3c143596b6d7e624338f8d9b1a0972bcbe4d7ad7) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research token-sectors` command.
28
+
29
+ - [#562](https://github.com/nansen-ai/nansen-cli/pull/562) [`0d0f5e6`](https://github.com/nansen-ai/nansen-cli/commit/0d0f5e6b83ae91f0215eac3ad2e9216897e6666c) Thanks [@gulshngill](https://github.com/gulshngill)! - Add the `nansen research transaction-with-token-transfer-lookup` command.
30
+
31
+ - [#581](https://github.com/nansen-ai/nansen-cli/pull/581) [`8726a23`](https://github.com/nansen-ai/nansen-cli/commit/8726a23c0e6028c98f941ee9a638dffa50e01ab1) Thanks [@gulshngill](https://github.com/gulshngill)! - Every command option in `nansen schema` now carries a type and a description (140 research and `wallet send` options had neither), and the `research points` group is described. `research token ohlcv --timeframe` documents its real default (`1d`), and `wallet send --chain`, `research search --type`, and the prediction-market `--neg-risk` filters declare the values they accept, so `--help` and shell completion offer them. Fixed `--neg-risk true` on the prediction-market screeners, which was sent to the API as `neg_risk: false` because the parsed boolean was compared against the string `'true'`. `nansen mcp install` and `nansen mcp uninstall` declare their positional client in the schema, and `nansen completion` scripts now complete it (`claude-code`, `claude-desktop`, `cursor`) in bash, zsh, and fish.
32
+
33
+ ### Patch Changes
34
+
35
+ - [#575](https://github.com/nansen-ai/nansen-cli/pull/575) [`927ffea`](https://github.com/nansen-ai/nansen-cli/commit/927ffea1762e7de78160a209aa3cc72298b42c24) Thanks [@kome12](https://github.com/kome12)! - Fail closed on an ambiguous broadcast failure in swap and bridge execute. When
36
+ `/execute` (swap) returns any 5xx (regardless of body shape — a structured 504
37
+ `UPSTREAM_TIMEOUT` is treated the same as a bare 502) or any other
38
+ uninterpretable response — or the POST, or reading its body, throws a network
39
+ error — after the signed tx was sent, or a bridge broadcast fails ambiguously,
40
+ the tx may already be live. The quote is now marked spent and the
41
+ run aborts — rather than trying the next candidate quote or leaving the quote
42
+ reusable — so a re-execute (or agent auto-retry) can't double-broadcast. Gasless
43
+ (Relay solver-paid) swaps additionally do not retry the `/execute` POST: the
44
+ signed authorization is broadcast by Relay's own wrapping tx, so a re-POST can't
45
+ be node-deduped and could trigger a second solve — a single attempt fails closed
46
+ instead. A bridge send is kept reusable only when the node's error proves the tx never
47
+ entered the mempool (a pre-broadcast validation rejection such as insufficient
48
+ funds); in-flight txpool states like "already known" or "nonce too low" fail
49
+ closed.
50
+
51
+ - [#572](https://github.com/nansen-ai/nansen-cli/pull/572) [`2ba5c15`](https://github.com/nansen-ai/nansen-cli/commit/2ba5c15a68f6cc2045dcfb928d65eecdb4083292) Thanks [@teyrebaz33](https://github.com/teyrebaz33)! - Fix `nansen changelog --since <version>` silently returning "No changelog entries found" for a version missing its patch number (e.g. `--since 1.43` instead of `--since 1.43.0`), even when matching entries exist. The comparison compared the missing component against `undefined`, and `>` is always `false` against `undefined` in both directions, so a version that matched on major.minor always came out "less than" the since-value. A missing component is now treated as `0`, and a `--since` value that isn't a valid version (e.g. `--since abc`) now prints a clear error instead of silently matching nothing.
52
+
53
+ The version-comparison logic is now shared (`src/semver.js`) between `nansen changelog --since` and the update-notifier's `isNewer` check, which had the identical bug in its own separate parser. `isNewer` couldn't misfire in practice (both versions it compares are always fully-qualified x.y.z today), but it's the same defect class, so it's fixed the same way rather than left in place.
54
+
55
+ - [#569](https://github.com/nansen-ai/nansen-cli/pull/569) [`04932c7`](https://github.com/nansen-ai/nansen-cli/commit/04932c7fb26fb2c4c4f9b14eddcd43a8e3060aa4) Thanks [@memosr](https://github.com/memosr)! - Refuse to re-execute a swap quote that has already been broadcast, mirroring the single-use guard `nansen bridge execute` already had. `nansen trade execute` now marks the quote as spent (`executedAt`) the instant a transaction is broadcast — before waiting for its receipt — so a `RECEIPT_TIMEOUT` (the tx is on-chain but the command exits non-zero) no longer leaves the quote replayable. Retrying the same `--quote <id>` after such a failure previously re-signed and re-broadcast the swap under a fresh nonce instead of being refused.
56
+
57
+ - [#592](https://github.com/nansen-ai/nansen-cli/pull/592) [`60bd2ef`](https://github.com/nansen-ai/nansen-cli/commit/60bd2ef64f5eebe89112bfa3366c013981f365aa) Thanks [@ygd58](https://github.com/ygd58)! - Fix the x402 auto-payment fallback in `src/api.js` generating multiple payment authorizations for the same request after an ambiguous outcome (issue #583). After a signed `Payment-Signature` was transmitted, `_x402Retry` previously collapsed every non-ok response — a clean rejection, a 5xx, an unreadable body — and every transport failure into a single `null`, and callers treated any `null` as "safe to try the next payment option/provider". That meant a 5xx, a timeout, or an unparseable response (any of which could mean the server already received and settled the payment) triggered signing and transmitting a _second_ independent payment for the same logical request. Separately, a genuine successful response whose JSON body happened to be `null` was indistinguishable from a rejection, risking a second payment for an already-settled call.
58
+
59
+ `_x402Retry` now returns a dedicated `X402_PAYMENT_REJECTED` sentinel only for a provably clean rejection (a non-5xx status with a readable body), and throws `NansenError` with the new `PAYMENT_AMBIGUOUS` code for anything else — a transport failure, a 5xx, or an unreadable body on either a rejection or a success. All three fallback call sites (Privy, local wallet, WalletConnect) now check against the sentinel instead of `null`, and re-throw a `PAYMENT_AMBIGUOUS` error immediately instead of silently moving on to the next provider.
60
+
61
+ - [#524](https://github.com/nansen-ai/nansen-cli/pull/524) [`3e8dcc2`](https://github.com/nansen-ai/nansen-cli/commit/3e8dcc233598383556004da7aaa0a64275beee3f) Thanks [@dolmaciabdullah-byte](https://github.com/dolmaciabdullah-byte)! - Use BigInt for EVM balance in `checkX402Balance` to avoid precision loss on 18-decimal tokens (BSC stablecoins): `parseInt(hex, 16)` loses integer precision once the raw wei value exceeds `Number.MAX_SAFE_INTEGER` (~9.0e15 wei, i.e. ~0.009 tokens at 18 decimals), skewing the low-balance warning.
62
+
63
+ - [#568](https://github.com/nansen-ai/nansen-cli/pull/568) [`9a45fc4`](https://github.com/nansen-ai/nansen-cli/commit/9a45fc494b3bcc0ebeb37f867902aecc075ee92f) Thanks [@Kewe63](https://github.com/Kewe63)! - Reject non-finite limit-order trigger prices and expiry values before wallet or API activity.
64
+
65
+ - [#567](https://github.com/nansen-ai/nansen-cli/pull/567) [`c0fe57b`](https://github.com/nansen-ai/nansen-cli/commit/c0fe57b6f881aa7338a16f6aa2fd33d5e6d42757) Thanks [@Kewe63](https://github.com/Kewe63)! - Keep `research perp` analytics-only instead of routing trading subcommands through the top-level perp dispatcher.
66
+
67
+ - [#558](https://github.com/nansen-ai/nansen-cli/pull/558) [`002921e`](https://github.com/nansen-ai/nansen-cli/commit/002921e5d72e7e54de26f241cb4b409e408b8bf9) Thanks [@gulshngill](https://github.com/gulshngill)! - Reject non-integer or non-positive `--limit` values with an actionable error instead of forwarding them to the API.
68
+
3
69
  ## 1.43.1
4
70
 
5
71
  ### Patch Changes
package/README.md CHANGED
@@ -51,11 +51,23 @@ nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
51
51
  nansen trade execute --quote <quoteId>
52
52
  nansen wallet <subcommand> [options]
53
53
  nansen mcp install <client> # add the Nansen MCP server to Claude Code/Desktop or Cursor
54
+ nansen completion <bash|zsh|fish> # shell completions (no API key needed)
54
55
  nansen schema [command] [--pretty] # full command reference (no API key needed)
55
56
  ```
56
57
 
57
58
  **Research categories:** `smart-money` (`sm`), `token` (`tgm`), `profiler` (`prof`), `portfolio` (`port`), `prediction-market` (`pm`), `search`, `perp`, `points`
58
59
 
60
+ **Direct research subcommands:**
61
+ - `nansen research chain-rank [--timeframe-days 7|30|365] [--chain-type all|evm]` — rank chains by growth metrics
62
+ - `nansen research token-sectors` — list token sectors available for filtering
63
+ - `nansen research address-premium-labels --address <addr> [--chain <chain>] [--page <n>] [--limit <n>]` — get all labels for an address, including premium labels
64
+ - `nansen research smart-money-pnl-leaderboard [--chains c1,c2] [--timeframe-days 1|7|30|90|180] [--filters '<json>'] [--sort <field[:asc|desc]>] [--page <n>] [--limit <n>]` — rank smart money wallets by PnL
65
+ - `nansen research position-intelligence --symbol <symbol>` — aggregate Hyperliquid positions by trader cohort
66
+ - `nansen research perp-pnl-summary --address <addr> --from-date <date> --to-date <date>` — summarize realized Hyperliquid PnL for an address
67
+ - `nansen research transaction-with-token-transfer-lookup --transaction-hash <hash> [--chain <chain>] [--block-timestamp "YYYY-MM-DD HH:MM:SS"]` — look up a transaction and its token/NFT transfers
68
+
69
+ Plus the `historical-*` point-in-time commands — run `nansen research help` for the full list.
70
+
59
71
  **Trade:** `quote`, `execute`, `bridge-status`, `limit-order` — DEX swaps on Solana and Base, cross-chain bridges, and Solana limit orders.
60
72
 
61
73
  **Wallet:** `create`, `list`, `show`, `export`, `default`, `delete`, `send` — local or Privy server-side wallets (EVM + Solana).
@@ -253,6 +265,28 @@ tempo request POST https://api.nansen.ai/api/v1/smart-money/netflow \
253
265
 
254
266
  > Note: MPP is server-side opt-in (`MPP_ENABLED=true` on the API). It's available on dev today and rolling out to prod — if `tempo request` returns a non-MPP 402, fall back to x402 or an API key.
255
267
 
268
+ ## Shell Completions
269
+
270
+ Tab-completion for commands, subcommands, flags, and the values a flag accepts:
271
+
272
+ ```bash
273
+ # bash
274
+ echo 'eval "$(nansen completion bash)"' >> ~/.bashrc
275
+ # or system-wide: nansen completion bash > /etc/bash_completion.d/nansen
276
+
277
+ # zsh
278
+ nansen completion zsh > "${fpath[1]}/_nansen" && compinit
279
+ # or, in ~/.zshrc after the compinit line: eval "$(nansen completion zsh)"
280
+
281
+ # fish
282
+ nansen completion fish > ~/.config/fish/completions/nansen.fish
283
+ ```
284
+
285
+ The scripts are generated from the same schema as `nansen schema`, so they cover
286
+ every nested subcommand and stay in step with the CLI. Nothing is written to
287
+ disk and no network call is made — the script goes to stdout. Re-run the command
288
+ after upgrading the CLI to pick up new commands.
289
+
256
290
  ## Key Options
257
291
 
258
292
  | Option | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.43.1",
3
+ "version": "1.44.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",
package/src/api.js CHANGED
@@ -19,6 +19,16 @@ import { readResponseMeta } from './response-meta.js';
19
19
  */
20
20
  export const RESPONSE_META = Symbol('nansenResponseMeta');
21
21
 
22
+ /**
23
+ * Sentinel returned by _x402Retry to mean "this payment option was cleanly
24
+ * rejected without settlement, safe to try the next option" — distinct from
25
+ * a genuine successful response whose JSON body happens to be `null`.
26
+ * Using `null` for both (the previous behavior) made a legitimate null-body
27
+ * success indistinguishable from a clean rejection, so the caller would sign
28
+ * and transmit ANOTHER payment for a request that had already succeeded.
29
+ */
30
+ export const X402_PAYMENT_REJECTED = Symbol('x402PaymentRejected');
31
+
22
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
33
 
24
34
  export function telemetryHeaders() {
@@ -65,6 +75,7 @@ export const ErrorCode = {
65
75
  // Client Errors
66
76
  NETWORK_ERROR: 'NETWORK_ERROR', // Connection failed
67
77
  TIMEOUT: 'TIMEOUT', // Request timed out
78
+ PAYMENT_AMBIGUOUS: 'PAYMENT_AMBIGUOUS', // x402 payment outcome unknown after transmission — do not retry with another payment
68
79
 
69
80
  // Generic
70
81
  UNKNOWN: 'UNKNOWN', // Unclassified error
@@ -530,7 +541,14 @@ export class NansenAPI {
530
541
 
531
542
  /**
532
543
  * Retry a POST request with a payment signature.
533
- * Returns parsed JSON if the paid request succeeds, or null if still rejected.
544
+ * Returns parsed JSON if the paid request succeeds, or the X402_PAYMENT_REJECTED
545
+ * sentinel if the server cleanly, legibly rejected it without settling.
546
+ * Throws a NansenError(PAYMENT_AMBIGUOUS) — instead of returning the sentinel —
547
+ * for any outcome that doesn't prove the payment was rejected: a transport
548
+ * failure after transmission, an HTTP 5xx, or a response body that can't be
549
+ * parsed. In all of those cases the server may already have received and
550
+ * settled the payment, so the caller must not treat it as safe to retry with
551
+ * a different option — that would risk paying twice for the same request.
534
552
  * Logs the payment and warns about low balance when walletLabel and network are given.
535
553
  *
536
554
  * @param {string} signature - Payment-Signature header value
@@ -539,7 +557,7 @@ export class NansenAPI {
539
557
  * @param {string} url - Request URL
540
558
  * @param {object} body - Request body (will be cleaned)
541
559
  * @param {object} [options={}] - Request options (may include .method, .headers)
542
- * @returns {Promise<object|null>} Parsed JSON on success, null if rejected
560
+ * @returns {Promise<object|typeof X402_PAYMENT_REJECTED>}
543
561
  *
544
562
  * TODO: full fix — extract the entire x402 provider dispatch from request() into
545
563
  * an attemptX402Payment() method so adding a new payment provider only requires
@@ -550,21 +568,52 @@ export class NansenAPI {
550
568
  // POST burned a payment signature then hit the wrong route for GET/DELETE/PATCH.
551
569
  const method = options.method || 'POST';
552
570
  const isGet = method === 'GET';
553
- const paidResponse = await fetch(url, {
554
- method,
555
- redirect: 'error',
556
- headers: {
557
- ...(!isGet && { 'Content-Type': 'application/json' }),
558
- 'X-Client-Type': 'nansen-cli',
559
- 'X-Client-Version': packageVersion,
560
- ...telemetryHeaders(),
561
- 'Payment-Signature': signature,
562
- ...this.defaultHeaders,
563
- ...options.headers,
564
- },
565
- ...(!isGet && method !== 'DELETE' && { body: JSON.stringify(NansenAPI.cleanBody(body)) }),
566
- });
567
- if (!paidResponse.ok) return null;
571
+ let paidResponse;
572
+ try {
573
+ paidResponse = await fetch(url, {
574
+ method,
575
+ redirect: 'error',
576
+ headers: {
577
+ ...(!isGet && { 'Content-Type': 'application/json' }),
578
+ 'X-Client-Type': 'nansen-cli',
579
+ 'X-Client-Version': packageVersion,
580
+ ...telemetryHeaders(),
581
+ 'Payment-Signature': signature,
582
+ ...this.defaultHeaders,
583
+ ...options.headers,
584
+ },
585
+ ...(!isGet && method !== 'DELETE' && { body: JSON.stringify(NansenAPI.cleanBody(body)) }),
586
+ });
587
+ } catch (err) {
588
+ // The signature was already on the wire when the connection failed —
589
+ // the server may have received and settled it before we lost the
590
+ // response. Fail closed rather than let the caller sign and send a
591
+ // second payment for the same logical request.
592
+ throw new NansenError(
593
+ `x402 payment outcome unknown: request failed after the signed payment was transmitted (${err.message}). Not attempting another payment for the same request.`,
594
+ ErrorCode.PAYMENT_AMBIGUOUS,
595
+ );
596
+ }
597
+ if (!paidResponse.ok) {
598
+ // A 5xx doesn't prove the payment was rejected — the server could have
599
+ // processed it before failing to respond. Only a readable non-5xx
600
+ // rejection body is safe to treat as "try the next option".
601
+ if (paidResponse.status >= 500) {
602
+ throw new NansenError(
603
+ `x402 payment outcome unknown: server returned ${paidResponse.status} after the signed payment was transmitted. Not attempting another payment for the same request.`,
604
+ ErrorCode.PAYMENT_AMBIGUOUS,
605
+ );
606
+ }
607
+ try {
608
+ await paidResponse.json();
609
+ } catch (err) {
610
+ throw new NansenError(
611
+ `x402 payment outcome unknown: rejection response body was unreadable (${err.message}). Not attempting another payment for the same request.`,
612
+ ErrorCode.PAYMENT_AMBIGUOUS,
613
+ );
614
+ }
615
+ return X402_PAYMENT_REJECTED;
616
+ }
568
617
  if (walletLabel) {
569
618
  console.error(`[x402] Paid via ${walletLabel}${network ? ` (${network})` : ''}`);
570
619
  }
@@ -577,7 +626,18 @@ export class NansenAPI {
577
626
  }
578
627
  } catch { /* balance check is best-effort */ }
579
628
  }
580
- const data = await paidResponse.json();
629
+ let data;
630
+ try {
631
+ data = await paidResponse.json();
632
+ } catch (err) {
633
+ // The payment was accepted (2xx) — it settled. We just can't read the
634
+ // response body, so surface that plainly rather than silently treating
635
+ // it as a rejection and paying again.
636
+ throw new NansenError(
637
+ `x402 payment succeeded but its response body was unreadable (${err.message}). The payment was not repeated.`,
638
+ ErrorCode.PAYMENT_AMBIGUOUS,
639
+ );
640
+ }
581
641
  const meta = readResponseMeta(paidResponse);
582
642
  this.lastResponseMeta = meta;
583
643
  if (meta && data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
@@ -712,9 +772,14 @@ export class NansenAPI {
712
772
  const { createPrivyPaymentSignatures } = await import('./privy.js');
713
773
  for await (const { signature, network } of createPrivyPaymentSignatures(response, url)) {
714
774
  const result = await this._x402Retry(signature, `Privy wallet ${defaultWalletName}`, network, url, body, options);
715
- if (result !== null) return result;
775
+ if (result !== X402_PAYMENT_REJECTED) return result;
716
776
  }
717
777
  } catch (privyErr) {
778
+ // An ambiguous outcome (transport failure, 5xx, unreadable body)
779
+ // after a signed payment was already transmitted must not be
780
+ // treated as an ordinary payment failure — there is no other
781
+ // provider to fall back to here, and retrying could double-pay.
782
+ if (privyErr instanceof NansenError && privyErr.code === ErrorCode.PAYMENT_AMBIGUOUS) throw privyErr;
718
783
  message = `x402 Privy payment failed: ${privyErr.message}`;
719
784
  }
720
785
  } else {
@@ -724,10 +789,17 @@ export class NansenAPI {
724
789
  const { createPaymentSignatures } = await import('./x402.js');
725
790
  for await (const { signature, network, asset } of createPaymentSignatures(response, url)) {
726
791
  const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options, asset);
727
- if (result !== null) return result;
728
- // This payment option was rejected, try next
792
+ if (result !== X402_PAYMENT_REJECTED) return result;
793
+ // This payment option was cleanly rejected without settling, try next
729
794
  }
730
- } catch { /* local wallet unavailable, try WalletConnect */ }
795
+ } catch (localErr) {
796
+ // An ambiguous outcome here means a signed payment may already
797
+ // be in flight or settled server-side. Do NOT fall through to
798
+ // WalletConnect below — that would sign and transmit a second,
799
+ // independent payment authorization for the same request.
800
+ if (localErr instanceof NansenError && localErr.code === ErrorCode.PAYMENT_AMBIGUOUS) throw localErr;
801
+ /* local wallet unavailable for any other reason, try WalletConnect */
802
+ }
731
803
 
732
804
  // 2. Fall back to WalletConnect (walletconnect-x402.js)
733
805
  {
@@ -749,8 +821,13 @@ export class NansenAPI {
749
821
  const { handleX402Payment } = await import('./walletconnect-x402.js');
750
822
  const paymentSignature = await handleX402Payment(paymentRequirements);
751
823
  const result = await this._x402Retry(paymentSignature, 'WalletConnect', null, url, body, options);
752
- if (result !== null) return result;
824
+ if (result !== X402_PAYMENT_REJECTED) return result;
753
825
  } catch (x402Err) {
826
+ // WalletConnect is the last resort in this chain — an
827
+ // ambiguous outcome here still must not be reported as an
828
+ // ordinary "payment failed" that invites the caller to
829
+ // retry the whole request (and sign yet another payment).
830
+ if (x402Err instanceof NansenError && x402Err.code === ErrorCode.PAYMENT_AMBIGUOUS) throw x402Err;
754
831
  if (!this.apiKey) {
755
832
  message = 'No API key configured. Three ways to authenticate:\n' +
756
833
  ' 1. API key: run `nansen login --human` or set NANSEN_API_KEY (get key at https://app.nansen.ai/auth/agent-setup)\n' +
@@ -834,6 +911,16 @@ export class NansenAPI {
834
911
  return this.request('/api/v1/account', {}, { method: 'GET', cache: false });
835
912
  }
836
913
 
914
+ // ============= Chain Endpoints =============
915
+
916
+ async chainRank(params = {}) {
917
+ const { timeFrame = 7, chainType = 'all' } = params;
918
+ return this.request('/api/v1/chains/chain-rank', {
919
+ time_frame: timeFrame,
920
+ chain_type: chainType
921
+ });
922
+ }
923
+
837
924
  // ============= Smart Money Endpoints =============
838
925
 
839
926
  async smartMoneyNetflow(params = {}) {
@@ -896,6 +983,17 @@ export class NansenAPI {
896
983
  });
897
984
  }
898
985
 
986
+ async smartMoneyPnlLeaderboard(params = {}) {
987
+ const { chains = ['solana'], timeframe = 7, filters = {}, orderBy, pagination } = params;
988
+ return this.request('/api/v1/smart-money/pnl-leaderboard', {
989
+ chains,
990
+ timeframe,
991
+ filters,
992
+ order_by: orderBy,
993
+ pagination
994
+ });
995
+ }
996
+
899
997
  // ============= Profiler Endpoints =============
900
998
 
901
999
  async addressBalance(params = {}) {
@@ -921,6 +1019,16 @@ export class NansenAPI {
921
1019
  });
922
1020
  }
923
1021
 
1022
+ async addressPremiumLabels(params = {}) {
1023
+ const { address, chain = 'all', pagination = { page: 1, per_page: 100 } } = params;
1024
+ if (address) requireValidAddress(address, chain);
1025
+ return this.request('/api/v1/profiler/address/premium-labels', {
1026
+ address,
1027
+ chain,
1028
+ pagination
1029
+ });
1030
+ }
1031
+
924
1032
  async addressTransactions(params = {}) {
925
1033
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30, date } = params;
926
1034
  if (address) requireValidAddress(address, chain);
@@ -970,6 +1078,10 @@ export class NansenAPI {
970
1078
  return this.request('/api/v1/search/general', body);
971
1079
  }
972
1080
 
1081
+ async tokenSectors() {
1082
+ return this.request('/api/v1/search/token-sectors', {}, { method: 'GET' });
1083
+ }
1084
+
973
1085
  async webSearch(params = {}) {
974
1086
  const { queries, numResults = 10 } = params;
975
1087
  if (!queries || queries.length === 0) {
@@ -1092,6 +1204,25 @@ export class NansenAPI {
1092
1204
  });
1093
1205
  }
1094
1206
 
1207
+ async addressPerpPnlSummary(params = {}) {
1208
+ const { address, fromDate, toDate } = params;
1209
+ // HL addresses are EVM-format, so ethereum validation accepts every valid HL address
1210
+ if (address) requireValidAddress(address, 'ethereum');
1211
+ return this.request('/api/v1/profiler/perp-pnl-summary', {
1212
+ address,
1213
+ date: { from: fromDate, to: toDate }
1214
+ });
1215
+ }
1216
+
1217
+ async transactionWithTokenTransferLookup(params = {}) {
1218
+ const { chain = 'ethereum', transactionHash, blockTimestamp } = params;
1219
+ return this.request('/api/v1/transaction-with-token-transfer-lookup', {
1220
+ chain,
1221
+ transaction_hash: transactionHash,
1222
+ block_timestamp: blockTimestamp
1223
+ });
1224
+ }
1225
+
1095
1226
  // ============= Token God Mode Endpoints =============
1096
1227
 
1097
1228
  async tokenScreener(params = {}) {
@@ -1240,6 +1371,13 @@ export class NansenAPI {
1240
1371
  });
1241
1372
  }
1242
1373
 
1374
+ async tokenPositionIntelligence(params = {}) {
1375
+ const { tokenAddress } = params;
1376
+ return this.request('/api/v1/tgm/position-intelligence', {
1377
+ token_address: tokenAddress
1378
+ });
1379
+ }
1380
+
1243
1381
  async tokenPerpPnlLeaderboard(params = {}) {
1244
1382
  const { tokenSymbol, filters = {}, orderBy, pagination, days = 30, withLabels } = params;
1245
1383
  const body = {
@@ -1632,6 +1770,29 @@ export class NansenAPI {
1632
1770
  });
1633
1771
  }
1634
1772
 
1773
+ async researchHistoricalTokenOhlcv(params = {}) {
1774
+ const { chain = 'solana', tokenAddress, fromDate, asOfDate, asOfTs, timeframe, applyBlacklistFilter } = params;
1775
+ if (tokenAddress) requireValidToken(tokenAddress, chain);
1776
+ if (!fromDate) {
1777
+ throw new NansenError('fromDate is required', ErrorCode.MISSING_PARAM);
1778
+ }
1779
+ if (asOfDate && asOfTs) {
1780
+ throw new NansenError('asOfDate and asOfTs are mutually exclusive', ErrorCode.INVALID_PARAMS);
1781
+ }
1782
+ if (!asOfDate && !asOfTs) {
1783
+ throw new NansenError('One of asOfDate or asOfTs is required', ErrorCode.MISSING_PARAM);
1784
+ }
1785
+ return this.request('/api/v1beta1/tgm/historical-token-ohlcv', {
1786
+ chain,
1787
+ token_address: tokenAddress,
1788
+ date_from: fromDate,
1789
+ as_of_date: asOfDate,
1790
+ as_of_ts: asOfTs,
1791
+ timeframe,
1792
+ apply_blacklist_filter: applyBlacklistFilter
1793
+ });
1794
+ }
1795
+
1635
1796
  // ============= Smart Alert Endpoints =============
1636
1797
 
1637
1798
  async alertsList(params = {}) {
package/src/bridge.js CHANGED
@@ -1077,6 +1077,56 @@ export async function resolveEvmStepFees(chain, txData, overrides = {}) {
1077
1077
  return { gasPrice: await evmRpcCall(chain, 'eth_gasPrice') };
1078
1078
  }
1079
1079
 
1080
+ // eth_sendRawTransaction JSON-RPC error messages that PROVE the tx never
1081
+ // entered the mempool: the node rejected it during validation, so nothing is in
1082
+ // flight and the quote stays reusable. Matched case-insensitively as substrings
1083
+ // of the node's error text. Every entry here must be UNAMBIGUOUSLY pre-broadcast
1084
+ // across EVM node implementations. Everything NOT listed fails closed — crucially
1085
+ // the txpool states ("already known", "known transaction", "already imported",
1086
+ // "nonce too low", "replacement transaction underpriced") that mean a tx is
1087
+ // ALREADY in flight; treating those as safe rejections would let a re-execute
1088
+ // sign a fresh nonce and broadcast a second bridge tx.
1089
+ //
1090
+ // Deliberately EXCLUDED: the bare "transaction underpriced". On go-ethereum /
1091
+ // op-geth (which is what Base — today's only EVM bridge chain — runs) it means a
1092
+ // fresh too-low-fee tx that was never accepted, i.e. safe. But some other nodes
1093
+ // (certain Besu / Nethermind versions) reuse that same bare message for a failed
1094
+ // REPLACEMENT (a nonce collision with an in-flight tx). Since the allowlist is
1095
+ // not chain-scoped and processEvmStep runs for whatever CHAIN_RPCS resolves, we
1096
+ // fail closed on it rather than risk a future non-geth chain misclassifying an
1097
+ // in-flight tx as safe. Cost is at most a needless re-quote.
1098
+ const PRE_BROADCAST_SEND_ERRORS = [
1099
+ 'insufficient funds',
1100
+ 'intrinsic gas too low',
1101
+ 'gas too low',
1102
+ 'exceeds block gas limit',
1103
+ 'exceeds the block gas limit',
1104
+ 'max fee per gas less than block base fee',
1105
+ 'fee cap less than block base fee',
1106
+ 'max priority fee per gas higher than max fee per gas',
1107
+ 'invalid sender',
1108
+ 'invalid signature',
1109
+ 'could not decode',
1110
+ 'rlp: ', // geth's RLP decode errors ("rlp: input string too long", …); the
1111
+ // trailing space avoids matching "rlp" embedded in an unrelated message
1112
+ 'negative value',
1113
+ ];
1114
+
1115
+ // True when we are CONFIDENT a failed send never put the tx into the mempool, so
1116
+ // the bridge quote may be retried. This is deliberately NARROW: a definitive
1117
+ // answer requires a JSON-RPC rejection (RPC_JSON_ERROR) whose message is on the
1118
+ // pre-broadcast allowlist, or a missing-RPC config error (the request never
1119
+ // left this process). A transport/HTTP failure, or ANY other JSON-RPC error
1120
+ // (including in-flight txpool states and the cross-node-ambiguous bare
1121
+ // "transaction underpriced"), is treated as unsafe and must fail closed — see
1122
+ // evmRpcCall (trading.js) for the error codes.
1123
+ function isPreBroadcastSendRejection(err) {
1124
+ if (err?.code === 'RPC_UNCONFIGURED') return true;
1125
+ if (err?.code !== 'RPC_JSON_ERROR') return false;
1126
+ const msg = (err.message || '').toLowerCase();
1127
+ return PRE_BROADCAST_SEND_ERRORS.some(pattern => msg.includes(pattern));
1128
+ }
1129
+
1080
1130
  async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log, onBroadcast, feeOverrides, nonceSequence, intent }) {
1081
1131
  for (const item of step.items || []) {
1082
1132
  if (item.status === 'complete') continue;
@@ -1114,7 +1164,20 @@ async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log,
1114
1164
  );
1115
1165
 
1116
1166
  log(` Broadcasting ${step.id} on ${chain}...`);
1117
- const txHash = await evmRpcCall(chain, 'eth_sendRawTransaction', [signedTx]);
1167
+ let txHash;
1168
+ try {
1169
+ txHash = await evmRpcCall(chain, 'eth_sendRawTransaction', [signedTx]);
1170
+ } catch (sendErr) {
1171
+ // Fail closed UNLESS we're confident the tx never entered the mempool (a
1172
+ // pre-broadcast validation rejection, or no RPC configured). Everything
1173
+ // else — a lost ack, a gateway error, OR an in-flight txpool state like
1174
+ // "already known" / "nonce too low" that a node reports as a JSON-RPC
1175
+ // error — may already be broadcasting, so mark the quote spent before
1176
+ // rethrowing to abort, so a later re-execute can't re-sign this step at a
1177
+ // fresh nonce and double-broadcast.
1178
+ if (!isPreBroadcastSendRejection(sendErr)) onBroadcast?.(step.id, null);
1179
+ throw sendErr;
1180
+ }
1118
1181
  // In flight now: record it before waiting on the receipt, because a receipt
1119
1182
  // timeout must not leave the quote reusable.
1120
1183
  onBroadcast?.(step.id, txHash);
@@ -1158,7 +1221,16 @@ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance
1158
1221
  }
1159
1222
 
1160
1223
  log(` Signing ${step.id} (EIP-712)...`);
1161
- await postBridgeExecute(apiInstance, targetUrl, postBody);
1224
+ // Fail closed on ANY submit failure: the proxy may have forwarded the
1225
+ // authorization before the error surfaced, and we can't tell that from a
1226
+ // clean pre-forward rejection. Consume the quote before rethrowing so a
1227
+ // re-execute can't resubmit this leg.
1228
+ try {
1229
+ await postBridgeExecute(apiInstance, targetUrl, postBody);
1230
+ } catch (sendErr) {
1231
+ onBroadcast?.(step.id, null);
1232
+ throw sendErr;
1233
+ }
1162
1234
  onBroadcast?.(step.id, null);
1163
1235
  log(` Submitted to ${new URL(targetUrl).hostname}`);
1164
1236
  } else if (signData.action) {
@@ -1196,7 +1268,17 @@ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance
1196
1268
  };
1197
1269
 
1198
1270
  log(` Signing ${step.id} (Hyperliquid deposit)...`);
1199
- const result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
1271
+ // Fail closed if the SUBMIT itself throws (transport/proxy error — HL may
1272
+ // still have received the action). A clean HL rejection instead returns
1273
+ // 200 and is caught by assertHyperliquidStepAccepted below, OUTSIDE this
1274
+ // try: that is a definitive no-op, so it must NOT consume the quote.
1275
+ let result;
1276
+ try {
1277
+ result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
1278
+ } catch (sendErr) {
1279
+ onBroadcast?.(step.id, null);
1280
+ throw sendErr;
1281
+ }
1200
1282
  assertHyperliquidStepAccepted(result, step.id);
1201
1283
  onBroadcast?.(step.id, null);
1202
1284
  log(` Submitted to api.hyperliquid.xyz`);
@@ -1270,7 +1352,14 @@ async function processSignatureStepPrivy(step, { privyClient, walletId, log, api
1270
1352
  } else {
1271
1353
  postBody.signature = signature;
1272
1354
  }
1273
- await postBridgeExecute(apiInstance, targetUrl, postBody);
1355
+ // Fail closed on any submit failure — see the local path for the rationale
1356
+ // (an ambiguous forward must not leave the quote reusable).
1357
+ try {
1358
+ await postBridgeExecute(apiInstance, targetUrl, postBody);
1359
+ } catch (sendErr) {
1360
+ onBroadcast?.(step.id, null);
1361
+ throw sendErr;
1362
+ }
1274
1363
  onBroadcast?.(step.id, null);
1275
1364
  } else {
1276
1365
  const [rHex, sHex, vHex] = [signature.slice(2, 66), signature.slice(66, 130), signature.slice(130, 132)];
@@ -1279,7 +1368,16 @@ async function processSignatureStepPrivy(step, { privyClient, walletId, log, api
1279
1368
  nonce: signData.nonce,
1280
1369
  signature: { r: '0x' + rHex, s: '0x' + sHex, v: parseInt(vHex, 16) },
1281
1370
  };
1282
- const result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
1371
+ // Submit-throw = ambiguous fail closed; a clean HL rejection returns 200
1372
+ // and is caught by assertHyperliquidStepAccepted (outside the try) as a
1373
+ // definitive no-op that must NOT consume the quote.
1374
+ let result;
1375
+ try {
1376
+ result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
1377
+ } catch (sendErr) {
1378
+ onBroadcast?.(step.id, null);
1379
+ throw sendErr;
1380
+ }
1283
1381
  assertHyperliquidStepAccepted(result, step.id);
1284
1382
  onBroadcast?.(step.id, null);
1285
1383
  }