nansen-cli 1.27.1 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#417](https://github.com/nansen-ai/nansen-cli/pull/417) [`ae6079f`](https://github.com/nansen-ai/nansen-cli/commit/ae6079f12d06d11fe357237b84387fcaffcfd387) Thanks [@0xlaveen](https://github.com/0xlaveen)! - Add `trade limit-order` commands (create, list, cancel, update) for Jupiter Limit Order V2 on Solana. Supports local, Privy, and WalletConnect wallets.
8
+
9
+ - [#413](https://github.com/nansen-ai/nansen-cli/pull/413) [`94bd349`](https://github.com/nansen-ai/nansen-cli/commit/94bd349bdf9a0a3f3389975144281306eea0e4ca) Thanks [@jake-kennis](https://github.com/jake-kennis)! - Add `top-tokens` subcommand to discover top-scoring tokens by Nansen Score. Calls the public endpoint (`/api/v1/nansen-score/top-tokens`) with optional `--market-cap` filter.
10
+
3
11
  ## 1.27.1
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.27.1",
3
+ "version": "1.28.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -45,10 +45,64 @@ nansen research token indicators --token $TOKEN --chain $CHAIN
45
45
  # Flow intelligence — only use for promising tokens from screener/indicators above
46
46
  nansen research token flow-intelligence --token $TOKEN --chain $CHAIN
47
47
  # → net_flow_usd per label: smart_trader, whale, exchange, fresh_wallets, public_figure
48
+
49
+ # Nansen Score Top Tokens — "what should I buy?" (public endpoint, any authenticated API key)
50
+ # Use this FIRST for discovery, then drill into individual tokens with `indicators` above
51
+ nansen research token top-tokens --limit 25
52
+ nansen research token top-tokens --market-cap largecap --limit 10
53
+ # → chain, token_address, token_symbol, performance_score, risk_score,
54
+ # per-indicator contributions, market_cap_group, latest_date, last_trigger_on
48
55
  ```
49
56
 
50
57
  Screener timeframes: `5m`, `10m`, `1h`, `6h`, `24h`, `7d`, `30d`
51
58
 
52
59
  Indicators: score is "bullish"/"bearish"/"neutral". signal_percentile > 70 = historically significant. Some tokens return empty indicators — not an error.
53
60
 
61
+ ## Top tokens — Nansen Score field reference
62
+
63
+ Results are pre-filtered to `performance_score >= 15` server-side and returned sorted by:
64
+ 1. `performance_score` DESC
65
+ 2. `market_cap_group` priority (largecap → midcap → lowcap)
66
+ 3. `risk_score` DESC
67
+ 4. 24h volume DESC
68
+
69
+ So row 0 is always the strongest candidate for the filter you applied — no client-side ranking needed.
70
+
71
+ Market cap buckets (used in both the sort priority and the `--market-cap` filter):
72
+ - `lowcap`: market cap < $100M
73
+ - `midcap`: market cap $100M – $1B
74
+ - `largecap`: market cap > $1B
75
+
76
+ Every contribution is **ternary** — exactly one of `{negative, 0, positive}` per field. No partial values. Zero means "indicator didn't apply to this token" (out of scope), not "indicator was neutral".
77
+
78
+ **Performance Score (Alpha — "likely to outperform BTC over 7–30d")**
79
+ Range: `-60 to +75` (arithmetic bounds; live max is closer to `+45` since no single token hits every positive indicator simultaneously). Buy threshold: `>= 15`. Sum of the five `*_performance` fields below.
80
+ | Field | Contribution | Trigger | What the underlying indicator measures |
81
+ |---|---|---|---|
82
+ | `price_momentum_performance` | +30 / 0 | upstream score `bullish` → +30 | Price momentum, scored against separate thresholds for large-cap vs. low/mid-cap tokens. |
83
+ | `chain_fees_performance` | +30 / 0 | `bullish` (30-day fee growth > +1%) → +30 | 30-day spending momentum on network fees (geometric mean of daily returns). **Only tracked for a handful of L1 native tokens (e.g. ETH, TRX, AVAX, RON); always 0 for every other token.** |
84
+ | `trading_range_performance` | +15 / 0 | `bullish` (price breaks above resistance in an uptrend) → +15 | 14-day price trend combined with position vs. nearest support/resistance. In practice fires mostly on established tokens that have well-defined levels — can fire at any market cap, but is rare for new / low-liquidity tokens. |
85
+ | `chain_tvl_performance` | 0 / -35 | `bearish` (composite TVL growth < 0) → -35 | TVL momentum composite signal. Only non-zero for chains / L2s whose TVL is tracked. No positive path exists — the field only deducts. |
86
+ | `protocol_fees_performance` | 0 / -25 | `bearish` (14-day fee growth < -3%) → -25 | 14-day protocol fee momentum. Only non-zero for tokens backed by protocols with measurable fee revenue. No positive path — deduction only. |
87
+
88
+ **Risk Score (Safety — "filters falling knives / dangerous setups")**
89
+ Range: `-60 to +80` (arithmetic bounds). Safety threshold: `> 0` (positive = safer, negative = riskier). Sum of the four `*_risk` fields below. For every risk field: upstream score `low` → positive contribution, `high` → negative contribution, `medium`/missing → 0.
90
+ | Field | Contribution | What the underlying indicator measures |
91
+ |---|---|---|
92
+ | `btc_reflexivity_risk` | +40 / -20 | Rolling 5-event median ratio of token drop to BTC drop on days BTC falls >3%. Ratio ≤ 1 → `low` → **+40** (token holds up as well as or better than BTC on drawdowns). Ratio > 1 → `high` → **-20** (token drops harder than BTC). Skipped for stablecoins and tokens with <$1M 24h volume. |
93
+ | `liquidity_risk` | +20 / -20 | Ratio of on-chain liquidity to market cap (`total_liquidity_usd / market_cap_usd`). Higher ratio → `low` → **+20** (deep books relative to cap). Very thin ratio → `high` → **-20**. |
94
+ | `concentration_risk` | +10 / -10 | Top-10 holder concentration as a fraction of supply. `< 0.12` → `low` → **+10** (well-distributed). `> 0.55` → `high` → **-10** (whale-concentrated). |
95
+ | `inflation_risk` | +10 / -10 | EMA of daily token supply inflation rate. Negative / near-zero → `low` → **+10** (stable or deflationary supply). Strongly positive → `high` → **-10** (high dilution). Only evaluated for tokens >= $100M market cap. |
96
+
97
+ Other response fields:
98
+ - `market_cap_group`: `lowcap` / `midcap` / `largecap` — see thresholds above.
99
+ - `latest_date`: ISO datetime of the most recent indicator refresh for this token.
100
+ - `last_trigger_on`: ISO datetime of the most recent trigger across contributing indicators (MAX aggregate — individual indicators may be days-to-months stale even when this looks fresh). Use `indicators` on a specific token to audit per-indicator ages.
101
+
102
+ **Stablecoins rank high but aren't picks.** USDC, USDT, DAI, FDUSD and similar score well on chain_fees + liquidity indicators but aren't what "what should I buy" means. Filter them out of the shortlist using the canonical whitelist at `nansen-dbt-ch-tokens/seeds/stablecoins_for_indicator.csv` before drilling into `indicators`.
103
+
104
+ Typical workflow: start with `top-tokens` for a shortlist → drop stablecoins → run `indicators` on the top 3–5 to inspect individual signals and their signal_percentile → `flow-intelligence` only on the finalists to confirm SM conviction.
105
+
106
+ Field meanings and contribution mappings above are sourced from `nansen-dbt-ch-tokens/models/indicators/api_nansen_score_indicators_all_tokens_latest.sql` and per-indicator model yml files. Sign conventions and live value ranges were validated against production ClickHouse data.
107
+
54
108
  Flow intelligence is credit-heavy. Use it to confirm SM conviction on tokens that already look promising from screener + indicators, not as a first pass on every token.
package/src/api.js CHANGED
@@ -1203,6 +1203,13 @@ export class NansenAPI {
1203
1203
  });
1204
1204
  }
1205
1205
 
1206
+ async topTokens(params = {}) {
1207
+ const { marketCapGroup, limit = 25 } = params;
1208
+ const body = { limit };
1209
+ if (marketCapGroup) body.market_cap_group = marketCapGroup;
1210
+ return this.request('/api/v1/nansen-score/top-tokens', body);
1211
+ }
1212
+
1206
1213
  // ============= Perp Endpoints =============
1207
1214
 
1208
1215
  async perpScreener(params = {}) {
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { NansenAPI, NansenError, CommandError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
7
7
  import { buildWalletCommands } from './wallet.js';
8
8
  import { buildTradingCommands } from './trading.js';
9
+ import { buildLimitOrderCommands } from './limit-order.js';
9
10
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
10
11
  import { buildAgentCommands } from './commands/agent.js';
11
12
  import { resolveAddress, isEnsName } from './ens.js';
@@ -1276,8 +1277,13 @@ export function buildCommands(deps = {}) {
1276
1277
  const withLabels = resolveBooleanOption(options, flags, 'premium-labels');
1277
1278
  return apiInstance.tokenPerpPnlLeaderboard({ tokenSymbol, filters, orderBy, pagination, days, withLabels });
1278
1279
  },
1280
+ 'top-tokens': () => {
1281
+ const marketCapGroup = options['market-cap'] || options['market-cap-group'];
1282
+ const limit = options.limit ? parseInt(options.limit) : undefined;
1283
+ return apiInstance.topTokens({ marketCapGroup, limit });
1284
+ },
1279
1285
  'help': () => ({
1280
- commands: ['info', 'ohlcv', 'screener', 'holders', 'flows', 'dex-trades', 'pnl', 'who-bought-sold', 'flow-intelligence', 'transfers', 'jup-dca', 'perp-trades', 'perp-positions', 'perp-pnl-leaderboard'],
1286
+ commands: ['info', 'ohlcv', 'screener', 'holders', 'flows', 'dex-trades', 'pnl', 'who-bought-sold', 'flow-intelligence', 'transfers', 'jup-dca', 'perp-trades', 'perp-positions', 'perp-pnl-leaderboard', 'top-tokens'],
1281
1287
  description: 'Token God Mode endpoints',
1282
1288
  example: 'nansen token screener --chain solana --timeframe 24h --smart-money --include-stablecoins false'
1283
1289
  })
@@ -1474,8 +1480,9 @@ export function buildCommands(deps = {}) {
1474
1480
  return cmds[category](args.slice(1), apiInstance, flags, options);
1475
1481
  };
1476
1482
 
1477
- // 'trade' delegates to quote/execute from buildTradingCommands
1483
+ // 'trade' delegates to quote/execute from buildTradingCommands and limit-order from buildLimitOrderCommands
1478
1484
  const tradingCmds = buildTradingCommands(deps);
1485
+ const limitOrderCmds = buildLimitOrderCommands(deps);
1479
1486
  cmds['trade'] = async (args, apiInstance, flags, options) => {
1480
1487
  const sub = args[0];
1481
1488
  if (!sub || sub === 'help') {
@@ -1485,12 +1492,14 @@ SUBCOMMANDS:
1485
1492
  quote Get a swap quote (price, route, fees)
1486
1493
  execute Sign and broadcast a quoted swap
1487
1494
  bridge-status Check cross-chain bridge transaction status
1495
+ limit-order Limit order management (Solana only)
1488
1496
 
1489
1497
  USAGE:
1490
1498
  nansen trade quote --chain <chain> --from <token> --to <token> --amount <units> [--wallet <name>]
1491
1499
  nansen trade quote --chain <chain> --to-chain <chain> --from <token> --to <token> --amount <units>
1492
1500
  nansen trade execute --quote <quoteId> [--wallet <name>]
1493
1501
  nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
1502
+ nansen trade limit-order <create|list|cancel|update> [options]
1494
1503
 
1495
1504
  EXAMPLES:
1496
1505
  nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
@@ -1498,9 +1507,11 @@ EXAMPLES:
1498
1507
  nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
1499
1508
  nansen trade execute --quote 1708900000000-abc123
1500
1509
  nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
1510
+ nansen trade limit-order create --from SOL --to USDC --amount 1000000000 --trigger-mint SOL --trigger-condition below --trigger-price 80
1511
+ nansen trade limit-order list
1501
1512
 
1502
1513
  WALLET:
1503
- --wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect (EVM only).
1514
+ --wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect.
1504
1515
  Defaults to the default local wallet if omitted.
1505
1516
 
1506
1517
  SYMBOLS:
@@ -1518,8 +1529,31 @@ CROSS-CHAIN NOTES (when using --to-chain):
1518
1529
  Typical bridge time: 1-5 minutes`);
