nansen-cli 1.43.0 → 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/src/semver.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Compare two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
3
+ *
4
+ * A missing trailing component is treated as 0 ("1.43" reads as "1.43.0"),
5
+ * not as `undefined` — `undefined` would make every `>` comparison against it
6
+ * false in both directions, so a version that matches on major.minor always
7
+ * came out "less than" a value that only specified major.minor (e.g.
8
+ * `compareSemver('1.43.1', '1.43')` fell through to comparing `1 > undefined`,
9
+ * which is false, so it returned -1 instead of 1).
10
+ *
11
+ * Shared by `nansen changelog --since` (src/cli.js) and the update-notifier's
12
+ * version check (src/update-check.js) so both compare versions the same,
13
+ * correct way instead of each hand-rolling its own parser.
14
+ */
15
+ export function compareSemver(a, b) {
16
+ const parse = v => {
17
+ const parts = String(v).replace(/^v/, '').split('.').map(Number);
18
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
19
+ };
20
+ const [aM, am, ap] = parse(a);
21
+ const [bM, bm, bp] = parse(b);
22
+ if (aM !== bM) return aM > bM ? 1 : -1;
23
+ if (am !== bm) return am > bm ? 1 : -1;
24
+ if (ap !== bp) return ap > bp ? 1 : -1;
25
+ return 0;
26
+ }
package/src/telemetry.js CHANGED
@@ -5,10 +5,13 @@
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
+ * Perp `order`/`close` additionally emit one canonical `trade_perps_*` outcome
9
+ * event per Hyperliquid response leg. Each event carries the leg's side,
10
+ * outcome and order id plus a shared submission id and SHA-256 wallet
11
+ * identifier. Raw wallet addresses, prices, sizes and exchange error text are
12
+ * never sent. The standard anonymous_id lets BI resolve a Nansen user when
13
+ * one is available.
14
+ * All telemetry is opt-out via DO_NOT_TRACK=1 or NANSEN_NO_TELEMETRY=1.
12
15
  */
13
16
 
14
17
  import fs from 'fs';
@@ -167,6 +170,54 @@ function buildContext() {
167
170
  };
168
171
  }
169
172
 
173
+ function hashWalletAddress(walletAddress) {
174
+ if (!walletAddress) return undefined;
175
+ return crypto
176
+ .createHash('sha256')
177
+ .update(String(walletAddress).toLowerCase())
178
+ .digest('base64');
179
+ }
180
+
181
+ function contentDerivedUuid(value) {
182
+ const bytes = crypto.createHash('sha256').update(value).digest().subarray(0, 16);
183
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
184
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
185
+ const hex = bytes.toString('hex');
186
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
187
+ }
188
+
189
+ function perpLegAttemptId({ wallet_address, submission_id, leg_index }) {
190
+ if (
191
+ wallet_address === undefined
192
+ || wallet_address === null
193
+ || submission_id === undefined
194
+ || submission_id === null
195
+ || leg_index === undefined
196
+ || leg_index === null
197
+ ) {
198
+ return crypto.randomUUID();
199
+ }
200
+ return contentDerivedUuid(`${String(wallet_address).toLowerCase()}:${submission_id}:${leg_index}`);
201
+ }
202
+
203
+ function perpOutcomeEventId({ wallet_address, submission_id, leg_index, outcome }) {
204
+ if (
205
+ wallet_address === undefined
206
+ || wallet_address === null
207
+ || submission_id === undefined
208
+ || submission_id === null
209
+ || leg_index === undefined
210
+ || leg_index === null
211
+ || outcome === undefined
212
+ || outcome === null
213
+ ) {
214
+ return crypto.randomUUID();
215
+ }
216
+ return contentDerivedUuid(
217
+ `${String(wallet_address).toLowerCase()}:${submission_id}:${leg_index}:${outcome}`,
218
+ );
219
+ }
220
+
170
221
  // ─── public API ────────────────────────────────────────────
171
222
 
