nansen-cli 1.27.0 → 1.28.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 +14 -0
- package/package.json +1 -1
- package/skills/nansen-token-screener/SKILL.md +54 -0
- package/src/api.js +7 -0
- package/src/cli.js +49 -5
- package/src/keychain.js +1 -0
- package/src/limit-order.js +873 -0
- package/src/privy.js +9 -0
- package/src/schema.json +146 -1
- package/src/telemetry.js +5 -1
- package/src/trading.js +14 -0
- package/src/transfer.js +1 -1
- package/src/walletconnect-trading.js +25 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI - Limit Order Commands (Jupiter Trigger V2)
|
|
3
|
+
*
|
|
4
|
+
* Supports create, list, cancel, and update of limit orders on Solana.
|
|
5
|
+
* Uses challenge-response JWT auth with disk caching.
|
|
6
|
+
* Zero external dependencies — uses Node.js built-in crypto only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { base58Encode, exportWallet, getWalletConfig, showWallet } from './wallet.js';
|
|
12
|
+
import { signEd25519, base58Decode, parseAmount, getTokenInfo } from './transfer.js';
|
|
13
|
+
import { signSolanaTransaction, resolveTokenAddress } from './trading.js';
|
|
14
|
+
import { validateTokenAddress } from './api.js';
|
|
15
|
+
import { getWalletConnectAddress, sendSolanaTransactionViaWalletConnect, signSolanaMessageViaWalletConnect } from './walletconnect-trading.js';
|
|
16
|
+
import { retrievePassword } from './keychain.js';
|
|
17
|
+
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
18
|
+
|
|
19
|
+
// ============= Constants =============
|
|
20
|
+
|
|
21
|
+
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
22
|
+
const LO_PREFIX = '/limit-order/v2';
|
|
23
|
+
const SOLSCAN_TX_URL = 'https://solscan.io/tx/';
|
|
24
|
+
|
|
25
|
+
// ============= JWT Auth & Caching (Local File) =============
|
|
26
|
+
|
|
27
|
+
function getAuthFilePath() {
|
|
28
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
29
|
+
return path.join(home, '.nansen', 'limit-order-auth.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Save a JWT token to ~/.nansen/limit-order-auth.json.
|
|
34
|
+
* Keyed by wallet pubkey so switching wallets invalidates correctly.
|
|
35
|
+
*/
|
|
36
|
+
export function saveCachedToken(walletPubkey, token) {
|
|
37
|
+
try {
|
|
38
|
+
const filePath = getAuthFilePath();
|
|
39
|
+
const dir = path.dirname(filePath);
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
fs.mkdirSync(dir, { mode: 0o700, recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const data = JSON.stringify({
|
|
44
|
+
walletPubkey,
|
|
45
|
+
token,
|
|
46
|
+
// 23-hour TTL provides 1-hour safety margin against server's 24-hour JWT
|
|
47
|
+
expiresAt: Date.now() + 23 * 3600 * 1000,
|
|
48
|
+
});
|
|
49
|
+
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load a cached JWT token from ~/.nansen/limit-order-auth.json.
|
|
58
|
+
* Returns the token string if valid and not expired, null otherwise.
|
|
59
|
+
*/
|
|
60
|
+
export function loadCachedToken(walletPubkey) {
|
|
61
|
+
try {
|
|
62
|
+
const filePath = getAuthFilePath();
|
|
63
|
+
if (!fs.existsSync(filePath)) return null;
|
|
64
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
65
|
+
const data = JSON.parse(raw);
|
|
66
|
+
if (data.walletPubkey !== walletPubkey) return null;
|
|
67
|
+
// 5-minute buffer before expiry to avoid mid-request failures
|
|
68
|
+
if (data.expiresAt <= Date.now() + 300_000) return null;
|
|
69
|
+
return data.token;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============= API Client =============
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Make an authenticated request to the limit order V2 API.
|
|
79
|
+
*/
|
|
80
|
+
async function loFetch(method, endpoint, { token, body, query } = {}) {
|
|
81
|
+
const url = new URL(`${LO_PREFIX}${endpoint}`, TRADING_API_URL);
|
|
82
|
+
if (query) {
|
|
83
|
+
for (const [key, value] of Object.entries(query)) {
|
|
84
|
+
if (value !== undefined && value !== null) {
|
|
85
|
+
url.searchParams.set(key, String(value));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const headers = {
|
|
91
|
+
'Accept': 'application/json',
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
};
|
|
94
|
+
if (token) {
|
|
95
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
96
|
+
}
|
|
97
|
+
if (process.env.NANSEN_API_KEY) {
|
|
98
|
+
headers['X-API-Key'] = process.env.NANSEN_API_KEY;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const opts = { method, headers };
|
|
102
|
+
if (body !== undefined) {
|
|
103
|
+
opts.body = JSON.stringify(body);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const res = await fetch(url.toString(), opts);
|
|
107
|
+
const text = await res.text();
|
|
108
|
+
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(text);
|
|
112
|
+
} catch {
|
|
113
|
+
throw Object.assign(
|
|
114
|
+
new Error(`Limit order API returned non-JSON response (status ${res.status})`),
|
|
115
|
+
{ code: 'NON_JSON_RESPONSE', status: res.status, details: text.slice(0, 200) }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
const code = parsed.code || 'LIMIT_ORDER_ERROR';
|
|
121
|
+
const msg = parsed.message || `Limit order request failed with status ${res.status}`;
|
|
122
|
+
throw Object.assign(new Error(msg), { code, status: res.status, details: parsed.details });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Auth endpoints (no JWT required) ---
|
|
129
|
+
|
|
130
|
+
export async function getChallenge(walletPubkey) {
|
|
131
|
+
return loFetch('POST', '/auth/challenge', { body: { walletPubkey } });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function verifyChallenge(walletPubkey, signatureBase58) {
|
|
135
|
+
return loFetch('POST', '/auth/verify', { body: { walletPubkey, signature: signatureBase58 } });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Vault endpoints ---
|
|
139
|
+
|
|
140
|
+
export async function getVault(token, userPubkey) {
|
|
141
|
+
return loFetch('GET', '/vault', { token, query: { userPubkey } });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function registerVault(token) {
|
|
145
|
+
return loFetch('POST', '/vault/register', { token, body: {} });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Order lifecycle endpoints ---
|
|
149
|
+
|
|
150
|
+
export async function craftDeposit(token, { inputMint, outputMint, userAddress, amount }) {
|
|
151
|
+
return loFetch('POST', '/deposit/craft', {
|
|
152
|
+
token,
|
|
153
|
+
body: { inputMint, outputMint, userAddress, amount },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function createOrder(token, params) {
|
|
158
|
+
return loFetch('POST', '/create', { token, body: params });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function listOrders(token, userPubkey, filters = {}) {
|
|
162
|
+
return loFetch('GET', '/orders', {
|
|
163
|
+
token,
|
|
164
|
+
query: { userPubkey, ...filters },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function updateOrder(token, orderId, params) {
|
|
169
|
+
return loFetch('PATCH', `/orders/${orderId}`, { token, body: params });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function cancelOrderRequest(token, orderId) {
|
|
173
|
+
return loFetch('POST', `/cancel/${orderId}`, { token, body: {} });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function confirmCancelOrder(token, orderId, { signedTransaction, cancelRequestId }) {
|
|
177
|
+
return loFetch('POST', `/cancel/${orderId}/confirm`, {
|
|
178
|
+
token,
|
|
179
|
+
body: { signedTransaction, cancelRequestId },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============= Message Signing =============
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Sign a message with a Solana wallet.
|
|
187
|
+
* Returns raw signature bytes as a Buffer.
|
|
188
|
+
*
|
|
189
|
+
* @param {Buffer} message - Raw message bytes
|
|
190
|
+
* @param {'local'|'privy'|'walletconnect'} walletType
|
|
191
|
+
* @param {object} walletInfo - Type-specific signing info
|
|
192
|
+
* @returns {Promise<Buffer>} Raw Ed25519 signature (64 bytes)
|
|
193
|
+
*/
|
|
194
|
+
export async function signSolanaMessage(message, walletType, walletInfo) {
|
|
195
|
+
if (walletType === 'local') {
|
|
196
|
+
// Extract seed (first 32 bytes of the 64-byte keypair hex)
|
|
197
|
+
const seed = Buffer.from(walletInfo.privateKeyHex.slice(0, 64), 'hex');
|
|
198
|
+
return signEd25519(message, seed);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (walletType === 'privy') {
|
|
202
|
+
const result = await walletInfo.privyClient.signSolanaMessage(
|
|
203
|
+
walletInfo.walletId,
|
|
204
|
+
message,
|
|
205
|
+
);
|
|
206
|
+
const sigBase64 = result.data?.signature || result.signature;
|
|
207
|
+
return Buffer.from(sigBase64, 'base64');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (walletType === 'walletconnect') {
|
|
211
|
+
const result = await signSolanaMessageViaWalletConnect(message);
|
|
212
|
+
// WC returns base58-encoded signature
|
|
213
|
+
return Buffer.from(base58Decode(result.signature));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new Error(`Unsupported wallet type: ${walletType}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============= Authentication Flow =============
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Authenticate with the limit order API and return a JWT.
|
|
223
|
+
* Uses disk cache to avoid re-signing for every CLI invocation.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} walletPubkey - Solana wallet address
|
|
226
|
+
* @param {'local'|'privy'|'walletconnect'} walletType
|
|
227
|
+
* @param {object} walletInfo - Signing info
|
|
228
|
+
* @param {function} log - Logger
|
|
229
|
+
* @returns {Promise<string>} JWT token
|
|
230
|
+
*/
|
|
231
|
+
export async function authenticate(walletPubkey, walletType, walletInfo, log = () => {}) {
|
|
232
|
+
const cached = loadCachedToken(walletPubkey);
|
|
233
|
+
if (cached) {
|
|
234
|
+
return cached;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
log(' Authenticating with limit order API...');
|
|
238
|
+
const { challenge } = await getChallenge(walletPubkey);
|
|
239
|
+
const messageBuffer = Buffer.from(challenge, 'utf8');
|
|
240
|
+
|
|
241
|
+
log(' Signing challenge...');
|
|
242
|
+
const signatureBytes = await signSolanaMessage(messageBuffer, walletType, walletInfo);
|
|
243
|
+
const signatureBase58 = base58Encode(signatureBytes);
|
|
244
|
+
|
|
245
|
+
const { token } = await verifyChallenge(walletPubkey, signatureBase58);
|
|
246
|
+
saveCachedToken(walletPubkey, token);
|
|
247
|
+
|
|
248
|
+
return token;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============= Wallet Resolution =============
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Resolve a Solana wallet for limit orders.
|
|
255
|
+
* Follows the same 3-way dispatch as trading.js: WalletConnect / named / default.
|
|
256
|
+
*
|
|
257
|
+
* @returns {{ pubkey, walletType, walletInfo, privyWalletIds }}
|
|
258
|
+
*/
|
|
259
|
+
export async function resolveSolanaWallet(walletName, deps = {}) {
|
|
260
|
+
const { log = console.log, exit = process.exit } = deps;
|
|
261
|
+
|
|
262
|
+
const isWalletConnect = walletName === 'walletconnect' || walletName === 'wc';
|
|
263
|
+
|
|
264
|
+
if (isWalletConnect) {
|
|
265
|
+
const address = await getWalletConnectAddress('solana');
|
|
266
|
+
if (!address) {
|
|
267
|
+
log('No WalletConnect session active. Run: walletconnect connect');
|
|
268
|
+
exit(1);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return { pubkey: address, walletType: 'walletconnect', walletInfo: {}, privyWalletIds: null };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let wallet;
|
|
275
|
+
if (walletName) {
|
|
276
|
+
wallet = showWallet(walletName);
|
|
277
|
+
} else {
|
|
278
|
+
try {
|
|
279
|
+
const config = getWalletConfig();
|
|
280
|
+
if (config.defaultWallet) {
|
|
281
|
+
wallet = showWallet(config.defaultWallet);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// No wallet configured
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!wallet || !wallet.solana) {
|
|
289
|
+
log('No Solana wallet found. Create one with: nansen wallet create');
|
|
290
|
+
exit(1);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (wallet.provider === 'privy') {
|
|
295
|
+
const { PrivyClient } = await import('./privy.js');
|
|
296
|
+
const privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
297
|
+
return {
|
|
298
|
+
pubkey: wallet.solana,
|
|
299
|
+
walletType: 'privy',
|
|
300
|
+
walletInfo: { privyClient, walletId: wallet.privyWalletIds?.solana },
|
|
301
|
+
privyWalletIds: wallet.privyWalletIds,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Local wallet — need password for signing
|
|
306
|
+
return {
|
|
307
|
+
pubkey: wallet.solana,
|
|
308
|
+
walletType: 'local',
|
|
309
|
+
walletInfo: {}, // privateKeyHex populated lazily when signing is needed
|
|
310
|
+
walletName: wallet.name,
|
|
311
|
+
privyWalletIds: null,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get the private key hex for a local wallet, prompting for password if needed.
|
|
317
|
+
*/
|
|
318
|
+
function getLocalWalletPrivateKey(walletName) {
|
|
319
|
+
const config = getWalletConfig();
|
|
320
|
+
let password = null;
|
|
321
|
+
if (config.passwordHash) {
|
|
322
|
+
const result = retrievePassword();
|
|
323
|
+
password = result.password;
|
|
324
|
+
if (!password) {
|
|
325
|
+
throw new Error('Wallet is encrypted and no password was found. Set NANSEN_WALLET_PASSWORD env var.');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const effectiveName = walletName || config.defaultWallet;
|
|
329
|
+
const exported = exportWallet(effectiveName, password);
|
|
330
|
+
return exported.solana.privateKey;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ============= Transaction Signing =============
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Sign a Solana transaction (base64) using the appropriate wallet type.
|
|
337
|
+
* Returns base64-encoded signed transaction.
|
|
338
|
+
*/
|
|
339
|
+
export async function signTransaction(txBase64, walletType, walletInfo) {
|
|
340
|
+
if (walletType === 'local') {
|
|
341
|
+
return signSolanaTransaction(txBase64, walletInfo.privateKeyHex);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (walletType === 'privy') {
|
|
345
|
+
const result = await walletInfo.privyClient.signSolanaTransaction(
|
|
346
|
+
walletInfo.walletId,
|
|
347
|
+
txBase64,
|
|
348
|
+
);
|
|
349
|
+
return result.data?.signed_transaction || result.signed_transaction;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (walletType === 'walletconnect') {
|
|
353
|
+
// WC expects base58 for Solana transactions
|
|
354
|
+
const txBytes = Buffer.from(txBase64, 'base64');
|
|
355
|
+
const txBase58 = base58Encode(txBytes);
|
|
356
|
+
const result = await sendSolanaTransactionViaWalletConnect(txBase58);
|
|
357
|
+
if (result.signedTransaction) {
|
|
358
|
+
// WC returns base58; convert to base64
|
|
359
|
+
const signedBytes = base58Decode(result.signedTransaction);
|
|
360
|
+
return Buffer.from(signedBytes).toString('base64');
|
|
361
|
+
}
|
|
362
|
+
throw new Error('WalletConnect did not return a signed transaction');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
throw new Error(`Unsupported wallet type: ${walletType}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============= Expiry Parsing =============
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse an expiry duration string to epoch milliseconds.
|
|
372
|
+
* Accepts: "24h", "7d", "30d", or raw epoch ms string.
|
|
373
|
+
* Returns null for no expiry.
|
|
374
|
+
*/
|
|
375
|
+
export function parseExpiry(expiryStr) {
|
|
376
|
+
if (!expiryStr || expiryStr === 'never') return null;
|
|
377
|
+
|
|
378
|
+
const match = expiryStr.match(/^(\d+)(h|d)$/i);
|
|
379
|
+
if (match) {
|
|
380
|
+
const value = parseInt(match[1], 10);
|
|
381
|
+
const unit = match[2].toLowerCase();
|
|
382
|
+
const ms = unit === 'h' ? value * 3600 * 1000 : value * 24 * 3600 * 1000;
|
|
383
|
+
return Date.now() + ms;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Try as raw epoch ms
|
|
387
|
+
const num = Number(expiryStr);
|
|
388
|
+
if (!isNaN(num) && num > Date.now() - 86400000) {
|
|
389
|
+
return num;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
throw new Error(`Invalid expiry format: "${expiryStr}". Use "24h", "7d", "30d", or epoch ms.`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============= Order Formatting =============
|
|
396
|
+
|
|
397
|
+
function formatOrderStatus(status) {
|
|
398
|
+
const map = {
|
|
399
|
+
pending: 'Pending',
|
|
400
|
+
open: 'Open',
|
|
401
|
+
executing: 'Executing',
|
|
402
|
+
filled: 'Filled',
|
|
403
|
+
pending_withdraw: 'Withdrawing',
|
|
404
|
+
cancelled: 'Cancelled',
|
|
405
|
+
expired: 'Expired',
|
|
406
|
+
failed: 'Failed',
|
|
407
|
+
};
|
|
408
|
+
return map[status] || status;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Reverse lookup: address → { symbol, decimals } for known Solana tokens
|
|
412
|
+
const KNOWN_SOLANA_TOKENS = {
|
|
413
|
+
'So11111111111111111111111111111111111111112': { symbol: 'SOL', decimals: 9 },
|
|
414
|
+
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': { symbol: 'USDC', decimals: 6 },
|
|
415
|
+
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': { symbol: 'USDT', decimals: 6 },
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
function tokenLabel(address) {
|
|
419
|
+
if (!address) return '?';
|
|
420
|
+
const info = KNOWN_SOLANA_TOKENS[address];
|
|
421
|
+
return info ? `${info.symbol} (${address})` : address;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Format a base-unit amount to human-readable (e.g. 116000000 SOL → "0.116 SOL").
|
|
426
|
+
* Falls back to raw amount for unknown tokens.
|
|
427
|
+
*/
|
|
428
|
+
function formatAmount(amount, mintAddress) {
|
|
429
|
+
if (!amount) return '?';
|
|
430
|
+
const info = KNOWN_SOLANA_TOKENS[mintAddress];
|
|
431
|
+
if (!info) return `${amount} ${mintAddress || '?'}`;
|
|
432
|
+
const raw = BigInt(amount);
|
|
433
|
+
const divisor = BigInt(10 ** info.decimals);
|
|
434
|
+
const whole = raw / divisor;
|
|
435
|
+
const frac = raw % divisor;
|
|
436
|
+
const fracStr = frac.toString().padStart(info.decimals, '0').replace(/0+$/, '');
|
|
437
|
+
const humanAmount = fracStr ? `${whole}.${fracStr}` : `${whole}`;
|
|
438
|
+
return `${humanAmount} ${info.symbol} (${amount} base units)`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function formatTimestamp(ts) {
|
|
442
|
+
if (!ts) return '?';
|
|
443
|
+
const num = Number(ts);
|
|
444
|
+
if (isNaN(num)) return ts;
|
|
445
|
+
const date = new Date(num);
|
|
446
|
+
return `${date.toLocaleString()} (${date.toISOString()})`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function formatOrder(order, index) {
|
|
450
|
+
const lines = [];
|
|
451
|
+
const label = index !== undefined ? ` Order #${index + 1}` : ' Order';
|
|
452
|
+
lines.push(`${label} (${order.id})`);
|
|
453
|
+
lines.push(` Status: ${formatOrderStatus(order.status)}`);
|
|
454
|
+
lines.push(` Sell: ${formatAmount(order.inputAmount, order.inputMint)}`);
|
|
455
|
+
lines.push(` Buy: ${tokenLabel(order.outputMint)}`);
|
|
456
|
+
lines.push(` Trigger: ${order.triggerCondition} $${order.triggerPriceUsd} on ${tokenLabel(order.triggerMint)}`);
|
|
457
|
+
lines.push(` Slippage: ${order.slippageBps != null ? `${order.slippageBps} bps` : 'auto'}`);
|
|
458
|
+
lines.push(` Created: ${formatTimestamp(order.createdAt)}`);
|
|
459
|
+
if (order.expiresAt) lines.push(` Expires: ${formatTimestamp(order.expiresAt)}`);
|
|
460
|
+
if (order.fills?.length > 0) {
|
|
461
|
+
lines.push(` Fills: ${order.fills.length}`);
|
|
462
|
+
for (const fill of order.fills) {
|
|
463
|
+
lines.push(` ${fill.inputAmount} → ${fill.outputAmount} (${fill.txSignature?.slice(0, 12)}...)`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return lines.join('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ============= CLI Command Builder =============
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Build limit order command handlers for CLI integration.
|
|
473
|
+
*/
|
|
474
|
+
export function buildLimitOrderCommands(deps = {}) {
|
|
475
|
+
const { log = console.log, exit = process.exit } = deps;
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
'create': async (args, apiInstance, flags, options) => {
|
|
479
|
+
const fromRaw = options.from || options['from-token'] || args[0];
|
|
480
|
+
const toRaw = options.to || options['to-token'] || args[1];
|
|
481
|
+
const from = resolveTokenAddress(fromRaw, 'solana');
|
|
482
|
+
const to = resolveTokenAddress(toRaw, 'solana');
|
|
483
|
+
const amount = options.amount || args[2];
|
|
484
|
+
const triggerPrice = options['trigger-price'];
|
|
485
|
+
const triggerCondition = options['trigger-condition'];
|
|
486
|
+
const triggerMintRaw = options['trigger-mint'];
|
|
487
|
+
const slippageBps = options['slippage-bps'] != null ? Number(options['slippage-bps']) : undefined;
|
|
488
|
+
const expiresStr = options.expires || '30d';
|
|
489
|
+
const walletName = options.wallet;
|
|
490
|
+
|
|
491
|
+
if (!from || !to || !amount || triggerPrice == null || !triggerMintRaw || !triggerCondition) {
|
|
492
|
+
log(`
|
|
493
|
+
Usage: nansen trade limit-order create --from <token> --to <token> --amount <amount> --trigger-mint <token> --trigger-condition <above|below> --trigger-price <usd>
|
|
494
|
+
|
|
495
|
+
OPTIONS:
|
|
496
|
+
--from <symbol|address> Token to sell (symbol like SOL, USDC or address)
|
|
497
|
+
--to <symbol|address> Token to buy (symbol like USDC, SOL or address)
|
|
498
|
+
--amount <amount> Amount to sell in token units (e.g. 1.5 for 1.5 SOL, 80 for 80 USDC)
|
|
499
|
+
--trigger-mint <symbol|addr> Token whose price triggers the order (e.g. SOL)
|
|
500
|
+
--trigger-condition <cond> "above" or "below"
|
|
501
|
+
--trigger-price <usd> Trigger price in USD (must be a positive number)
|
|
502
|
+
--slippage-bps <bps> Slippage in basis points (100 = 1%), omit for auto
|
|
503
|
+
--expires <duration> Expiry duration: "24h", "7d", "30d" (default: 30d)
|
|
504
|
+
--wallet <name> Wallet name (or "walletconnect"/"wc")
|
|
505
|
+
|
|
506
|
+
EXAMPLES:
|
|
507
|
+
# Sell 1 SOL for USDC when SOL drops below $80
|
|
508
|
+
nansen trade limit-order create --from SOL --to USDC --amount 1 --trigger-mint SOL --trigger-condition below --trigger-price 80
|
|
509
|
+
# Buy SOL with 80 USDC when SOL goes above $100
|
|
510
|
+
nansen trade limit-order create --from USDC --to SOL --amount 80 --trigger-mint SOL --trigger-condition above --trigger-price 100`);
|
|
511
|
+
exit(1);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Validate token addresses are valid Solana addresses (catches EVM addresses, typos, etc.)
|
|
516
|
+
const fromValidation = validateTokenAddress(from, 'solana');
|
|
517
|
+
if (!fromValidation.valid) {
|
|
518
|
+
log(`Error: Invalid --from token address: ${fromValidation.error}`);
|
|
519
|
+
exit(1);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const toValidation = validateTokenAddress(to, 'solana');
|
|
523
|
+
if (!toValidation.valid) {
|
|
524
|
+
log(`Error: Invalid --to token address: ${toValidation.error}`);
|
|
525
|
+
exit(1);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Amount is always in human-readable token units (e.g. 1.5 = 1.5 SOL)
|
|
530
|
+
// Converted to base units (lamports) internally
|
|
531
|
+
let amountBaseUnits;
|
|
532
|
+
try {
|
|
533
|
+
const num = Number(amount);
|
|
534
|
+
if (isNaN(num) || num <= 0) {
|
|
535
|
+
log('Error: --amount must be a positive number in token units (e.g. 1.5 for 1.5 SOL, 80 for 80 USDC).');
|
|
536
|
+
exit(1);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const fromInfo = KNOWN_SOLANA_TOKENS[from];
|
|
540
|
+
let decimals;
|
|
541
|
+
if (fromInfo) {
|
|
542
|
+
decimals = fromInfo.decimals;
|
|
543
|
+
} else {
|
|
544
|
+
const tokenInfo = await getTokenInfo(CHAIN_RPCS.solana, from);
|
|
545
|
+
decimals = tokenInfo.decimals;
|
|
546
|
+
}
|
|
547
|
+
amountBaseUnits = String(parseAmount(String(amount), decimals));
|
|
548
|
+
} catch (err) {
|
|
549
|
+
log(`Error: Could not resolve decimals for ${from}: ${err.message}`);
|
|
550
|
+
exit(1);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const price = Number(triggerPrice);
|
|
555
|
+
if (isNaN(price) || price <= 0) {
|
|
556
|
+
log('Error: --trigger-price must be a positive number (USD price).');
|
|
557
|
+
exit(1);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (triggerCondition !== 'above' && triggerCondition !== 'below') {
|
|
562
|
+
log('Error: --trigger-condition must be "above" or "below".');
|
|
563
|
+
exit(1);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let expiresAt;
|
|
568
|
+
try {
|
|
569
|
+
expiresAt = parseExpiry(expiresStr);
|
|
570
|
+
} catch (err) {
|
|
571
|
+
log(`Error: ${err.message}`);
|
|
572
|
+
exit(1);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const triggerMint = resolveTokenAddress(triggerMintRaw, 'solana');
|
|
577
|
+
|
|
578
|
+
const tmValidation = validateTokenAddress(triggerMint, 'solana');
|
|
579
|
+
if (!tmValidation.valid) {
|
|
580
|
+
log(`Error: Invalid --trigger-mint address: ${tmValidation.error}`);
|
|
581
|
+
exit(1);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
// 1. Resolve wallet
|
|
587
|
+
const resolved = await resolveSolanaWallet(walletName, deps);
|
|
588
|
+
if (!resolved) return;
|
|
589
|
+
|
|
590
|
+
let { pubkey, walletType, walletInfo } = resolved;
|
|
591
|
+
|
|
592
|
+
// For local wallets, load private key now
|
|
593
|
+
if (walletType === 'local') {
|
|
594
|
+
const privateKeyHex = getLocalWalletPrivateKey(resolved.walletName);
|
|
595
|
+
walletInfo = { privateKeyHex };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
log(`\nCreating limit order on Solana...`);
|
|
599
|
+
log(` Wallet: ${pubkey}`);
|
|
600
|
+
log(` Sell: ${formatAmount(amountBaseUnits, from)}`);
|
|
601
|
+
log(` Buy: ${to}`);
|
|
602
|
+
log(` Trigger: $${price} (${triggerCondition})`);
|
|
603
|
+
|
|
604
|
+
// 2. Authenticate
|
|
605
|
+
const token = await authenticate(pubkey, walletType, walletInfo, log);
|
|
606
|
+
|
|
607
|
+
// 3. Check vault, auto-register if needed
|
|
608
|
+
// Backend returns { vaultPubkey: "..." } when vault exists, or throws/returns empty when not
|
|
609
|
+
let hasVault = false;
|
|
610
|
+
try {
|
|
611
|
+
const vaultInfo = await getVault(token, pubkey);
|
|
612
|
+
hasVault = !!(vaultInfo?.vaultPubkey || vaultInfo?.vaultAddress);
|
|
613
|
+
} catch {
|
|
614
|
+
// No vault found
|
|
615
|
+
}
|
|
616
|
+
if (!hasVault) {
|
|
617
|
+
log(' Registering vault for first-time use...');
|
|
618
|
+
try {
|
|
619
|
+
await registerVault(token);
|
|
620
|
+
} catch (regErr) {
|
|
621
|
+
// Vault may already exist — ignore "already registered" errors
|
|
622
|
+
if (!/already registered/i.test(regErr.message)) throw regErr;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 4. Craft deposit transaction
|
|
627
|
+
log(' Crafting deposit transaction...');
|
|
628
|
+
const deposit = await craftDeposit(token, {
|
|
629
|
+
inputMint: from,
|
|
630
|
+
outputMint: to,
|
|
631
|
+
userAddress: pubkey,
|
|
632
|
+
amount: amountBaseUnits,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// 5. Sign deposit transaction
|
|
636
|
+
log(' Signing deposit transaction...');
|
|
637
|
+
const signedDepositTx = await signTransaction(deposit.transaction, walletType, walletInfo);
|
|
638
|
+
|
|
639
|
+
// 6. Create order
|
|
640
|
+
log(' Submitting order...');
|
|
641
|
+
const orderParams = {
|
|
642
|
+
orderType: 'single',
|
|
643
|
+
depositRequestId: deposit.requestId,
|
|
644
|
+
depositSignedTx: signedDepositTx,
|
|
645
|
+
userPubkey: pubkey,
|
|
646
|
+
inputMint: from,
|
|
647
|
+
inputAmount: amountBaseUnits,
|
|
648
|
+
outputMint: to,
|
|
649
|
+
triggerMint,
|
|
650
|
+
triggerCondition,
|
|
651
|
+
triggerPriceUsd: price, // Must be Number, not string
|
|
652
|
+
...(slippageBps != null ? { slippageBps } : {}),
|
|
653
|
+
...(expiresAt != null ? { expiresAt } : {}),
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const result = await createOrder(token, orderParams);
|
|
657
|
+
|
|
658
|
+
log(`\n ✓ Limit order created`);
|
|
659
|
+
log(` Order ID: ${result.id}`);
|
|
660
|
+
log(` Tx: ${result.txSignature}`);
|
|
661
|
+
log(` Explorer: ${SOLSCAN_TX_URL}${result.txSignature}`);
|
|
662
|
+
log('');
|
|
663
|
+
|
|
664
|
+
} catch (err) {
|
|
665
|
+
log(`Error: ${err.message}`);
|
|
666
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
667
|
+
if (err.cause) log(` Cause: ${err.cause.message || err.cause}`);
|
|
668
|
+
exit(1);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
'list': async (args, apiInstance, flags, options) => {
|
|
673
|
+
const walletName = options.wallet;
|
|
674
|
+
const state = options.state;
|
|
675
|
+
const mint = options.mint ? resolveTokenAddress(options.mint, 'solana') : undefined;
|
|
676
|
+
const limit = options.limit || 20;
|
|
677
|
+
const offset = options.offset || 0;
|
|
678
|
+
const sort = options.sort;
|
|
679
|
+
const dir = options.dir || 'desc';
|
|
680
|
+
|
|
681
|
+
if (mint) {
|
|
682
|
+
const mintValidation = validateTokenAddress(mint, 'solana');
|
|
683
|
+
if (!mintValidation.valid) {
|
|
684
|
+
log(`Error: Invalid --mint address: ${mintValidation.error}`);
|
|
685
|
+
exit(1);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
const resolved = await resolveSolanaWallet(walletName, deps);
|
|
692
|
+
if (!resolved) return;
|
|
693
|
+
|
|
694
|
+
let { pubkey, walletType, walletInfo } = resolved;
|
|
695
|
+
|
|
696
|
+
// For local wallets, load private key for auth
|
|
697
|
+
if (walletType === 'local') {
|
|
698
|
+
const privateKeyHex = getLocalWalletPrivateKey(resolved.walletName);
|
|
699
|
+
walletInfo = { privateKeyHex };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const token = await authenticate(pubkey, walletType, walletInfo, log);
|
|
703
|
+
|
|
704
|
+
const result = await listOrders(token, pubkey, { state, mint, limit, offset, sort, dir });
|
|
705
|
+
const orders = result.orders || [];
|
|
706
|
+
|
|
707
|
+
if (orders.length === 0) {
|
|
708
|
+
log('\nNo limit orders found.');
|
|
709
|
+
if (state) log(` (filtered by state: ${state})`);
|
|
710
|
+
log('');
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
log(`\nLimit Orders (${result.pagination?.total || orders.length} total):\n`);
|
|
715
|
+
orders.forEach((order, i) => {
|
|
716
|
+
if (i > 0) log('');
|
|
717
|
+
log(formatOrder(order, i));
|
|
718
|
+
});
|
|
719
|
+
if (result.pagination && result.pagination.total > offset + orders.length) {
|
|
720
|
+
log(`\n Showing ${offset + 1}-${offset + orders.length} of ${result.pagination.total}. Use --offset ${offset + orders.length} to see more.`);
|
|
721
|
+
}
|
|
722
|
+
log('');
|
|
723
|
+
|
|
724
|
+
} catch (err) {
|
|
725
|
+
log(`Error: ${err.message}`);
|
|
726
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
727
|
+
exit(1);
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
'cancel': async (args, apiInstance, flags, options) => {
|
|
732
|
+
const orderId = options.order || options['order-id'] || args[0];
|
|
733
|
+
const walletName = options.wallet;
|
|
734
|
+
|
|
735
|
+
if (!orderId) {
|
|
736
|
+
log(`
|
|
737
|
+
Usage: nansen trade limit-order cancel --order <orderId>
|
|
738
|
+
|
|
739
|
+
OPTIONS:
|
|
740
|
+
--order <id> Order ID to cancel
|
|
741
|
+
--wallet <name> Wallet name (or "walletconnect"/"wc")
|
|
742
|
+
|
|
743
|
+
EXAMPLES:
|
|
744
|
+
nansen trade limit-order cancel --order abc123`);
|
|
745
|
+
exit(1);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const resolved = await resolveSolanaWallet(walletName, deps);
|
|
751
|
+
if (!resolved) return;
|
|
752
|
+
|
|
753
|
+
let { pubkey, walletType, walletInfo } = resolved;
|
|
754
|
+
|
|
755
|
+
if (walletType === 'local') {
|
|
756
|
+
const privateKeyHex = getLocalWalletPrivateKey(resolved.walletName);
|
|
757
|
+
walletInfo = { privateKeyHex };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
log(`\nCancelling order ${orderId}...`);
|
|
761
|
+
|
|
762
|
+
// 1. Authenticate
|
|
763
|
+
const token = await authenticate(pubkey, walletType, walletInfo, log);
|
|
764
|
+
|
|
765
|
+
// 2. Request cancellation — get unsigned withdrawal tx
|
|
766
|
+
log(' Requesting cancellation...');
|
|
767
|
+
const cancelResult = await cancelOrderRequest(token, orderId);
|
|
768
|
+
|
|
769
|
+
// 3. Sign the withdrawal transaction
|
|
770
|
+
log(' Signing withdrawal transaction...');
|
|
771
|
+
const signedTx = await signTransaction(cancelResult.transaction, walletType, walletInfo);
|
|
772
|
+
|
|
773
|
+
// 4. Confirm cancellation
|
|
774
|
+
log(' Confirming cancellation...');
|
|
775
|
+
const confirmed = await confirmCancelOrder(token, orderId, {
|
|
776
|
+
signedTransaction: signedTx,
|
|
777
|
+
cancelRequestId: cancelResult.requestId,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
log(`\n ✓ Order cancelled`);
|
|
781
|
+
log(` Order ID: ${confirmed.id}`);
|
|
782
|
+
log(` Tx: ${confirmed.txSignature}`);
|
|
783
|
+
log(` Explorer: ${SOLSCAN_TX_URL}${confirmed.txSignature}`);
|
|
784
|
+
log('');
|
|
785
|
+
|
|
786
|
+
} catch (err) {
|
|
787
|
+
log(`Error: ${err.message}`);
|
|
788
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
789
|
+
exit(1);
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
|
|
793
|
+
'update': async (args, apiInstance, flags, options) => {
|
|
794
|
+
const orderId = options.order || options['order-id'] || args[0];
|
|
795
|
+
const triggerPrice = options['trigger-price'];
|
|
796
|
+
const slippageBps = options['slippage-bps'];
|
|
797
|
+
const walletName = options.wallet;
|
|
798
|
+
|
|
799
|
+
if (!orderId) {
|
|
800
|
+
log(`
|
|
801
|
+
Usage: nansen trade limit-order update --order <orderId> [--trigger-price <usd>] [--slippage-bps <bps>]
|
|
802
|
+
|
|
803
|
+
OPTIONS:
|
|
804
|
+
--order <id> Order ID to update
|
|
805
|
+
--trigger-price <usd> New trigger price in USD
|
|
806
|
+
--slippage-bps <bps> Slippage in basis points (100 = 1%)
|
|
807
|
+
--wallet <name> Wallet name (or "walletconnect"/"wc")
|
|
808
|
+
|
|
809
|
+
NOTE: Only provided fields are updated. Auto slippage can only be set at creation time
|
|
810
|
+
(by omitting --slippage-bps from the create command).
|
|
811
|
+
|
|
812
|
+
EXAMPLES:
|
|
813
|
+
nansen trade limit-order update --order abc123 --trigger-price 85
|
|
814
|
+
nansen trade limit-order update --order abc123 --slippage-bps 100`);
|
|
815
|
+
exit(1);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (triggerPrice == null && slippageBps == null) {
|
|
820
|
+
log('Error: Provide at least one of --trigger-price or --slippage-bps to update.');
|
|
821
|
+
exit(1);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const updateBody = { orderType: 'single' };
|
|
826
|
+
if (triggerPrice != null) {
|
|
827
|
+
const price = Number(triggerPrice);
|
|
828
|
+
if (isNaN(price) || price <= 0) {
|
|
829
|
+
log('Error: --trigger-price must be a positive number.');
|
|
830
|
+
exit(1);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
updateBody.triggerPriceUsd = price;
|
|
834
|
+
}
|
|
835
|
+
if (slippageBps != null) {
|
|
836
|
+
const bps = Number(slippageBps);
|
|
837
|
+
if (isNaN(bps) || bps < 0 || bps > 10000) {
|
|
838
|
+
log('Error: --slippage-bps must be between 0 and 10000 basis points.');
|
|
839
|
+
exit(1);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
updateBody.slippageBps = bps;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const resolved = await resolveSolanaWallet(walletName, deps);
|
|
847
|
+
if (!resolved) return;
|
|
848
|
+
|
|
849
|
+
let { pubkey, walletType, walletInfo } = resolved;
|
|
850
|
+
|
|
851
|
+
if (walletType === 'local') {
|
|
852
|
+
const privateKeyHex = getLocalWalletPrivateKey(resolved.walletName);
|
|
853
|
+
walletInfo = { privateKeyHex };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
log(`\nUpdating order ${orderId}...`);
|
|
857
|
+
|
|
858
|
+
const token = await authenticate(pubkey, walletType, walletInfo, log);
|
|
859
|
+
await updateOrder(token, orderId, updateBody);
|
|
860
|
+
|
|
861
|
+
log(`\n ✓ Order updated`);
|
|
862
|
+
if (updateBody.triggerPriceUsd != null) log(` Trigger price: $${updateBody.triggerPriceUsd}`);
|
|
863
|
+
if (updateBody.slippageBps != null) log(` Slippage: ${updateBody.slippageBps} bps`);
|
|
864
|
+
log('');
|
|
865
|
+
|
|
866
|
+
} catch (err) {
|
|
867
|
+
log(`Error: ${err.message}`);
|
|
868
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
869
|
+
exit(1);
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}
|