1519
1530
  return;
1520
1531
  }
1532
+ if (sub === 'limit-order') {
1533
+ const loSub = args[1];
1534
+ if (!loSub || loSub === 'help') {
1535
+ log(`nansen trade limit-order — Limit order commands (Solana only)
1536
+
1537
+ SUBCOMMANDS:
1538
+ create Place a new limit order
1539
+ list List your limit orders
1540
+ cancel Cancel an open order
1541
+ update Update trigger price or slippage
1542
+
1543
+ USAGE:
1544
+ nansen trade limit-order create --from <token> --to <token> --amount <units> --trigger-mint <token> --trigger-condition <above|below> --trigger-price <usd>
1545
+ nansen trade limit-order list [--state <active|past>]
1546
+ nansen trade limit-order cancel --order <orderId>
1547
+ nansen trade limit-order update --order <orderId> --trigger-price <usd>`);
1548
+ return;
1549
+ }
1550
+ if (!limitOrderCmds[loSub]) {
1551
+ throw new NansenError(`Unknown limit-order subcommand: ${loSub}. Available: create, list, cancel, update`, ErrorCode.UNKNOWN);
1552
+ }
1553
+ return limitOrderCmds[loSub](args.slice(2), apiInstance, flags, options);
1554
+ }
1521
1555
  if (!tradingCmds[sub]) {
1522
- throw new NansenError(`Unknown trade subcommand: ${sub}. Available: quote, execute, bridge-status`, ErrorCode.UNKNOWN);
1556
+ throw new NansenError(`Unknown trade subcommand: ${sub}. Available: quote, execute, bridge-status, limit-order`, ErrorCode.UNKNOWN);
1523
1557
  }
