nansen-cli 1.36.1 → 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 +28 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +2 -0
- package/src/api.js +47 -2
- package/src/cli.js +88 -48
- package/src/commands/agent.js +14 -4
- package/src/cost-cache.js +34 -1
- package/src/perp.js +134 -5
- package/src/response-meta.js +13 -7
- package/src/schema.json +39 -0
- package/src/telemetry.js +50 -0
- package/src/update-check.js +43 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
25
|
+
## 1.36.2
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- [#479](https://github.com/nansen-ai/nansen-cli/pull/479) [`e2590ed`](https://github.com/nansen-ai/nansen-cli/commit/e2590ed5caf0461b43f6e726ec62d87f70d391fd) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface richer API response metadata: the `X-Nansen-Credits-Cost` header now drives credit reporting (a concise `Credits: N (this call)` stderr line after each data command, falling back to the cached spec estimate when the header is absent), `requestId` is hoisted to the top level of the JSON error envelope (including `nansen agent` failures, which previously dropped it), and error codes now come from the API's stable `code` field when present — known codes map onto the existing error code enum, unknown ones pass through verbatim instead of being flattened. stdout JSON is unchanged; all new reporting goes to stderr.
|
|
30
|
+
|
|
3
31
|
## 1.36.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -209,7 +209,7 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
|
|
|
209
209
|
|
|
210
210
|
```json
|
|
211
211
|
{ "success": true, "data": <api_response> }
|
|
212
|
-
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }
|
|
212
|
+
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "requestId": "...", "details": { ... } }
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
**Critical error codes:**
|
|
@@ -226,8 +226,8 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
|
|
|
226
226
|
|
|
227
227
|
| Field | Meaning |
|
|
228
228
|
|-------|---------|
|
|
229
|
-
| `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. |
|
|
230
|
-
| `credits` | `used`, `remaining` |
|
|
229
|
+
| `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. Also hoisted to the top level of the error envelope. |
|
|
230
|
+
| `credits` | `used`, `remaining`, `cost` (the authoritative charge for this call) |
|
|
231
231
|
| `rateLimit` | `limit`, `remaining`, `resetSeconds` |
|
|
232
232
|
|
|
233
233
|
Any field may be absent or `null`, meaning unknown — never assume zero. A low-balance warning goes to **stderr**, so stdout stays pure JSON.
|
package/package.json
CHANGED
|
@@ -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
|
@@ -116,12 +116,43 @@ export class NansenError extends Error {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* Stable snake_case codes the server sends in error bodies, mapped to the
|
|
120
|
+
* ErrorCode values downstream consumers already key on.
|
|
121
|
+
*/
|
|
122
|
+
const SERVER_CODE_MAP = {
|
|
123
|
+
rate_limit_exceeded: ErrorCode.RATE_LIMITED,
|
|
124
|
+
insufficient_credits: ErrorCode.CREDITS_EXHAUSTED,
|
|
125
|
+
payment_required: ErrorCode.PAYMENT_REQUIRED,
|
|
126
|
+
unauthorized: ErrorCode.UNAUTHORIZED,
|
|
127
|
+
forbidden: ErrorCode.FORBIDDEN,
|
|
128
|
+
not_found: ErrorCode.NOT_FOUND,
|
|
129
|
+
unsupported_filter: ErrorCode.UNSUPPORTED_FILTER,
|
|
130
|
+
validation_error: ErrorCode.INVALID_PARAMS,
|
|
131
|
+
invalid_params: ErrorCode.INVALID_PARAMS,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Map an error response to an error code.
|
|
136
|
+
*
|
|
137
|
+
* Order matters: a 402 is always PAYMENT_REQUIRED (the x402 auto-payment flow
|
|
138
|
+
* keys on it, whatever the body says); then a stable `code` field from the
|
|
139
|
+
* server body wins over prose matching — known codes map onto the ErrorCode
|
|
140
|
+
* enum, unknown ones pass through verbatim so new server codes are tolerated,
|
|
141
|
+
* never flattened; bodies without a code fall back to status + prose.
|
|
120
142
|
*/
|
|
121
143
|
export function statusToErrorCode(status, data = {}) {
|
|
144
|
+
if (status === 402) return ErrorCode.PAYMENT_REQUIRED;
|
|
145
|
+
|
|
146
|
+
const rawCode = [data?.code, data?.detail?.code]
|
|
147
|
+
.find(value => typeof value === 'string' && value.trim() !== '');
|
|
148
|
+
if (rawCode !== undefined) {
|
|
149
|
+
const serverCode = rawCode.trim();
|
|
150
|
+
return SERVER_CODE_MAP[serverCode] ?? serverCode;
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
const message = data?.message || data?.error || '';
|
|
123
154
|
const messageLower = message.toLowerCase();
|
|
124
|
-
|
|
155
|
+
|
|
125
156
|
switch (status) {
|
|
126
157
|
case 400:
|
|
127
158
|
case 422:
|
|
@@ -484,6 +515,8 @@ export class NansenAPI {
|
|
|
484
515
|
* low-credit warning wants.
|
|
485
516
|
*/
|
|
486
517
|
this.lastResponseMeta = null;
|
|
518
|
+
/** API path of the most recent request(), for pairing lastResponseMeta with a cost estimate. */
|
|
519
|
+
this.lastEndpoint = null;
|
|
487
520
|
}
|
|
488
521
|
|
|
489
522
|
static cleanBody(body) {
|
|
@@ -547,6 +580,7 @@ export class NansenAPI {
|
|
|
547
580
|
}
|
|
548
581
|
|
|
549
582
|
async request(endpoint, body = {}, options = {}) {
|
|
583
|
+
this.lastEndpoint = endpoint;
|
|
550
584
|
const url = `${this.baseUrl}${endpoint}`;
|
|
551
585
|
const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions;
|
|
552
586
|
const shouldRetry = options.retry !== false; // Allow disabling retry per-request
|
|
@@ -978,6 +1012,17 @@ export class NansenAPI {
|
|
|
978
1012
|
});
|
|
979
1013
|
}
|
|
980
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
|
+
|
|
981
1026
|
async addressCounterparties(params = {}) {
|
|
982
1027
|
const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
|
|
983
1028
|
if (address) requireValidAddress(address, chain);
|
package/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './comman
|
|
|
15
15
|
import { resolveAddress, isEnsName } from './ens.js';
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
|
|
18
|
-
import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
|
|
18
|
+
import { refreshCostMapIfStale, getCostForEndpoint, creditsCharged } from './cost-cache.js';
|
|
19
19
|
import { creditWarning, noticeWarnings } from './response-meta.js';
|
|
20
20
|
import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
|
|
21
21
|
import { createRequire } from 'module';
|
|
@@ -390,6 +390,10 @@ export function formatError(error) {
|
|
|
390
390
|
code: error.code || 'UNKNOWN',
|
|
391
391
|
status: error.status || null,
|
|
392
392
|
};
|
|
393
|
+
// Hoisted so the id survives even if details is omitted or later pruned.
|
|
394
|
+
if (details?.requestId) {
|
|
395
|
+
result.requestId = details.requestId;
|
|
396
|
+
}
|
|
393
397
|
if (details != null && !(typeof details === 'object' && !Array.isArray(details) && Object.keys(details).length === 0)) {
|
|
394
398
|
result.details = details;
|
|
395
399
|
}
|
|
@@ -767,9 +771,54 @@ Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
|
|
|
767
771
|
Docs: https://docs.nansen.ai
|
|
768
772
|
Skills: npx skills add nansen-ai/nansen-cli (agent-optimised docs per command group)
|
|
769
773
|
|
|
770
|
-
Telemetry: anonymous usage stats
|
|
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
|
|
771
775
|
`;
|
|
772
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
|
+
|
|
773
822
|
// Helper to prompt for input (exported for mocking)
|
|
774
823
|
export async function prompt(question, hidden = false) {
|
|
775
824
|
return new Promise((resolve) => {
|
|
@@ -828,7 +877,8 @@ export function buildCommands(deps = {}) {
|
|
|
828
877
|
saveConfigFn = saveConfig,
|
|
829
878
|
deleteConfigFn = deleteConfig,
|
|
830
879
|
getConfigFileFn = getConfigFile,
|
|
831
|
-
isTTY = process.stdin.isTTY
|
|
880
|
+
isTTY = process.stdin.isTTY,
|
|
881
|
+
env = process.env
|
|
832
882
|
} = deps;
|
|
833
883
|
|
|
834
884
|
const cmds = {
|
|
@@ -1000,6 +1050,9 @@ export function buildCommands(deps = {}) {
|
|
|
1000
1050
|
} else {
|
|
1001
1051
|
log('No saved credentials found');
|
|
1002
1052
|
}
|
|
1053
|
+
if (env.NANSEN_API_KEY) {
|
|
1054
|
+
log('Warning: NANSEN_API_KEY remains active. Run: unset NANSEN_API_KEY');
|
|
1055
|
+
}
|
|
1003
1056
|
},
|
|
1004
1057
|
|
|
1005
1058
|
'help': async (_args, _apiInstance, _flags, _options) => {
|
|
@@ -1143,7 +1196,8 @@ export function buildCommands(deps = {}) {
|
|
|
1143
1196
|
let ensName;
|
|
1144
1197
|
if (address && isEnsName(address)) {
|
|
1145
1198
|
try {
|
|
1146
|
-
const
|
|
1199
|
+
const ensChain = subcommand === 'first-funder' ? 'ethereum' : chain;
|
|
1200
|
+
const resolved = await resolveAddress(address, ensChain);
|
|
1147
1201
|
address = resolved.address;
|
|
1148
1202
|
ensName = resolved.ensName;
|
|
1149
1203
|
} catch (err) {
|
|
@@ -1169,6 +1223,7 @@ export function buildCommands(deps = {}) {
|
|
|
1169
1223
|
'search': () => apiInstance.entitySearch({ query: options.query }),
|
|
1170
1224
|
'historical-balances': () => apiInstance.addressHistoricalBalances({ address, chain, filters, orderBy, pagination, days }),
|
|
1171
1225
|
'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, orderBy, pagination }),
|
|
1226
|
+
'first-funder': () => apiInstance.addressFirstFunder({ address }),
|
|
1172
1227
|
'counterparties': () => apiInstance.addressCounterparties({ address, chain, filters, orderBy, pagination, days }),
|
|
1173
1228
|
'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, orderBy, pagination, days }),
|
|
1174
1229
|
'perp-positions': () => apiInstance.addressPerpPositions({ address, filters, orderBy, pagination }),
|
|
@@ -1215,7 +1270,7 @@ export function buildCommands(deps = {}) {
|
|
|
1215
1270
|
return compareWallets(apiInstance, { addresses: addrs, chain, days });
|
|
1216
1271
|
},
|
|
1217
1272
|
'help': () => ({
|
|
1218
|
-
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'],
|
|
1219
1274
|
description: 'Wallet profiling endpoints',
|
|
1220
1275
|
example: 'nansen research profiler compare --addresses "0xABC...,0xDEF..." --chain ethereum'
|
|
1221
1276
|
})
|
|
@@ -1554,47 +1609,7 @@ export function buildCommands(deps = {}) {
|
|
|
1554
1609
|
cmds['trade'] = async (args, apiInstance, flags, options) => {
|
|
1555
1610
|
const sub = args[0];
|
|
1556
1611
|
if (!sub || sub === 'help') {
|
|
1557
|
-
log(
|
|
1558
|
-
|
|
1559
|
-
SUBCOMMANDS:
|
|
1560
|
-
quote Get a swap quote (price, route, fees)
|
|
1561
|
-
execute Sign and broadcast a quoted swap
|
|
1562
|
-
bridge-status Check cross-chain bridge transaction status
|
|
1563
|
-
limit-order Limit order management (Solana only)
|
|
1564
|
-
|
|
1565
|
-
USAGE:
|
|
1566
|
-
nansen trade quote --chain <chain> --from <token> --to <token> --amount <units> [--wallet <name>]
|
|
1567
|
-
nansen trade quote --chain <chain> --to-chain <chain> --from <token> --to <token> --amount <units>
|
|
1568
|
-
nansen trade execute --quote <quoteId> [--wallet <name>]
|
|
1569
|
-
nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1570
|
-
nansen trade limit-order <create|list|cancel|update> [options]
|
|
1571
|
-
|
|
1572
|
-
EXAMPLES:
|
|
1573
|
-
nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
1574
|
-
nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
1575
|
-
nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
|
|
1576
|
-
nansen trade execute --quote 1708900000000-abc123
|
|
1577
|
-
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
|
|
1578
|
-
nansen trade limit-order create --from SOL --to USDC --amount 1.5 --trigger-mint SOL --trigger-condition below --trigger-price 80
|
|
1579
|
-
nansen trade limit-order list
|
|
1580
|
-
|
|
1581
|
-
WALLET:
|
|
1582
|
-
--wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect.
|
|
1583
|
-
Defaults to the default local wallet if omitted.
|
|
1584
|
-
|
|
1585
|
-
SYMBOLS:
|
|
1586
|
-
Common tokens resolve automatically: SOL, ETH, USDC, USDT, WETH
|
|
1587
|
-
Raw addresses are also accepted.
|
|
1588
|
-
|
|
1589
|
-
CROSS-CHAIN NOTES (when using --to-chain):
|
|
1590
|
-
Supported combos:
|
|
1591
|
-
native → native (ETH <-> SOL)
|
|
1592
|
-
USDC → USDC (both directions)
|
|
1593
|
-
USDC → native (USDC → ETH or SOL)
|
|
1594
|
-
native → USDC (ETH/SOL → USDC)
|
|
1595
|
-
non-native → non-native — not supported (use USDC as intermediate)
|
|
1596
|
-
Bridge providers: Li.Fi or Relay (selected automatically based on best price)
|
|
1597
|
-
Typical bridge time: 1-5 minutes`);
|
|
1612
|
+
log(TRADE_USAGE);
|
|
1598
1613
|
return;
|
|
1599
1614
|
}
|
|
1600
1615
|
if (sub === 'limit-order') {
|
|
@@ -1918,7 +1933,16 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1918
1933
|
return { type: 'command-help', command };
|
|
1919
1934
|
}
|
|
1920
1935
|
}
|
|
1921
|
-
//
|
|
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
|
|
1922
1946
|
if (command === 'help' || !commands[command]) {
|
|
1923
1947
|
output(BANNER + HELP);
|
|
1924
1948
|
notify();
|
|
@@ -1935,8 +1959,15 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1935
1959
|
const chain = options.chain || null;
|
|
1936
1960
|
|
|
1937
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.
|
|
1938
1967
|
const errorData = {
|
|
1939
|
-
error:
|
|
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}`,
|
|
1940
1971
|
available: Object.keys(commands)
|
|
1941
1972
|
};
|
|
1942
1973
|
const formatted = formatOutput(errorData, { pretty, table });
|
|
@@ -1983,6 +2014,15 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1983
2014
|
if (lowCredits) errorOutput(lowCredits);
|
|
1984
2015
|
for (const notice of noticeWarnings(api.lastResponseMeta)) errorOutput(notice);
|
|
1985
2016
|
|
|
2017
|
+
// What this call cost — authoritative header when the API sent one, else
|
|
2018
|
+
// the cached spec estimate. stderr only, so stdout JSON stays pure.
|
|
2019
|
+
const charged = creditsCharged(api.lastResponseMeta, api.lastEndpoint);
|
|
2020
|
+
if (charged?.source === 'header') {
|
|
2021
|
+
errorOutput(`Credits: ${charged.cost} (this call)`);
|
|
2022
|
+
} else if (charged?.source === 'estimate') {
|
|
2023
|
+
errorOutput(`Credits: ~${charged.estimate.free} free / ${charged.estimate.pro} pro (estimated)`);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
1986
2026
|
// Commands that handle their own output return undefined
|
|
1987
2027
|
if (result === undefined) {
|
|
1988
2028
|
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
package/src/commands/agent.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { NansenError, ErrorCode, statusToErrorCode, telemetryHeaders, packageVersion } from '../api.js';
|
|
8
8
|
import { getCostForEndpoint } from '../cost-cache.js';
|
|
9
|
+
import { readResponseMeta } from '../response-meta.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Build standard request headers, matching apiInstance.request() conventions.
|
|
@@ -25,7 +26,7 @@ function buildHeaders(apiInstance) {
|
|
|
25
26
|
* Throw a NansenError with the same structure as apiInstance.request() errors.
|
|
26
27
|
* Includes `details` field for consistency with other commands.
|
|
27
28
|
*/
|
|
28
|
-
function throwApiError(message, status, serverDetail) {
|
|
29
|
+
function throwApiError(message, status, serverDetail, errData = null, requestId = null) {
|
|
29
30
|
// Match the friendly wrapper messages from apiInstance.request()
|
|
30
31
|
let friendlyMessage = message;
|
|
31
32
|
if (status === 401) {
|
|
@@ -36,9 +37,14 @@ function throwApiError(message, status, serverDetail) {
|
|
|
36
37
|
|
|
37
38
|
throw new NansenError(
|
|
38
39
|
friendlyMessage,
|
|
39
|
-
statusToErrorCode(status),
|
|
40
|
+
statusToErrorCode(status, errData || {}),
|
|
40
41
|
status,
|
|
41
|
-
{
|
|
42
|
+
{
|
|
43
|
+
detail: serverDetail || message,
|
|
44
|
+
attempt: 1,
|
|
45
|
+
retryAfterMs: null,
|
|
46
|
+
...(requestId && { requestId }),
|
|
47
|
+
},
|
|
42
48
|
);
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -273,16 +279,20 @@ EXAMPLES:
|
|
|
273
279
|
if (!response.ok) {
|
|
274
280
|
clearTimeout(timer);
|
|
275
281
|
let serverDetail;
|
|
282
|
+
let errData = null;
|
|
276
283
|
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
277
284
|
try {
|
|
278
|
-
|
|
285
|
+
errData = await response.json();
|
|
279
286
|
serverDetail = errData.detail || errData.message;
|
|
280
287
|
} catch { /* ignore parse failure */ }
|
|
281
288
|
}
|
|
289
|
+
const meta = readResponseMeta(response);
|
|
282
290
|
throwApiError(
|
|
283
291
|
serverDetail || `Agent returned ${response.status}`,
|
|
284
292
|
response.status,
|
|
285
293
|
serverDetail,
|
|
294
|
+
errData,
|
|
295
|
+
meta?.requestId,
|
|
286
296
|
);
|
|
287
297
|
}
|
|
288
298
|
|
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
|
*/
|
|
@@ -27,6 +45,21 @@ export function getCostForEndpoint(endpoint) {
|
|
|
27
45
|
}
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What did (or would) this call cost?
|
|
50
|
+
*
|
|
51
|
+
* Prefers the authoritative cost response header, then the spec-derived
|
|
52
|
+
* estimate for the endpoint, else null.
|
|
53
|
+
* Returns { cost, source: 'header' } or { estimate: { free, pro }, source: 'estimate' }.
|
|
54
|
+
*/
|
|
55
|
+
export function creditsCharged(meta, endpoint) {
|
|
56
|
+
const charged = meta?.credits?.cost;
|
|
57
|
+
if (charged != null) return { cost: charged, source: 'header' };
|
|
58
|
+
const estimate = endpoint ? getCostForEndpoint(endpoint) : null;
|
|
59
|
+
if (estimate != null) return { estimate, source: 'estimate' };
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
/**
|
|
31
64
|
* Fetches the OpenAPI spec and writes the cost map to disk if the cache is
|
|
32
65
|
* missing or older than 24h. Awaited inline — only blocks on cold/stale cache.
|
|
@@ -60,7 +93,7 @@ export async function refreshCostMapIfStale() {
|
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
|
|
63
|
-
|
|
96
|
+
writeAtomic(CACHE_FILE, JSON.stringify({ costs, fetchedAt: Date.now() }));
|
|
64
97
|
} catch {
|
|
65
98
|
// silent — network failure, parse error, write error
|
|
66
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
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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/response-meta.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
/** Header names, as documented in the API reference. */
|
|
20
20
|
const CREDITS_USED = 'x-nansen-credits-used';
|
|
21
21
|
const CREDITS_REMAINING = 'x-nansen-credits-remaining';
|
|
22
|
+
const CREDITS_COST = 'x-nansen-credits-cost';
|
|
22
23
|
const RATE_LIMIT = 'x-ratelimit-limit';
|
|
23
24
|
const RATE_REMAINING = 'x-ratelimit-remaining';
|
|
24
25
|
const RATE_RESET = 'x-ratelimit-reset';
|
|
@@ -34,8 +35,8 @@ const REQUEST_ID = 'x-request-id';
|
|
|
34
35
|
function intHeader(response, name) {
|
|
35
36
|
const raw = stringHeader(response, name);
|
|
36
37
|
if (raw === null) return null;
|
|
37
|
-
const value = Number
|
|
38
|
-
return Number.
|
|
38
|
+
const value = Number(raw);
|
|
39
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -57,6 +58,7 @@ function stringHeader(response, name) {
|
|
|
57
58
|
export function readResponseMeta(response) {
|
|
58
59
|
const used = intHeader(response, CREDITS_USED);
|
|
59
60
|
const remaining = intHeader(response, CREDITS_REMAINING);
|
|
61
|
+
const cost = intHeader(response, CREDITS_COST);
|
|
60
62
|
const limit = intHeader(response, RATE_LIMIT);
|
|
61
63
|
const rateRemaining = intHeader(response, RATE_REMAINING);
|
|
62
64
|
const resetSeconds = intHeader(response, RATE_RESET);
|
|
@@ -71,8 +73,10 @@ export function readResponseMeta(response) {
|
|
|
71
73
|
// never parse it or assume a format.
|
|
72
74
|
meta.requestId = requestId;
|
|
73
75
|
}
|
|
74
|
-
if (used !== null || remaining !== null) {
|
|
75
|
-
|
|
76
|
+
if (used !== null || remaining !== null || cost !== null) {
|
|
77
|
+
// cost is the server's authoritative pre-flight price for this call;
|
|
78
|
+
// used is what was actually deducted. They can disagree (e.g. free rails).
|
|
79
|
+
meta.credits = { used, remaining, cost };
|
|
76
80
|
}
|
|
77
81
|
if (limit !== null || rateRemaining !== null || resetSeconds !== null) {
|
|
78
82
|
// resetSeconds is a delta in seconds — how long the tripped window needs to
|
|
@@ -111,13 +115,15 @@ export function noticeWarnings(meta) {
|
|
|
111
115
|
export function creditWarning(meta) {
|
|
112
116
|
const credits = meta?.credits;
|
|
113
117
|
if (!credits) return null;
|
|
114
|
-
const { used, remaining } = credits;
|
|
118
|
+
const { used, remaining, cost } = credits;
|
|
115
119
|
if (remaining === null) return null;
|
|
116
120
|
if (remaining === 0) {
|
|
117
121
|
return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// The cost header is the authoritative charge; used is the fallback.
|
|
124
|
+
const charged = cost ?? used;
|
|
125
|
+
if (charged !== null && charged > 0 && remaining < charged) {
|
|
126
|
+
return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api`;
|
|
121
127
|
}
|
|
122
128
|
return null;
|
|
123
129
|
}
|
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
|
+
}
|
package/src/update-check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|