172
223
  /**
@@ -258,7 +309,7 @@ export function trackCommandFailed({
258
309
  }
259
310
 
260
311
  /**
261
- * Track a completed Hyperliquid perp order (`nansen perp order` / `perp close`).
312
+ * Track one Hyperliquid perp response leg (`nansen perp order` / `perp close`).
262
313
  *
263
314
  * Fired from `buildScreenSignSubmit` in perp.js AFTER the HL /exchange response
264
315
  * is parsed (`summarizeOrderResult`). This is the only event that sees the order
@@ -268,25 +319,61 @@ export function trackCommandFailed({
268
319
  * Hyperliquid — Decision D4), so the backend never sees the response either;
269
320
  * this client-side event is the only way order outcomes reach BI.
270
321
  *
271
- * Fires on success only: a HL rejection throws in `submitExchange` and is
272
- * captured by `cli_command_failed`, so no failed event is emitted here.
322
+ * Reuse the canonical trade_perps order/close succeeded/failed event names so
323
+ * CLI, web, mobile, and backend share one BI vocabulary. Exchange rejections
324
+ * with a parsed response emit the matching `*_failed` event before
325
+ * the original command error is rethrown. Network and indeterminate timeout
326
+ * failures remain covered only by `cli_command_failed` because no authoritative
327
+ * exchange response exists.
273
328
  *
274
- * Deliberately minimal: only the trade side and the Hyperliquid order id no
275
- * asset, price, size, or fill detail. A trade (fill) id is not carried by the
276
- * order-placement response (it exists only once the order fills, via the fills
277
- * feed), so it is not available here. `oid` is omitted when it exceeded JS
278
- * safe-integer precision at parse time (see summarizeOrderResult).
329
+ * One event is emitted for each response leg. This preserves bracket and batch
330
+ * order identity without putting a nested array contract into the BI pipeline.
331
+ * A trade/fill id is not carried by the placement response; BI joins each safe
332
+ * `oid` to Hyperliquid fills to obtain it. The wallet is SHA-256 hashed using
333
+ * the same lower-case convention as BI's `hash_address` macro.
279
334
  *
280
335
  * @param {object} opts
281
336
  * @param {'order'|'close'} opts.command - Which perp command placed the order (routes `path`)
282
- * @param {'buy'|'sell'} opts.side - Normalized trade side
283
- * @param {number} [opts.oid] - Parent leg's Hyperliquid order id (omitted if imprecise)
337
+ * @param {'buy'|'sell'} opts.side - This leg's normalized trade side
338
+ * @param {'long'|'short'} [opts.position_side] - Position side after accounting for reduce-only
339
+ * @param {'filled'|'resting'|'rejected'} opts.outcome - Exchange outcome
340
+ * @param {string} opts.submission_id - Shared id for all legs in one action
341
+ * @param {number} opts.leg_index - Zero-based response-leg index
342
+ * @param {string} opts.leg - parent/take-profit/stop-loss/leg N
343
+ * @param {string} opts.wallet_address - Raw signer address; hashed before send
344
+ * @param {number} [opts.oid] - Hyperliquid order id (omitted if imprecise/unavailable)
345
+ * @param {string} [opts.error_code] - Stable local code; never raw exchange text
284
346
  */
