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/CHANGELOG.md +80 -0
- package/README.md +34 -0
- package/package.json +3 -3
- package/src/api.js +184 -23
- package/src/bridge.js +103 -5
- package/src/cli.js +47 -53
- package/src/commands/completion.js +652 -0
- package/src/commands/mcp.js +19 -3
- package/src/commands/research.js +199 -25
- package/src/hl-client.js +20 -8
- package/src/limit-order.js +31 -20
- package/src/mcp-verify.js +66 -1
- package/src/perp.js +72 -24
- package/src/query-options.js +32 -0
- package/src/schema.json +631 -131
- package/src/semver.js +26 -0
- package/src/telemetry.js +118 -19
- package/src/trading.js +196 -22
- package/src/transfer.js +4 -1
- package/src/update-check.js +13 -6
- package/src/wallet.js +10 -6
- package/src/x402.js +8 -1
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
*
|
|
272
|
-
*
|
|
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
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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 -
|
|
283
|
-
* @param {
|
|
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({
|
|
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
|
|
374
|
+
event,
|
|
288
375
|
event_source: getEventSource(),
|
|
289
|
-
event_id:
|
|
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
|
|
299
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
3640
|
-
//
|
|
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
|
|
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);
|
package/src/update-check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 { /*
|
|
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
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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;
|