1524
1558
  return tradingCmds[sub](args.slice(1), apiInstance, flags, options);
1525
1559
  };
package/src/keychain.js CHANGED
@@ -227,3 +227,4 @@ export function deleteCredentialsFile() {
227
227
  export function resolvePassword() {
228
228
  return retrievePassword().password;
229
229
  }
230
+
@@ -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
+ }
package/src/privy.js CHANGED
@@ -125,6 +125,15 @@ export class PrivyClient {
125
125
  });
126
126
  }
127
127
 
128
+ async signSolanaMessage(walletId, messageBuffer) {
129
+ const messageBase64 = Buffer.from(messageBuffer).toString('base64');
130
+ return this._request("POST", `/wallets/${walletId}/rpc`, {
131
+ method: "signMessage",
132
+ chain_type: "solana",
133
+ params: { message: messageBase64, encoding: "base64" },
134
+ });
135
+ }
136
+
128
137
  }
129
138
 
130
139
  // ============= Helpers =============
package/src/schema.json CHANGED
@@ -500,6 +500,19 @@
500
500
  "default": true
501
501
  }
502
502
  }
503
+ },
504
+ "top-tokens": {
505
+ "endpoint": "/api/v1/nansen-score/top-tokens",
506
+ "description": "Top tokens ranked by Nansen Score (public endpoint, any authenticated API key)",
507
+ "options": {
508
+ "market-cap": {
509
+ "description": "Filter by market cap group",
510
+ "enum": ["lowcap", "midcap", "largecap"]
511
+ },
512
+ "limit": {
513
+ "default": 25
514
+ }
515
+ }
503
516
  }