285
- export function trackPerpOrderCompleted({ command, side, oid }) {
347
+ export function trackPerpOrderCompleted({
348
+ command,
349
+ side,
350
+ position_side,
351
+ outcome,
352
+ submission_id,
353
+ leg_index,
354
+ leg,
355
+ wallet_address,
356
+ oid,
357
+ error_code,
358
+ }) {
359
+ const walletAddressHash = hashWalletAddress(wallet_address);
360
+ const attemptId = perpLegAttemptId({ wallet_address, submission_id, leg_index });
361
+ const eventId = perpOutcomeEventId({ wallet_address, submission_id, leg_index, outcome });
362
+ const event = `trade_perps_${command === 'close' ? 'close' : 'order'}_${outcome === 'rejected' ? 'failed' : 'succeeded'}`;
363
+ // position_side is supplied per leg by summarizeOrderResult (reduceOnly-aware,
364
+ // undefined when the leg's own side is unknown). Only derive a fallback when a
365
+ // real leg side is present; never fabricate a position_side from the
366
+ // command-level side when the leg's side is unknown — that would corrupt the
367
+ // wallet-scoped joins in the companion dbt change.
368
+ const positionSide = position_side ?? (side === undefined
369
+ ? undefined
370
+ : command === 'close'
371
+ ? (side === 'buy' ? 'short' : 'long')
372
+ : (side === 'buy' ? 'long' : 'short'));
286
373
  return sendEvent({
287
- event: 'perp_order_completed',
374
+ event,
288
375
  event_source: getEventSource(),
289
- event_id: crypto.randomUUID(),
376
+ event_id: eventId,
290
377
  user_id: null,
291
378
  anonymous_id: getAnonymousId(),
292
379
  session_id: getSessionId(),
@@ -295,9 +382,21 @@ export function trackPerpOrderCompleted({ command, side, oid }) {
295
382
  // "/perp/close"), so BI can line the two events up per command.
296
383
  path: commandToPath(`perp ${command}`),
297
384
  properties: {
298
- source: `nansen-cli/${cliVersion}`,
299
- side,
385
+ // Canonical trade events use a stable product source. The CLI release is
386
+ // already preserved in context.client_version by buildContext().
387
+ source: 'cli',
388
+ chain: 'hyperliquid',
389
+ attempt_id: attemptId,
390
+ action: command === 'close' ? 'close' : 'open',
391
+ position_side: positionSide,
392
+ order_side: side,
393
+ execution_status: outcome,
394
+ ...(submission_id !== undefined && { submission_id: String(submission_id) }),
395
+ leg_index,
396
+ leg,
397
+ ...(walletAddressHash && { wallet_address_hash: walletAddressHash }),
300
398
  ...(oid !== undefined && { oid }),
399
+ ...(error_code && { error_code }),
301
400
  },
302
401
  context: buildContext(),
303
402
  });
package/src/trading.js CHANGED
@@ -87,22 +87,41 @@ export function resolveTokenAddress(symbolOrAddress, chainName) {
87
87
  * @returns {Promise<*>} Parsed result value
88
88
  * @throws {Error} If chain has no configured RPC or the RPC returns an error
89
89
  */
90
+ // Error codes let a broadcasting caller (bridge.js) tell a DEFINITIVE rejection
91
+ // (the node refused the tx — nothing is in flight, safe to retry) apart from an
92
+ // AMBIGUOUS failure (a gateway/transport error that may have dropped the ack
93
+ // AFTER the node accepted the tx), so it can fail closed only on the latter:
94
+ // - RPC_UNCONFIGURED — no URL; thrown before any request leaves the process
95
+ // - RPC_NETWORK_ERROR — request left but no response (reset/timeout): ambiguous
96
+ // - RPC_HTTP_ERROR — non-JSON HTTP response (e.g. a 502 gateway page): ambiguous
97
+ // - RPC_JSON_ERROR — a JSON-RPC { error }: the node definitively rejected it
90
98
  export async function evmRpcCall(chain, method, params = []) {
91
99
  const rpcUrl = CHAIN_RPCS[chain];
92
- if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
93
- const res = await fetch(rpcUrl, {
94
- method: 'POST',
95
- headers: { 'Content-Type': 'application/json' },
96
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
97
- });
100
+ if (!rpcUrl) throw Object.assign(new Error(`No RPC URL configured for chain: ${chain}`), { code: 'RPC_UNCONFIGURED' });
101
+ let res;
102
+ try {
103
+ res = await fetch(rpcUrl, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
107
+ });
108
+ } catch (netErr) {
109
+ // The request left this process but no response came back. A reset or
110
+ // timeout can strike AFTER the node accepted the payload, so a caller that
111
+ // just broadcast a tx cannot assume it was never sent.
112
+ throw Object.assign(new Error(`RPC request to ${chain} failed for ${method}: ${netErr.message}`), { code: 'RPC_NETWORK_ERROR' });
113
+ }
98
114
  const text = await res.text();
99
115
  let body;
100
116
  try {
101
117
  body = JSON.parse(text);
102
118
  } catch {
103
- throw new Error(`RPC endpoint returned non-JSON response (HTTP ${res.status}) for ${method}: ${text.slice(0, 100)}`);
119
+ throw Object.assign(
120
+ new Error(`RPC endpoint returned non-JSON response (HTTP ${res.status}) for ${method}: ${text.slice(0, 100)}`),
121
+ { code: 'RPC_HTTP_ERROR', status: res.status },
122
+ );
104
123
  }
105
- if (body.error) throw new Error(`RPC error (${method}): ${body.error.message}`);
124
+ if (body.error) throw Object.assign(new Error(`RPC error (${method}): ${body.error.message}`), { code: 'RPC_JSON_ERROR' });
106
125
  return body.result;
107
126
  }
108
127
 
@@ -186,19 +205,72 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
186
205
  await new Promise(r => setTimeout(r, retryDelayMs));
187
206
  }
188
207
 
189
- const res = await fetch(`${TRADING_API_URL}/execute`, {
190
- method: 'POST',
191
- headers,
192
- body: JSON.stringify(params),
193
- });
208
+ let res;
209
+ try {
210
+ res = await fetch(`${TRADING_API_URL}/execute`, {
211
+ method: 'POST',
212
+ headers,
213
+ body: JSON.stringify(params),
214
+ });
215
+ } catch (netErr) {
216
+ // The POST left this process but no response came back (a reset/timeout).
217
+ // That may have struck AFTER the backend received the signed tx and
218
+ // broadcast it — indistinguishable from "never sent" — so treat it as
219
+ // BROADCAST_FAILED, the same fail-closed class as a 502. Retrying re-sends
220
+ // the SAME signed bytes (a byte-identical replay a node dedupes), so a
221
+ // retry here can't itself double-broadcast; only exhausting them fails
222
+ // closed at the caller (isFatalBroadcastError → mark the quote spent).
223
+ lastError = Object.assign(
224
+ new Error(`Execute POST to /execute failed: ${netErr.message}`),
225
+ { code: 'BROADCAST_FAILED' }
226
+ );
227
+ if (attempt < retries) continue;
228
+ throw lastError;
229
+ }
194
230
 
195
- const text = await res.text();
231
+ let text;
232
+ try {
233
+ text = await res.text();
234
+ } catch (bodyErr) {
235
+ // Headers arrived but the body read failed (a truncated/reset response).
236
+ // Like the network case above, the backend may already have broadcast, so
237
+ // fail closed as BROADCAST_FAILED rather than surface a codeless error the
238
+ // candidate loop would treat as nonfatal. A retry re-sends byte-identical
239
+ // bytes a node dedupes.
240
+ lastError = Object.assign(
241
+ new Error(`Execute response body read failed (status ${res.status}): ${bodyErr.message}`),
242
+ { code: 'BROADCAST_FAILED', status: res.status }
243
+ );
244
+ if (res.status >= 500 && attempt < retries) continue;
245
+ throw lastError;
246
+ }
247
+
248
+ // Parse up front so a JSON body can be preserved as structured details — but
249
+ // the classification below never lets a parseable body downgrade an
250
+ // ambiguous status to its own (nonfatal) code.
196
251
  let body;
252
+ let parsed = true;
197
253
  try {
198
254
  body = JSON.parse(text);
199
255
  } catch {
256
+ parsed = false;
257
+ }
258
+
259
+ // ANY 5xx is ambiguous no matter the body SHAPE: the gateway may have
260
+ // forwarded the signed tx upstream before failing (a JSON 502/504 like
261
+ // { code: "UPSTREAM_TIMEOUT" } is exactly that case, and a 500/504 carries
262
+ // the same "forwarded then lost the ack" risk as a 502/503). Classify EVERY
263
+ // 5xx as BROADCAST_FAILED and keep the body only as details — never fall
264
+ // through to the !res.ok branch below, which would surface a nonfatal
265
+ // upstream code and let the candidate loop broadcast the next quote on top
266
+ // of a live tx.
267
+ if (res.status >= 500) {
268
+ // Only append the simulation fee hint for a NON-JSON body. A structured
269
+ // JSON error (e.g. { code: "UPSTREAM_TIMEOUT" }) already explains itself
270
+ // via `details`; tacking "you may be out of SOL" onto a gateway timeout
271
+ // would misdirect the user.
200
272
  const chainType = params.chain && CHAIN_MAP[params.chain]?.type;
201
- const feeHint = res.status === 502
273
+ const feeHint = !parsed
202
274
  ? chainType === 'solana'
203
275
  ? ' This often means the transaction failed simulation — check that you have enough SOL for fees (~0.005 SOL minimum).'
204
276
  : chainType === 'evm'
@@ -206,11 +278,21 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
206
278
  : ''
207
279
  : '';
208
280
  lastError = Object.assign(
209
- new Error(`Execute API returned non-JSON response (status ${res.status}).${feeHint || ' This may be a Cloudflare challenge or server error.'}`),
281
+ new Error(`Execute API returned ${res.status} treating as an ambiguous broadcast failure; the transaction may already be live.${feeHint}`),
282
+ { code: 'BROADCAST_FAILED', status: res.status, details: parsed ? body : text.slice(0, 200) }
283
+ );
284
+ // Retry (re-POSTs byte-identical bytes) then fail closed at the caller.
285
+ if (attempt < retries) continue;
286
+ throw lastError;
287
+ }
288
+
289
+ if (!parsed) {
290
+ // Non-JSON on a sub-500 status (a Cloudflare challenge or HTML error
291
+ // page). Still uninterpretable after the POST went out, so fail closed.
292
+ lastError = Object.assign(
293
+ new Error(`Execute API returned non-JSON response (status ${res.status}). This may be a Cloudflare challenge or server error.`),
210
294
  { code: 'BROADCAST_FAILED', status: res.status, details: text.slice(0, 200) }
211
295
  );
212
- // Retry on 502/503 (likely transient Cloudflare issues)
213
- if ((res.status === 502 || res.status === 503) && attempt < retries) continue;
214
296
  throw lastError;
215
297
  }
216
298
 
@@ -434,9 +516,43 @@ export function loadQuote(quoteId) {
434
516
  if (data.type && data.type !== 'swap') {
435
517
  throw new Error(`Quote "${quoteId}" is a ${data.type} quote. Use the matching command (e.g. "nansen bridge execute" for a bridge quote).`);
436
518
  }
519
+ if (data.executedAt) {
520
+ // Quotes are single-use: re-signing and re-broadcasting would submit a
521
+ // second, independently valid swap — a fresh EVM nonce or Solana blockhash,
522
+ // not a byte-identical replay a node would reject. Refuse a quote that has
523
+ // already been broadcast, the way loadBridgeQuote does.
524
+ const when = new Date(data.executedAt).toISOString();
525
+ const hashes = (data.broadcasts || []).map(b => b.txHash).filter(Boolean);
526
+ const detail = hashes.length ? ` (${hashes.join(', ')})` : '';
527
+ throw new Error(
528
+ `Quote "${quoteId}" was already executed at ${when}${detail}. The transaction may still be pending — check the explorer before retrying. Request a new quote with "nansen trade quote" to trade again.`,
529
+ );
530
+ }
437
531
  return data;
438
532
  }
439
533
 
534
+ // Records that a broadcast has happened. `executedAt` is set on the first call
535
+ // and never moved, so the quote is consumed the instant the swap goes out — a
536
+ // later receipt-wait timeout or a REVERTED/failed outcome must not leave the
537
+ // quote reusable, since retrying would sign and broadcast a second,
538
+ // independently valid swap. Mirrors markBridgeQuoteExecuted (bridge.js).
539
+ export function markQuoteExecuted(quoteId, progress = {}) {
540
+ const filePath = safeQuotesPath(`${quoteId}.json`);
541
+ if (!filePath || !fs.existsSync(filePath)) return;
542
+ try {
543
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
544
+ data.executedAt = data.executedAt || Date.now();
545
+ if (progress.broadcast) {
546
+ data.broadcasts = [...(data.broadcasts || []), { ...progress.broadcast, at: Date.now() }];
547
+ }
548
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
549
+ } catch {
550
+ // Best-effort: if the marker can't be written, the next execute attempt
551
+ // will still proceed, but that's preferable to crashing after a successful
552
+ // broadcast.
553
+ }
554
+ }
555
+
440
556
  /**
441
557
  * Remove stale files from the quotes dir. Quote files use a 1-hour TTL because
442
558
  * the price is stale; tx records use a 30-day TTL because a finalized tx hash
@@ -811,6 +927,12 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
811
927
  * - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so
812
928
  * retrying would race a second tx against the same nonce
813
929
  * (a confirmed on-chain revert is NOT this — it may retry)
930
+ * - BROADCAST_FAILED — /execute returned an uninterpretable response (non-JSON,
931
+ * typically a 502/503 after all retries) AFTER we POSTed
932
+ * the signed tx. A dropped ack is indistinguishable from
933
+ * "never sent", so the backend may already have broadcast
934
+ * it; failing closed here trades a needless re-quote for
935
+ * never trying the next candidate on top of a live tx.
814
936
  *
815
937
  * @param {Error} err
816
938
  * @returns {boolean}
@@ -818,7 +940,8 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
818
940
  function isFatalBroadcastError(err) {
819
941
  return err?.code === 'TXHASH_MISMATCH'
820
942
  || err?.code === 'INVALID_SIGNED_TX'
821
- || err?.code === 'RECEIPT_TIMEOUT';
943
+ || err?.code === 'RECEIPT_TIMEOUT'
944
+ || err?.code === 'BROADCAST_FAILED';
822
945
  }
823
946
 
824
947
  /**
@@ -3172,6 +3295,12 @@ EXAMPLES:
3172
3295
  }
3173
3296
 
3174
3297
  if (wcResult.txHash) {
3298
+ // The wallet already broadcast — the quote is spent right here,
3299
+ // before the receipt wait below can throw RECEIPT_TIMEOUT and
3300
+ // abort this function without ever reaching the shared
3301
+ // executeTransaction() marker further down.
3302
+ markQuoteExecuted(quoteId, { broadcast: { txHash: wcResult.txHash } });
3303
+
3175
3304
  // Wallet broadcast — verify on-chain
3176
3305
  log(' Verifying on-chain status...');
3177
3306
  try {
@@ -3521,12 +3650,36 @@ EXAMPLES:
3521
3650
  execParams.requestId = requestId; // Solana Jupiter Ultra
3522
3651
  }
3523
3652
 
3524
- const result = await executeTransaction(execParams);
3653
+ // A retry re-POSTs the signed payload. For a normal swap that's a
3654
+ // byte-identical replay the node dedupes, so retrying an ambiguous
3655
+ // 5xx/network failure can't itself double-broadcast. But a --gasless
3656
+ // Relay swap sends a signed AUTHORIZATION, and Relay's solver
3657
+ // broadcasts its OWN wrapping tx from it (the returned txHash is not
3658
+ // our bytes) — so a re-POST after the solver already picked it up
3659
+ // can't be deduped at the node level and risks a second solve. For
3660
+ // gasless we therefore don't retry: a single POST either succeeds or
3661
+ // fails closed (BROADCAST_FAILED marks the quote spent and aborts).
3662
+ const result = await executeTransaction(execParams, { retries: gasless ? 0 : undefined });
3525
3663
 
3526
3664
  if (result.status === 'Success') {
3527
3665
  let txId = result.signature || result.txHash;
3528
3666
  let explorerUrl = chainConfig.explorer + txId;
3529
3667
 
3668
+ // The transaction is on-chain (or in flight) the instant the
3669
+ // Trading API accepts it — the quote is spent here, before the
3670
+ // on-chain verification below can throw RECEIPT_TIMEOUT (or
3671
+ // anything else) and abort this function. Covers local EVM,
3672
+ // Privy EVM, Privy Solana, local/WalletConnect Solana, the
3673
+ // WalletConnect sign-only fallback, and --gasless Relay — every
3674
+ // path that reaches this shared broadcast call.
3675
+ //
3676
+ // Deliberate: a broadcast that later reverts on-chain (the
3677
+ // "Trying next quote" path below) still consumes the quote —
3678
+ // the revert still burned the nonce, so re-signing this same
3679
+ // quote for a retry would race the reverted tx's nonce. This is
3680
+ // intentional, not an oversight.
3681
+ markQuoteExecuted(quoteId, { broadcast: { txHash: txId } });
3682
+
3530
3683
  // For EVM: verify the tx actually succeeded on-chain
3531
3684
  if (chainType === 'evm') {
3532
3685
  log(' Verifying on-chain status...');
@@ -3629,15 +3782,36 @@ EXAMPLES:
3629
3782
  } else {
3630
3783
  log(`\n ✗ Quote ${quoteName} failed: ${result.status}`);
3631
3784
  if (result.error) log(` Error: ${result.error}`);
3785
+ // A non-Success result can still carry a hash — the same
3786
+ // /execute response shape (status: 'Failed' + txHash) is
3787
+ // observed for approval broadcasts in trading.test.js, so a
3788
+ // "Failed" swap isn't provably unbroadcast either. We can't
3789
+ // tell from here whether the hash means the tx actually went
3790
+ // out, but the asymmetry favors marking: a needless re-quote
3791
+ // is cheaper than a silent double broadcast.
3792
+ const failedTxId = result.signature || result.txHash;
3793
+ if (failedTxId) markQuoteExecuted(quoteId, { broadcast: { txHash: failedTxId } });
3632
3794
  lastQuoteError = `${quoteName}: ${result.error || result.status}`;
3633
3795
  if (qi + 1 < endIndex) log(` Trying next quote...`);
3634
3796
  }
3635
3797
 
3636
3798
  } catch (quoteErr) {
3799
+ // A BROADCAST_FAILED throws out of executeTransaction — BEFORE the
3800
+ // normal markQuoteExecuted runs — so nothing has recorded this quote
3801
+ // as spent. The signed tx may already be live on the backend (a 502
3802
+ // on the ack, not on the send), so fail closed: mark it here so a
3803
+ // later "trade execute --quote <id>" (or an agent auto-retry) is
3804
+ // refused before it re-signs under a fresh nonce. No broadcast hash
3805
+ // is recorded — we don't have one — which yields loadQuote's generic
3806
+ // "may still be pending, check the explorer" message.
3807
+ if (quoteErr?.code === 'BROADCAST_FAILED') {
3808
+ markQuoteExecuted(quoteId);
3809
+ }
3637
3810
  // Post-broadcast failures abort the whole execute — never retry the
3638
3811
  // next quote once a transaction is already out and its outcome is
3639
- // unknown (mismatch, underivable local hash, or an unconfirmed
3640
- // receipt timeout). See isFatalBroadcastError.
3812
+ // unknown (mismatch, underivable local hash, an unconfirmed receipt
3813
+ // timeout, or an ambiguous broadcast failure). See
3814
+ // isFatalBroadcastError.
3641
3815
  if (isFatalBroadcastError(quoteErr)) throw quoteErr;
3642
3816
  const msg = quoteErr.message || '';
3643
3817
  log(` ❌ Quote ${quoteName} failed: ${msg}`);
package/src/transfer.js CHANGED
@@ -49,7 +49,10 @@ function validateSolanaAddress(address) {
49
49
  // ============= Amount Parsing =============
50
50
 
51
51
  function parseAmount(amountStr, decimals) {
52
- const parts = amountStr.split('.');
52
+ const str = String(amountStr).trim();
53
+ if (str.startsWith('-')) throw new Error('Amount must be positive');
54
+ if (!/^\d+(\.\d+)?$/.test(str)) throw new Error('Amount must be a valid number');
55
+ const parts = str.split('.');
53
56
  const whole = parts[0] || '0';
54
57
  let frac = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
55
58
  return BigInt(whole) * (10n ** BigInt(decimals)) + BigInt(frac);
@@ -9,6 +9,7 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import childProcess from 'child_process';
11
11
  import { fileURLToPath } from 'url';
12
+ import { compareSemver } from './semver.js';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
@@ -19,14 +20,20 @@ const PACKAGE_NAME = 'nansen-cli';
19
20
  /**
20
21
  * Compare two semver strings. Returns true if latest > current.
21
22
  * Exported so `nansen doctor` reports upgrade state with identical semantics.
23
+ *
24
+ * Delegates to the shared compareSemver (src/semver.js, also used by `nansen
25
+ * changelog --since`) instead of hand-rolling its own parser. The previous
26
+ * inline parser here had the same bug compareSemver used to have: a version
27
+ * string with fewer than 3 components (e.g. a hand-edited or truncated cache
28
+ * file) parsed its missing component as `undefined`, and `>` is always
29
+ * `false` against `undefined` in both directions — so a partial version
30
+ * always read as "not newer", never "newer". In practice both `latest` (from
31
+ * the npm registry) and `current` (from this package's own version) are
32
+ * always full x.y.z today, so this couldn't misfire yet — but it's the same
33
+ * defect class, so it's fixed the same way rather than left as a landmine.
22
34
  */
23
35
  export function isNewer(latest, current) {
24
- const parse = v => v.replace(/^v/, '').split('.').map(Number);
25
- const [lM, lm, lp] = parse(latest);
26
- const [cM, cm, cp] = parse(current);
27
- if (lM !== cM) return lM > cM;
28
- if (lm !== cm) return lm > cm;
29
- return lp > cp;
36
+ return compareSemver(latest, current) > 0;
30
37
  }
31
38
 
32
39
  const LAST_VERSION_FILE = path.join(CONFIG_DIR, 'last-version.json');
package/src/wallet.js CHANGED
@@ -801,13 +801,15 @@ export function buildWalletCommands(deps = {}) {
801
801
  throw new CommandError('Usage: nansen wallet delete <name>', 'MISSING_ARGS');
802
802
  }
803
803
 
804
- // Check if this is a Privy wallet (no password needed)
804
+ // Check if this is a Privy wallet (no password needed).
805
+ // getWalletFile() runs validateWalletName(), which rejects any name
806
+ // outside [a-zA-Z0-9_-]{1,64} — so the path is confined to the wallets
807
+ // dir and cannot traverse. deleteWallet() re-validates below.
805
808
  let isPrivy = false;
806
809
  try {
807
- const walletFile = path.join(getWalletsDir(), `${name}.json`);
808
- const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
810
+ const data = JSON.parse(fs.readFileSync(getWalletFile(name), 'utf8'));
809
811
  if (data.provider === 'privy') isPrivy = true;
810
- } catch { /* file might not exist, deleteWallet will throw */ }
812
+ } catch { /* invalid/missing name; deleteWallet will validate and throw */ }
811
813
 
812
814
  let password = null;
813
815
  if (!isPrivy) {
@@ -862,8 +864,10 @@ export function buildWalletCommands(deps = {}) {
862
864
  try {
863
865
  const walletName = options.wallet || getWalletConfig().defaultWallet;
864
866
  if (walletName) {
865
- const walletFile = path.join(getWalletsDir(), `${walletName}.json`);
866
- const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
867
+ // getWalletFile() runs validateWalletName(), confining the path to
868
+ // the wallets dir; an invalid name throws and is ignored here, and
869
+ // the real wallet load downstream validates again.
870
+ const data = JSON.parse(fs.readFileSync(getWalletFile(walletName), 'utf8'));
867
871
  if (data.provider === 'privy') isPrivyWallet = true;
868
872
  }
869
873
  } catch { /* ignore */ }
package/src/x402.js CHANGED
@@ -285,7 +285,14 @@ export async function checkX402Balance(network, asset = null) {
285
285
  }),
286
286
  });
287
287
  const data = await resp.json();
288
- return { balance: parseInt(data.result, 16) / 10 ** decimals, symbol };
288
+ // Use BigInt to avoid precision loss on 18-decimal tokens (BSC stablecoins).
289
+ if (!data.result || data.result === "0x") return { balance: 0, symbol };
290
+ const raw = BigInt(data.result);
291
+ const divisor = 10n ** BigInt(decimals);
292
+ // Fractional part is display-only (.toFixed(2)); sub-cent precision not guaranteed.
293
+ const whole = Number(raw / divisor);
294
+ const frac = Number((raw % divisor) * 10000n / divisor) / 10000;
295
+ return { balance: whole + frac, symbol };
289
296
  }
290
297
 
291
298
  return null;