nansen-cli 1.36.2 → 1.37.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#481](https://github.com/nansen-ai/nansen-cli/pull/481) [`ccaa40b`](https://github.com/nansen-ai/nansen-cli/commit/ccaa40beb570c2a6df5917ded308c3bb1722eb70) Thanks [@kome12](https://github.com/kome12)! - Add `research profiler first-funder` command to look up the first wallet that funded an EVM address. The funder is the earliest address to send native gas, resolved across chains, returned with its Nansen label and the funding transaction.
8
+
9
+ - [#485](https://github.com/nansen-ai/nansen-cli/pull/485) [`b49c758`](https://github.com/nansen-ai/nansen-cli/commit/b49c75866379421c0738032e1f98a4a74b3fb5b4) Thanks [@MarcLlopart](https://github.com/MarcLlopart)! - `nansen perp order` and `perp close` now print the Hyperliquid order id (`oid`) and fill (size @ avg price) returned by the exchange, plus a ready-to-run `nansen perp cancel` command for any resting order — mirroring how spot trading surfaces its quote id. TP/SL bracket legs are labelled (parent / take-profit / stop-loss). Order ids are uint64; an id beyond JavaScript's safe integer range (2^53) is detected and its exact value and cancel hint are withheld rather than shown rounded, so a wrong id is never presented as actionable.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#483](https://github.com/nansen-ai/nansen-cli/pull/483) [`d0d10a2`](https://github.com/nansen-ai/nansen-cli/commit/d0d10a266e32aa086a8934acaa8e1d0b9ddff2e2) Thanks [@kome12](https://github.com/kome12)! - Unknown-command errors now detect when a whole multi-word command was passed as a single argument (a common shell-quoting mistake, e.g. `nansen "trade --help"` or an unquoted variable under zsh) and point at the likely cause instead of a bare "Unknown command".
14
+
15
+ - [#484](https://github.com/nansen-ai/nansen-cli/pull/484) [`bc5f774`](https://github.com/nansen-ai/nansen-cli/commit/bc5f774165df7103eff9ba5cfcdc660bcec5d752) Thanks [@kome12](https://github.com/kome12)! - Write the cost-map and update-check cache files atomically (temp file + rename) so concurrent `nansen` processes can no longer observe an empty or truncated cache.
16
+
17
+ - [#465](https://github.com/nansen-ai/nansen-cli/pull/465) [`4105193`](https://github.com/nansen-ai/nansen-cli/commit/41051932236a37121819e0d1bf47c8fb34422ec8) Thanks [@dobbydobap](https://github.com/dobbydobap)! - Fix `nansen quote --help`, `nansen trade quote --help`, and `nansen execute --help` to print the trade usage and exit with code 0 instead of erroring with exit code 1.
18
+
19
+ - [#485](https://github.com/nansen-ai/nansen-cli/pull/485) [`c9aaf58`](https://github.com/nansen-ai/nansen-cli/commit/c9aaf58923819c014588cb2c068600ad9872276e) Thanks [@MarcLlopart](https://github.com/MarcLlopart)! - `nansen perp order` / `perp close` now emit an anonymous `perp_order_completed` telemetry event after the Hyperliquid `/exchange` response is parsed. Perp orders bypass the Nansen API on submit (the CLI signs and posts straight to Hyperliquid), so this client-side event is the only signal that an order was placed. The payload is deliberately minimal — only the trade side and the Hyperliquid order id (omitted when it exceeded JS safe-integer precision); no asset, price, size, or fill detail is sent. The telemetry disclosure (CLI help footer and module docs) names exactly these fields. Honours the existing `DO_NOT_TRACK` / `NANSEN_NO_TELEMETRY` opt-out; order rejections remain covered by `cli_command_failed`.
20
+
21
+ - [#478](https://github.com/nansen-ai/nansen-cli/pull/478) [`758ce13`](https://github.com/nansen-ai/nansen-cli/commit/758ce13b7c65a5a88d20378ae1ad5cc7bba7d7ba) Thanks [@boleklebovski](https://github.com/boleklebovski)! - Document the missing `trade quote` and `trade execute` options in `src/schema.json`: `--swap-mode`, `--slippage`, `--auto-slippage`, `--max-auto-slippage`, `--quote`, `--quote-index` and `--no-simulate`. These options are already implemented and documented for humans, but were absent from the machine-readable schema.
22
+
23
+ - [#488](https://github.com/nansen-ai/nansen-cli/pull/488) [`f653b37`](https://github.com/nansen-ai/nansen-cli/commit/f653b3761a4abc8e8a45d3ff42cedf0241a8ff20) Thanks [@gulshngill](https://github.com/gulshngill)! - Warn on logout when `NANSEN_API_KEY` remains active in the environment.
24
+
3
25
  ## 1.36.2
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.36.2",
3
+ "version": "1.37.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",
@@ -216,6 +216,8 @@ nansen perp order --coin BTC --side sell --size 0.001 --price 95000 --type marke
216
216
  - `--type`: `limit` (default) or `market`. `--tif`: `Gtc` (default), `Ioc`, `Alo`.
217
217
  - `--slippage`: decimal in `[0,1]` for market orders (default `0.03` = 3%).
218
218
 
219
+ On success the command prints the Hyperliquid order id (`oid`) and the fill (size @ avg price). A resting (unfilled) order also prints a ready-to-run `nansen perp cancel --coin <coin> --oid <oid>`. Attached take-profit/stop-loss legs are labelled and each print their own `oid`.
220
+
219
221
  ## Close / cancel
220
222
 
221
223
  ```bash
package/src/api.js CHANGED
@@ -1012,6 +1012,17 @@ export class NansenAPI {
1012
1012
  });
1013
1013
  }
1014
1014
 
1015
+ async addressFirstFunder(params = {}) {
1016
+ const { address } = params;
1017
+ // EVM addresses only; the funder is resolved across chains server-side, so
1018
+ // chain is fixed to 'all' and the endpoint forbids any extra fields.
1019
+ if (address) requireValidAddress(address, 'ethereum');
1020
+ return this.request('/api/v1/profiler/address/first-funder', {
1021
+ address,
1022
+ chain: 'all'
1023
+ });
1024
+ }
1025
+
1015
1026
  async addressCounterparties(params = {}) {
1016
1027
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
1017
1028
  if (address) requireValidAddress(address, chain);
package/src/cli.js CHANGED
@@ -771,9 +771,54 @@ Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
771
771
  Docs: https://docs.nansen.ai
772
772
  Skills: npx skills add nansen-ai/nansen-cli (agent-optimised docs per command group)
773
773
 
774
- Telemetry: anonymous usage stats collected. Disable: DO_NOT_TRACK=1
774
+ Telemetry: anonymous usage stats (commands, timing, errors). Perp order/close additionally send the order side and Hyperliquid order id. Disable: DO_NOT_TRACK=1
775
775
  `;
776
776
 
777
+ // Usage text for the `trade` command group. Shared by the trade handler and the
778
+ // --help path in runCLI, so `nansen trade`, `nansen trade <sub> --help`, and the
779
+ // deprecated top-level `quote`/`execute --help` all show the same usage.
780
+ export const TRADE_USAGE = `nansen trade — DEX trading commands
781
+
782
+ SUBCOMMANDS:
783
+ quote Get a swap quote (price, route, fees)
784
+ execute Sign and broadcast a quoted swap
785
+ bridge-status Check cross-chain bridge transaction status
786
+ limit-order Limit order management (Solana only)
787
+
788
+ USAGE:
789
+ nansen trade quote --chain <chain> --from <token> --to <token> --amount <units> [--wallet <name>]
790
+ nansen trade quote --chain <chain> --to-chain <chain> --from <token> --to <token> --amount <units>
791
+ nansen trade execute --quote <quoteId> [--wallet <name>]
792
+ nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
793
+ nansen trade limit-order <create|list|cancel|update> [options]
794
+
795
+ EXAMPLES:
796
+ nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
797
+ nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
798
+ nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
799
+ nansen trade execute --quote 1708900000000-abc123
800
+ nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
801
+ nansen trade limit-order create --from SOL --to USDC --amount 1.5 --trigger-mint SOL --trigger-condition below --trigger-price 80
802
+ nansen trade limit-order list
803
+
804
+ WALLET:
805
+ --wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect.
806
+ Defaults to the default local wallet if omitted.
807
+
808
+ SYMBOLS:
809
+ Common tokens resolve automatically: SOL, ETH, USDC, USDT, WETH
810
+ Raw addresses are also accepted.
811
+
812
+ CROSS-CHAIN NOTES (when using --to-chain):
813
+ Supported combos:
814
+ native → native (ETH <-> SOL)
815
+ USDC → USDC (both directions)
816
+ USDC → native (USDC → ETH or SOL)
817
+ native → USDC (ETH/SOL → USDC)
818
+ non-native → non-native — not supported (use USDC as intermediate)
819
+ Bridge providers: Li.Fi or Relay (selected automatically based on best price)
820
+ Typical bridge time: 1-5 minutes`;
821
+
777
822
  // Helper to prompt for input (exported for mocking)
778
823
  export async function prompt(question, hidden = false) {
779
824
  return new Promise((resolve) => {
@@ -832,7 +877,8 @@ export function buildCommands(deps = {}) {
832
877
  saveConfigFn = saveConfig,
833
878
  deleteConfigFn = deleteConfig,
834
879
  getConfigFileFn = getConfigFile,
835
- isTTY = process.stdin.isTTY
880
+ isTTY = process.stdin.isTTY,
881
+ env = process.env
836
882
  } = deps;
837
883
 
838
884
  const cmds = {
@@ -1004,6 +1050,9 @@ export function buildCommands(deps = {}) {
1004
1050
  } else {
1005
1051
  log('No saved credentials found');
1006
1052
  }
1053
+ if (env.NANSEN_API_KEY) {
1054
+ log('Warning: NANSEN_API_KEY remains active. Run: unset NANSEN_API_KEY');
1055
+ }
1007
1056
  },
1008
1057
 
1009
1058
  'help': async (_args, _apiInstance, _flags, _options) => {
@@ -1147,7 +1196,8 @@ export function buildCommands(deps = {}) {
1147
1196
  let ensName;
1148
1197
  if (address && isEnsName(address)) {
1149
1198
  try {
1150
- const resolved = await resolveAddress(address, chain);
1199
+ const ensChain = subcommand === 'first-funder' ? 'ethereum' : chain;
1200
+ const resolved = await resolveAddress(address, ensChain);
1151
1201
  address = resolved.address;
1152
1202
  ensName = resolved.ensName;
1153
1203
  } catch (err) {
@@ -1173,6 +1223,7 @@ export function buildCommands(deps = {}) {
1173
1223
  'search': () => apiInstance.entitySearch({ query: options.query }),
1174
1224
  'historical-balances': () => apiInstance.addressHistoricalBalances({ address, chain, filters, orderBy, pagination, days }),
1175
1225
  'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, orderBy, pagination }),
1226
+ 'first-funder': () => apiInstance.addressFirstFunder({ address }),
1176
1227
  'counterparties': () => apiInstance.addressCounterparties({ address, chain, filters, orderBy, pagination, days }),
1177
1228
  'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, orderBy, pagination, days }),
1178
1229
  'perp-positions': () => apiInstance.addressPerpPositions({ address, filters, orderBy, pagination }),
@@ -1219,7 +1270,7 @@ export function buildCommands(deps = {}) {
1219
1270
  return compareWallets(apiInstance, { addresses: addrs, chain, days });
1220
1271
  },
1221
1272
  'help': () => ({
1222
- commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades', 'dex-trades', 'batch', 'trace', 'compare'],
1273
+ commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'first-funder', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades', 'dex-trades', 'batch', 'trace', 'compare'],
1223
1274
  description: 'Wallet profiling endpoints',
1224
1275
  example: 'nansen research profiler compare --addresses "0xABC...,0xDEF..." --chain ethereum'
1225
1276
  })
@@ -1558,47 +1609,7 @@ export function buildCommands(deps = {}) {
1558
1609
  cmds['trade'] = async (args, apiInstance, flags, options) => {
1559
1610
  const sub = args[0];
1560
1611
  if (!sub || sub === 'help') {
1561
- log(`nansen trade — DEX trading commands
1562
-
1563
- SUBCOMMANDS:
1564
- quote Get a swap quote (price, route, fees)
1565
- execute Sign and broadcast a quoted swap
1566
- bridge-status Check cross-chain bridge transaction status
1567
- limit-order Limit order management (Solana only)
1568
-
1569
- USAGE:
1570
- nansen trade quote --chain <chain> --from <token> --to <token> --amount <units> [--wallet <name>]
1571
- nansen trade quote --chain <chain> --to-chain <chain> --from <token> --to <token> --amount <units>
1572
- nansen trade execute --quote <quoteId> [--wallet <name>]
1573
- nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
1574
- nansen trade limit-order <create|list|cancel|update> [options]
1575
-
1576
- EXAMPLES:
1577
- nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
1578
- nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
1579
- nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
1580
- nansen trade execute --quote 1708900000000-abc123
1581
- nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
1582
- nansen trade limit-order create --from SOL --to USDC --amount 1.5 --trigger-mint SOL --trigger-condition below --trigger-price 80
1583
- nansen trade limit-order list
1584
-
1585
- WALLET:
1586
- --wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect.
1587
- Defaults to the default local wallet if omitted.
1588
-
1589
- SYMBOLS:
1590
- Common tokens resolve automatically: SOL, ETH, USDC, USDT, WETH
1591
- Raw addresses are also accepted.
1592
-
1593
- CROSS-CHAIN NOTES (when using --to-chain):
1594
- Supported combos:
1595
- native → native (ETH <-> SOL)
1596
- USDC → USDC (both directions)
1597
- USDC → native (USDC → ETH or SOL)
1598
- native → USDC (ETH/SOL → USDC)
1599
- non-native → non-native — not supported (use USDC as intermediate)
1600
- Bridge providers: Li.Fi or Relay (selected automatically based on best price)
1601
- Typical bridge time: 1-5 minutes`);
1612
+ log(TRADE_USAGE);
1602
1613
  return;
1603
1614
  }
1604
1615
  if (sub === 'limit-order') {
@@ -1922,7 +1933,16 @@ export async function runCLI(rawArgs, deps = {}) {
1922
1933
  return { type: 'command-help', command };
1923
1934
  }
1924
1935
  }
1925
- // Commands with handlers (e.g. quote, execute) show their own usage
1936
+ // The trade group (and the deprecated top-level quote/execute aliases) use
1937
+ // handler-based usage rather than schema help. Show it and exit 0, instead of
1938
+ // falling through to command execution, which would error on missing required
1939
+ // args and exit 1.
1940
+ if (command === 'trade' || DEPRECATED_TO_TRADE.has(command)) {
1941
+ output(deprecationNote(command) + TRADE_USAGE);
1942
+ notify();
1943
+ return { type: 'command-help', command };
1944
+ }
1945
+ // 'help' and unknown commands: full banner + command list
1926
1946
  if (command === 'help' || !commands[command]) {
1927
1947
  output(BANNER + HELP);
1928
1948
  notify();
@@ -1939,8 +1959,15 @@ export async function runCLI(rawArgs, deps = {}) {
1939
1959
  const chain = options.chain || null;
1940
1960
 
1941
1961
  if (!commands[command]) {
1962
+ // A command token containing whitespace almost always means a multi-word
1963
+ // invocation was passed as a single argument — e.g. `nansen "trade --help"`,
1964
+ // or an unquoted shell variable under zsh (which, unlike bash, does not
1965
+ // word-split `$var`). Point the user straight at the cause instead of a bare
1966
+ // "Unknown command" that reads like a spurious failure.
1942
1967
  const errorData = {
1943
- error: `Unknown command: ${command}`,
1968
+ error: /\s/.test(command)
1969
+ ? `Unknown command: "${command}". This looks like multiple words passed as one argument — check your shell quoting (use \`nansen trade --help\`, not \`nansen "trade --help"\`).`
1970
+ : `Unknown command: ${command}`,
1944
1971
  available: Object.keys(commands)
1945
1972
  };
1946
1973
  const formatted = formatOutput(errorData, { pretty, table });
package/src/cost-cache.js CHANGED
@@ -14,6 +14,24 @@ const CACHE_FILE = path.join(CONFIG_DIR, 'cost-map.json');
14
14
  const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
15
15
  const OPENAPI_URL = 'https://api.nansen.ai/openapi.json';
16
16
 
17
+ /**
18
+ * Write `data` to `file` atomically: write to a unique temp file in the same
19
+ * directory, then rename over the target. rename(2) is atomic on POSIX, so a
20
+ * concurrent reader always sees either the old file or the fully-written new
21
+ * one — never a truncated/empty file. The temp name includes the pid so
22
+ * concurrent writers don't clobber each other's temp files.
23
+ */
24
+ function writeAtomic(file, data) {
25
+ const tmp = `${file}.${process.pid}.tmp`;
26
+ try {
27
+ fs.writeFileSync(tmp, data);
28
+ fs.renameSync(tmp, file);
29
+ } catch (err) {
30
+ try { fs.unlinkSync(tmp); } catch { /* temp file may not exist */ }
31
+ throw err;
32
+ }
33
+ }
34
+
17
35
  /**
18
36
  * Returns { free, pro } credit cost for the given API path, or null if unavailable.
19
37
  */
@@ -75,7 +93,7 @@ export async function refreshCostMapIfStale() {
75
93
  }
76
94
 
77
95
  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
78
- fs.writeFileSync(CACHE_FILE, JSON.stringify({ costs, fetchedAt: Date.now() }));
96
+ writeAtomic(CACHE_FILE, JSON.stringify({ costs, fetchedAt: Date.now() }));
79
97
  } catch {
80
98
  // silent — network failure, parse error, write error
81
99
  }
package/src/perp.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  userSignedEip712,
23
23
  } from './hl-action.js';
24
24
  import { submitExchange } from './hl-client.js';
25
+ import { trackPerpOrderCompleted } from './telemetry.js';
25
26
  import { resolveEvmWallet, resolvePrivateKey } from './wallet-signing.js';
26
27
  import { hashTypedData } from './x402-evm.js';
27
28
 
@@ -241,8 +242,66 @@ async function signHlAction(eip712, { privateKeyHex, privyClient, privyWalletId,
241
242
  // { action, nonce, eip712, size?, price? } from an hl-action.js builder; the
242
243
  // vault is always null for a normal wallet (the CLI signs L1 actions with the
243
244
  // wallet key directly). submitExchange throws on any HL rejection.
245
+ // Parse the per-order statuses HL returns for an `order` action so the oid and
246
+ // fill are surfaced — the perp analogue of spot printing its quote id. HL replies:
247
+ // response.data.statuses[] = { resting:{oid} } | { filled:{oid,totalSz,avgPx} } | { error }
248
+ // A rejected leg ({error}) has already thrown in submitExchange, so only
249
+ // resting/filled legs reach here. A TP/SL bracket returns multiple legs; label
250
+ // them the same way extractActionErrors does (parent / take-profit / stop-loss).
251
+ // Gated on the SUBMITTED action being an order: leverage/transfer/builder-fee
252
+ // actions (type "default") and cancels return no oids, so [] falls back to the
253
+ // concise raw response line in buildScreenSignSubmit.
254
+ export function summarizeOrderResult(result, action) {
255
+ if (action?.type !== 'order') return [];
256
+ const statuses = result?.response?.data?.statuses;
257
+ if (!Array.isArray(statuses)) return [];
258
+ const multiLeg = (action.orders?.length ?? 0) > 1;
259
+ const out = [];
260
+ for (const [index, entry] of statuses.entries()) {
261
+ if (!entry || typeof entry !== 'object') continue;
262
+ const tpsl = action.orders?.[index]?.t?.trigger?.tpsl;
263
+ const leg = tpsl === 'tp'
264
+ ? 'take-profit'
265
+ : tpsl === 'sl'
266
+ ? 'stop-loss'
267
+ : action.grouping === 'normalTpsl' && index === 0
268
+ ? 'parent'
269
+ : multiLeg
270
+ ? `leg ${index + 1}`
271
+ : 'parent';
272
+ // HL oids are uint64; JSON.parse already narrowed them to Number, so any id
273
+ // above 2^53 arrived rounded. Flag precision (oidSafe) so the caller can
274
+ // withhold a copy-paste cancel — and BI can drop the id — rather than act on
275
+ // a wrong oid presented as authoritative.
276
+ if (entry.filled && entry.filled.oid !== undefined) {
277
+ const { oid, totalSz, avgPx } = entry.filled;
278
+ out.push({ leg, kind: 'filled', oid, oidSafe: Number.isSafeInteger(oid), totalSz, avgPx });
279
+ } else if (entry.resting && entry.resting.oid !== undefined) {
280
+ const { oid } = entry.resting;
281
+ out.push({ leg, kind: 'resting', oid, oidSafe: Number.isSafeInteger(oid) });
282
+ }
283
+ }
284
+ return out;
285
+ }
286
+
287
+ // Fire the perp_order_completed event via the injected tracker. Deliberately
288
+ // minimal (privacy): only the trade side and the parent Hyperliquid order id —
289
+ // no asset, price, size, or fill detail. The order-placement response carries no
290
+ // trade/fill id (that exists only once the order fills, via the fills feed), so
291
+ // side + oid is the reliable maximum here. The oid is omitted when it arrived
292
+ // rounded past 2^53 (oidSafe false) so BI never records a wrong id. `summary` is
293
+ // summarizeOrderResult's output; its parent leg carries the order id.
294
+ function emitPerpOrderCompleted(telemetry, summary) {
295
+ const parent = summary.find((o) => o.leg === 'parent') ?? summary[0];
296
+ return telemetry.track({
297
+ command: telemetry.command,
298
+ side: telemetry.side,
299
+ oid: parent && parent.oidSafe ? parent.oid : undefined,
300
+ });
301
+ }
302
+
244
303
  async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
245
- const { action, nonce, eip712, size, price } = prepared;
304
+ const { action, nonce, eip712, size, price, coin, telemetry } = prepared;
246
305
  const { walletAddress, log } = ctx;
247
306
 
248
307
  log(' Screening...');
@@ -262,10 +321,44 @@ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
262
321
 
263
322
  const status = result.status ?? 'ok';
264
323
  log(` Status: ${status}`);
265
- if (result.response) {
324
+
325
+ // Surface the order id(s) HL returned so the caller can track/cancel the
326
+ // order — mirrors how spot prints its quote id plus a ready-to-run follow-up.
327
+ const orders = summarizeOrderResult(result, action);
328
+ if (orders.length) {
329
+ for (const o of orders) {
330
+ const tag = o.leg === 'parent' ? '' : ` [${o.leg}]`;
331
+ // Withhold the exact id (and the copy-paste cancel) when it arrived rounded
332
+ // past 2^53 — a wrong oid presented as actionable is worse than none.
333
+ const oidText = o.oidSafe ? `oid ${o.oid}` : 'oid too large to display precisely';
334
+ if (o.kind === 'filled') {
335
+ log(` Filled${tag}: ${o.totalSz} @ ${o.avgPx} (${oidText})`);
336
+ } else {
337
+ log(` Resting order${tag}: ${oidText}`);
338
+ if (coin && o.oidSafe) log(` Cancel: nansen perp cancel --coin ${coin} --oid ${o.oid}`);
339
+ }
340
+ }
341
+ } else if (result.response) {
342
+ // Non-order actions (leverage, transfer, builder-fee approval) or a response
343
+ // shape without statuses: keep the concise raw line.
266
344
  const resp = typeof result.response === 'string' ? result.response : JSON.stringify(result.response);
267
345
  log(` Response: ${resp}`);
268
346
  }
347
+
348
+ // Emit the order OUTCOME to BI (oid, fill status/price/size, TP/SL legs) — the
349
+ // perp analogue of the command-level telemetry, which fires too early (before
350
+ // this HL response) to observe any of it. Order/close only: cancel / leverage
351
+ // / transfer / builder-fee actions carry no `telemetry` and also summarize to
352
+ // []. Guarded + swallowed so a telemetry failure can never downgrade a
353
+ // completed order into a cli_command_failed.
354
+ if (telemetry && orders.length) {
355
+ try {
356
+ await emitPerpOrderCompleted(telemetry, orders);
357
+ } catch {
358
+ // Best-effort; never surface a tracking error after a real fill.
359
+ }
360
+ }
361
+
269
362
  return result;
270
363
  }
271
364
 
@@ -468,7 +561,11 @@ function resolveCoin(options) {
468
561
  // ── Command builder ──────────────────────────────────────────────────
469
562
 
470
563
  export function buildPerpCommands(deps = {}) {
471
- const { log = console.log, warn = (m) => process.stderr.write(`${m}\n`) } = deps;
564
+ const {
565
+ log = console.log,
566
+ warn = (m) => process.stderr.write(`${m}\n`),
567
+ track = trackPerpOrderCompleted,
568
+ } = deps;
472
569
 
473
570
  return {
474
571
  'order': async (args, apiInstance, flags, options) => {
@@ -542,7 +639,23 @@ OPTIONS:
542
639
  const nonce = hlNonce();
543
640
  const eip712 = l1Eip712(action, null, nonce);
544
641
 
545
- await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx);
642
+ await buildScreenSignSubmit(
643
+ apiInstance,
644
+ {
645
+ action,
646
+ nonce,
647
+ eip712,
648
+ size: effSize,
649
+ price: effPrice,
650
+ coin,
651
+ telemetry: {
652
+ command: 'order',
653
+ side: isBuy ? 'buy' : 'sell',
654
+ track,
655
+ },
656
+ },
657
+ ctx,
658
+ );
546
659
  log('');
547
660
  return undefined;
548
661
  },
@@ -639,7 +752,23 @@ OPTIONS:
639
752
  const nonce = hlNonce();
640
753
  const eip712 = l1Eip712(action, null, nonce);
641
754
 
642
- await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx);
755
+ await buildScreenSignSubmit(
756
+ apiInstance,
757
+ {
758
+ action,
759
+ nonce,
760
+ eip712,
761
+ size: effSize,
762
+ price: effPrice,
763
+ coin,
764
+ telemetry: {
765
+ command: 'close',
766
+ side: isBuy ? 'buy' : 'sell',
767
+ track,
768
+ },
769
+ },
770
+ ctx,
771
+ );
643
772
  log('');
644
773
  return undefined;
645
774
  },
package/src/schema.json CHANGED
@@ -564,6 +564,15 @@
564
564
  }
565
565
  }
566
566
  },
567
+ "first-funder": {
568
+ "endpoint": "/api/v1/profiler/address/first-funder",
569
+ "description": "Find the first wallet that funded an EVM address",
570
+ "options": {
571
+ "address": {
572
+ "required": true
573
+ }
574
+ }
575
+ },
567
576
  "pnl": {
568
577
  "endpoint": "/api/v1/profiler/address/pnl",
569
578
  "description": "PnL and trade performance",
@@ -1557,6 +1566,23 @@
1557
1566
  "aggregator": {
1558
1567
  "type": "string",
1559
1568
  "description": "Force a specific aggregator: lifi, relay, jupiter, or okx. Filters the returned quote list client-side; errors if no quote from that aggregator was returned."
1569
+ },
1570
+ "swap-mode": {
1571
+ "type": "string",
1572
+ "default": "exactIn",
1573
+ "description": "\"exactIn\" (default) to spend exactly --amount of the sell token, or \"exactOut\" to receive exactly --amount of the buy token. Not supported together with --amount-unit percent."
1574
+ },
1575
+ "slippage": {
1576
+ "type": "string",
1577
+ "description": "Slippage tolerance as a decimal between 0 and 1 (e.g. 0.03 for 3%). Values outside that range are rejected."
1578
+ },
1579
+ "auto-slippage": {
1580
+ "type": "boolean",
1581
+ "description": "Let slippage be calculated automatically instead of using a fixed --slippage."
1582
+ },
1583
+ "max-auto-slippage": {
1584
+ "type": "string",
1585
+ "description": "Upper bound applied when --auto-slippage is enabled, as a decimal between 0 and 1 (e.g. 0.03 for 3%)."
1560
1586
  }
1561
1587
  },
1562
1588
  "prerequisites": [
@@ -1566,6 +1592,11 @@
1566
1592
  "execute": {
1567
1593
  "description": "Sign and broadcast a quoted trade",
1568
1594
  "options": {
1595
+ "quote": {
1596
+ "type": "string",
1597
+ "required": true,
1598
+ "description": "Quote ID returned by `nansen trade quote` (alias: --quote-id)."
1599
+ },
1569
1600
  "chain": {
1570
1601
  "type": "string",
1571
1602
  "default": "base",
@@ -1578,6 +1609,14 @@
1578
1609
  "gasless": {
1579
1610
  "type": "boolean",
1580
1611
  "description": "Relay-only: have Relay's solver pay gas + broadcast (user signs only). Requires the selected quote's aggregator to be \"relay\". Not supported via WalletConnect."
1612
+ },
1613
+ "quote-index": {
1614
+ "type": "string",
1615
+ "description": "Pin a specific quote by 0-based index when the cached quote returned several. Must be within range; there is no fallback to the other quotes."
1616
+ },
1617
+ "no-simulate": {
1618
+ "type": "boolean",
1619
+ "description": "Skip the pre-broadcast simulation."
1581
1620
  }
1582
1621
  }
1583
1622
  },
package/src/telemetry.js CHANGED
@@ -5,6 +5,10 @@
5
5
  * how long they take, and where errors occur. Events are fire-and-forget —
6
6
  * failures are silently ignored and never block the CLI.
7
7
  *
8
+ * Perp `order`/`close` additionally emit a `perp_order_completed` event that
9
+ * carries only the trade side and the Hyperliquid order id, tied to the same
10
+ * random anonymous_id. All telemetry is opt-out via DO_NOT_TRACK=1 or
11
+ * NANSEN_NO_TELEMETRY=1.
8
12
  */
9
13
 
10
14
  import fs from 'fs';
@@ -245,3 +249,49 @@ export function trackCommandFailed({
245
249
  context: buildContext(),
246
250
  });
247
251
  }
252
+
253
+ /**
254
+ * Track a completed Hyperliquid perp order (`nansen perp order` / `perp close`).
255
+ *
256
+ * Fired from `buildScreenSignSubmit` in perp.js AFTER the HL /exchange response
257
+ * is parsed (`summarizeOrderResult`). This is the only event that sees the order
258
+ * OUTCOME: `cli_command_succeeded` fires at the command wrapper, before the
259
+ * order path returns, so it captures command metadata but never the fill. Perp
260
+ * orders bypass the Nansen API on submit (CLI signs and posts straight to
261
+ * Hyperliquid — Decision D4), so the backend never sees the response either;
262
+ * this client-side event is the only way order outcomes reach BI.
263
+ *
264
+ * Fires on success only: a HL rejection throws in `submitExchange` and is
265
+ * captured by `cli_command_failed`, so no failed event is emitted here.
266
+ *
267
+ * Deliberately minimal: only the trade side and the Hyperliquid order id — no
268
+ * asset, price, size, or fill detail. A trade (fill) id is not carried by the
269
+ * order-placement response (it exists only once the order fills, via the fills
270
+ * feed), so it is not available here. `oid` is omitted when it exceeded JS
271
+ * safe-integer precision at parse time (see summarizeOrderResult).
272
+ *
273
+ * @param {object} opts
274
+ * @param {'order'|'close'} opts.command - Which perp command placed the order (routes `path`)
275
+ * @param {'buy'|'sell'} opts.side - Normalized trade side
276
+ * @param {number} [opts.oid] - Parent leg's Hyperliquid order id (omitted if imprecise)
277
+ */
278
+ export function trackPerpOrderCompleted({ command, side, oid }) {
279
+ return sendEvent({
280
+ event: 'perp_order_completed',
281
+ event_source: getEventSource(),
282
+ event_id: crypto.randomUUID(),
283
+ user_id: null,
284
+ anonymous_id: getAnonymousId(),
285
+ session_id: getSessionId(),
286
+ timestamp: new Date().toISOString(),
287
+ // Same path as the command's cli_command_succeeded ("/perp/order" |
288
+ // "/perp/close"), so BI can line the two events up per command.
289
+ path: commandToPath(`perp ${command}`),
290
+ properties: {
291
+ source: `nansen-cli/${cliVersion}`,
292
+ side,
293
+ ...(oid !== undefined && { oid }),
294
+ },
295
+ context: buildContext(),
296
+ });
297
+ }
@@ -84,6 +84,48 @@ export function getUpdateNotification(currentVersion) {
84
84
  }
85
85
  }
86
86
 
87
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
88
+
89
+ /**
90
+ * Build the Node source run by the detached child. It fetches the latest
91
+ * version and writes it to `file` atomically: the JSON is written to a
92
+ * pid-scoped temp file, then renamed over the target. rename(2) is atomic on
93
+ * POSIX, so a concurrent `nansen` reader always sees either the old file or the
94
+ * fully-written new one — never a truncated/empty file.
95
+ *
96
+ * The registry URL is overridable via NANSEN_REGISTRY_URL purely as a test seam
97
+ * (lets a test point the child at a local server); it defaults to npm.
98
+ */
99
+ export function buildCheckScript(dir, file, url = process.env.NANSEN_REGISTRY_URL || REGISTRY_URL) {
100
+ return `
101
+ const url = ${JSON.stringify(url)};
102
+ const http = require(url.startsWith('https:') ? 'https' : 'http');
103
+ const fs = require('fs');
104
+ const dir = ${JSON.stringify(dir)};
105
+ const file = ${JSON.stringify(file)};
106
+ const req = http.get(url, { timeout: 5000 }, (res) => {
107
+ let body = '';
108
+ res.on('data', c => body += c);
109
+ res.on('end', () => {
110
+ try {
111
+ const { version } = JSON.parse(body);
112
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700, recursive: true });
113
+ const tmp = file + '.' + process.pid + '.tmp';
114
+ try {
115
+ fs.writeFileSync(tmp, JSON.stringify({ latest: version, checkedAt: Date.now() }));
116
+ fs.renameSync(tmp, file);
117
+ } catch (e) {
118
+ try { fs.unlinkSync(tmp); } catch {}
119
+ throw e;
120
+ }
121
+ } catch {}
122
+ });
123
+ });
124
+ req.on('error', () => {});
125
+ req.setTimeout(5000, () => req.destroy());
126
+ `;
127
+ }
128
+
87
129
  /**
88
130
  * If the cache is missing or stale, spawn a detached background process to refresh it.
89
131
  */
@@ -97,29 +139,7 @@ export function scheduleUpdateCheck() {
97
139
  if (checkedAt && Date.now() - checkedAt < STALE_MS) return;
98
140
  }
99
141
 
100
- // Inline script executed by the detached child
101
- const script = `
102
- const https = require('https');
103
- const fs = require('fs');
104
- const path = require('path');
105
- const dir = ${JSON.stringify(CONFIG_DIR)};
106
- const file = ${JSON.stringify(CACHE_FILE)};
107
- const req = https.get('https://registry.npmjs.org/${PACKAGE_NAME}/latest', { timeout: 5000 }, (res) => {
108
- let body = '';
109
- res.on('data', c => body += c);
110
- res.on('end', () => {
111
- try {
112
- const { version } = JSON.parse(body);
113
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700, recursive: true });
114
- fs.writeFileSync(file, JSON.stringify({ latest: version, checkedAt: Date.now() }));
115
- } catch {}
116
- });
117
- });
118
- req.on('error', () => {});
119
- req.setTimeout(5000, () => req.destroy());
120
- `;
121
-
122
- const child = childProcess.spawn(process.execPath, ['-e', script], {
142
+ const child = childProcess.spawn(process.execPath, ['-e', buildCheckScript(CONFIG_DIR, CACHE_FILE)], {
123
143
  detached: true,
124
144
  stdio: 'ignore'
125
145
  });