504
517
  },
505
518
  "description": "Token God Mode - deep analytics for any token"
@@ -906,6 +919,138 @@
906
919
  "description": "Destination chain (solana or base)"
907
920
  }
908
921
  }
922
+ },
923
+ "limit-order": {
924
+ "description": "Limit order management (Solana only)",
925
+ "subcommands": {
926
+ "create": {
927
+ "description": "Place a new limit order",
928
+ "options": {
929
+ "from": {
930
+ "type": "string",
931
+ "required": true,
932
+ "description": "Token to sell (address or symbol)"
933
+ },
934
+ "to": {
935
+ "type": "string",
936
+ "required": true,
937
+ "description": "Token to buy (address or symbol)"
938
+ },
939
+ "amount": {
940
+ "type": "string",
941
+ "required": true,
942
+ "description": "Amount in base units (e.g. lamports)"
943
+ },
944
+ "trigger-price": {
945
+ "type": "number",
946
+ "required": true,
947
+ "description": "Trigger price in USD"
948
+ },
949
+ "trigger-condition": {
950
+ "type": "string",
951
+ "required": true,
952
+ "description": "Trigger when price is 'above' or 'below' target"
953
+ },
954
+ "trigger-mint": {
955
+ "type": "string",
956
+ "required": true,
957
+ "description": "Token whose price triggers the order (e.g. SOL)"
958
+ },
959
+ "slippage-bps": {
960
+ "type": "number",
961
+ "description": "Slippage tolerance in basis points (e.g. 50 = 0.5%)"
962
+ },
963
+ "expires": {
964
+ "type": "string",
965
+ "default": "30d",
966
+ "description": "Expiry duration (e.g. 24h, 7d, 30d) or epoch ms"
967
+ },
968
+ "wallet": {
969
+ "type": "string",
970
+ "description": "Wallet name (or \"walletconnect\"/\"wc\")"
971
+ }
972
+ },
973
+ "chains": ["solana"],
974
+ "prerequisites": [
975
+ "A Solana wallet must be configured. Run: nansen wallet create"
976
+ ]
977
+ },
978
+ "list": {
979
+ "description": "List your limit orders",
980
+ "options": {
981
+ "state": {
982
+ "type": "string",
983
+ "description": "Filter by state (active, past)"
984
+ },
985
+ "mint": {
986
+ "type": "string",
987
+ "description": "Filter by token mint address"
988
+ },
989
+ "limit": {
990
+ "type": "number",
991
+ "default": 20,
992
+ "description": "Max results per page"
993
+ },
994
+ "offset": {
995
+ "type": "number",
996
+ "default": 0,
997
+ "description": "Pagination offset"
998
+ },
999
+ "sort": {
1000
+ "type": "string",
1001
+ "description": "Sort field"
1002
+ },
1003
+ "dir": {
1004
+ "type": "string",
1005
+ "default": "desc",
1006
+ "description": "Sort direction (asc or desc)"
1007
+ },
1008
+ "wallet": {
1009
+ "type": "string",
1010
+ "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1011
+ }
1012
+ },
1013
+ "chains": ["solana"]
1014
+ },
1015
+ "cancel": {
1016
+ "description": "Cancel an open limit order",
1017
+ "options": {
1018
+ "order": {
1019
+ "type": "string",
1020
+ "required": true,
1021
+ "description": "Order ID to cancel"
1022
+ },
1023
+ "wallet": {
1024
+ "type": "string",
1025
+ "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1026
+ }
1027
+ },
1028
+ "chains": ["solana"]
1029
+ },
1030
+ "update": {
1031
+ "description": "Update trigger price or slippage on an existing order",
1032
+ "options": {
1033
+ "order": {
1034
+ "type": "string",
1035
+ "required": true,
1036
+ "description": "Order ID to update"
1037
+ },
1038
+ "trigger-price": {
1039
+ "type": "number",
1040
+ "description": "New trigger price in USD"
1041
+ },
1042
+ "slippage-bps": {
1043
+ "type": "number",
1044
+ "description": "New slippage in basis points (0-10000)"
1045
+ },
1046
+ "wallet": {
1047
+ "type": "string",
1048
+ "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1049
+ }
1050
+ },
1051
+ "chains": ["solana"]
1052
+ }
1053
+ }
909
1054
  }
910
1055
  }
911
1056
  },
package/src/telemetry.js CHANGED
@@ -158,9 +158,13 @@ function buildContext() {
158
158
 
159
159
  /**
160
160
  * Convert a command string like "smart-money netflow" to a path like "/smart-money/netflow".
161
+ * Agent ("agent …") always maps to "/agent" so prompts are not exploded into path segments.
161
162
  */
162
163
  function commandToPath(command) {
163
- return '/' + command.replace(/\s+/g, '/');
164
+ if (typeof command === 'string' && /^agent(?:\s+|$)/.test(command.trimStart())) {
165
+ return '/agent';
166
+ }
167
+ return '/' + String(command).replace(/\s+/g, '/');
164
168
  }
165
169
 
166
170
  /**
package/src/transfer.js CHANGED
@@ -302,7 +302,7 @@ function modPowBig(base, exp, mod) {
302
302
  return result;
303
303
  }
304
304
 
305
- async function getTokenInfo(rpcUrl, mint) {
305
+ export async function getTokenInfo(rpcUrl, mint) {
306
306
  // Get mint account to determine token program and decimals
307
307
  const info = await rpcCall(rpcUrl, 'getAccountInfo', [mint, { encoding: 'jsonParsed' }]);
308
308
  if (!info || !info.value) throw new Error(`Token mint ${mint} not found`);
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { wcExec } from './walletconnect-exec.js';
12
+ import { base58Encode } from './wallet.js';
12
13
 
13
14
  const SOLANA_MAINNET_CHAIN = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
14
15
 
@@ -153,3 +154,27 @@ export async function sendSolanaTransactionViaWalletConnect(txBase58, timeoutMs
153
154
 
154
155
  throw new Error('Unexpected response from walletconnect Solana sign');
155
156
  }
157
+
158
+ /**
159
+ * Sign a Solana message via WalletConnect.
160
+ *
161
+ * Used for challenge-response authentication (e.g., Jupiter Limit Order V2).
162
+ * Returns the raw Ed25519 signature as base58.
163
+ *
164
+ * @param {Buffer} messageBuffer - Raw message bytes to sign
165
+ * @param {number} [timeoutMs=120000] - Timeout for user approval
166
+ * @returns {{ signature: string }} Base58-encoded signature
167
+ */
168
+ export async function signSolanaMessageViaWalletConnect(messageBuffer, timeoutMs = 120000) {
169
+ const payload = {
170
+ message: base58Encode(messageBuffer),
171
+ chainId: SOLANA_MAINNET_CHAIN,
172
+ };
173
+
174
+ const output = await wcExec('walletconnect', ['sign-message', JSON.stringify(payload)], timeoutMs);
175
+ const result = parseWcJson(output);
176
+
177
+ if (result.signature) return { signature: result.signature };
178
+
179
+ throw new Error('Unexpected response from walletconnect sign-message');
180
+ }