nansen-cli 1.41.1 → 1.43.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 +49 -0
- package/README.md +33 -7
- package/package.json +1 -1
- package/scripts/postinstall.js +3 -3
- package/skills/nansen-wallet-manager/SKILL.md +5 -5
- package/src/api.js +11 -5
- package/src/bridge.js +791 -55
- package/src/cli.js +68 -41
- package/src/commands/agent.js +4 -0
- package/src/commands/mcp.js +373 -0
- package/src/doctor.js +16 -7
- package/src/limit-order.js +16 -2
- package/src/mcp-verify.js +292 -0
- package/src/privy.js +25 -4
- package/src/schema.json +55 -1
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +32 -5
- package/src/trading.js +92 -41
- package/src/wallet.js +22 -14
- package/src/walletconnect-x402.js +17 -10
- package/src/x402-evm.js +9 -6
- package/src/x402-policy.js +201 -0
- package/src/x402-svm.js +3 -2
- package/src/x402-tokens.js +29 -0
- package/src/x402.js +12 -25
package/src/limit-order.js
CHANGED
|
@@ -102,12 +102,26 @@ async function loFetch(method, endpoint, { token, body, query } = {}) {
|
|
|
102
102
|
headers['X-API-Key'] = process.env.NANSEN_API_KEY;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Never follow a redirect on a request carrying the JWT / X-API-Key — undici
|
|
106
|
+
// forwards the custom X-API-Key header across a cross-origin redirect, leaking
|
|
107
|
+
// the key to the redirect target.
|
|
108
|
+
const opts = { method, headers, redirect: 'error' };
|
|
106
109
|
if (body !== undefined) {
|
|
107
110
|
opts.body = JSON.stringify(body);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
|
|
113
|
+
let res;
|
|
114
|
+
try {
|
|
115
|
+
res = await fetch(url.toString(), opts);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// redirect: 'error' rejects with a bare TypeError on any server redirect;
|
|
118
|
+
// convert it (and genuine network failures) into a coded, actionable error
|
|
119
|
+
// rather than letting an undecorated crash reach the CLI.
|
|
120
|
+
throw Object.assign(
|
|
121
|
+
new Error(`Limit order API request failed (${method} ${url.pathname}): ${err.message}`, { cause: err }),
|
|
122
|
+
{ code: 'LIMIT_ORDER_NETWORK_ERROR' }
|
|
123
|
+
);
|
|
124
|
+
}
|
|
111
125
|
const text = await res.text();
|
|
112
126
|
|
|
113
127
|
let parsed;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { check, formatChecks, maskKey, resolveAuthConfig } from './doctor.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MCP_URL = 'https://mcp.nansen.ai/ra/mcp';
|
|
4
|
+
export const CANARY_TOOL = 'nansen_score_top_tokens';
|
|
5
|
+
|
|
6
|
+
class McpRequestError extends Error {
|
|
7
|
+
constructor(message, { status = null, rpcMessage = null } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'McpRequestError';
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.rpcMessage = rpcMessage;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function responseContentType(response) {
|
|
16
|
+
return (
|
|
17
|
+
response.headers?.get?.('content-type')
|
|
18
|
+
|| response.headers?.['content-type']
|
|
19
|
+
|| response.headers?.['Content-Type']
|
|
20
|
+
|| ''
|
|
21
|
+
).toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function responseText(message) {
|
|
25
|
+
const content = [message?.result?.content, message?.content]
|
|
26
|
+
.filter(Array.isArray)
|
|
27
|
+
.flat()
|
|
28
|
+
.map(item => item?.text)
|
|
29
|
+
.filter(text => typeof text === 'string');
|
|
30
|
+
const error = message?.error;
|
|
31
|
+
const errorText = typeof error === 'string'
|
|
32
|
+
? error
|
|
33
|
+
: [error?.message, error?.data].filter(value => typeof value === 'string').join(': ');
|
|
34
|
+
return [...content, errorText].filter(Boolean).join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseResponseBody(body, contentType, requestId) {
|
|
38
|
+
if (!contentType.includes('text/event-stream')) return JSON.parse(body);
|
|
39
|
+
|
|
40
|
+
for (const line of body.split(/\r?\n/)) {
|
|
41
|
+
if (!line.startsWith('data:')) continue;
|
|
42
|
+
const data = line.slice(5).trim();
|
|
43
|
+
if (!data || data === '[DONE]') continue;
|
|
44
|
+
try {
|
|
45
|
+
const message = JSON.parse(data);
|
|
46
|
+
// String compare: a proxy may re-serialize the JSON-RPC id as "1".
|
|
47
|
+
if (String(message?.id) === String(requestId)) return message;
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore non-JSON SSE data and keep looking for the JSON-RPC message.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error(`no JSON-RPC message with id ${requestId} in SSE response`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Send one stateless MCP JSON-RPC request and parse either JSON or SSE output.
|
|
58
|
+
*/
|
|
59
|
+
export async function mcpRequest(url, method, params, {
|
|
60
|
+
apiKey,
|
|
61
|
+
fetchFn = fetch,
|
|
62
|
+
timeoutMs = 30_000,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const requestId = 1;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const headers = {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Accept: 'application/json, text/event-stream',
|
|
69
|
+
};
|
|
70
|
+
if (apiKey) headers['NANSEN-API-KEY'] = apiKey;
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
73
|
+
let response;
|
|
74
|
+
let body;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetchFn(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
// The canary carries NANSEN-API-KEY; undici forwards that custom header
|
|
79
|
+
// across a cross-origin redirect, so refuse to follow one rather than
|
|
80
|
+
// relay the key to whatever host the (possibly non-default) server points at.
|
|
81
|
+
redirect: 'error',
|
|
82
|
+
headers,
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }),
|
|
85
|
+
});
|
|
86
|
+
body = await response.text();
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let message;
|
|
92
|
+
try {
|
|
93
|
+
message = parseResponseBody(body, responseContentType(response), requestId);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new McpRequestError(
|
|
96
|
+
`MCP server returned an unexpected response${response?.status ? ` (HTTP ${response.status})` : ''}: ${error.message}`,
|
|
97
|
+
{ status: response?.status || null },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (response?.status && (response.status < 200 || response.status >= 300)) {
|
|
102
|
+
throw new McpRequestError(
|
|
103
|
+
`MCP server returned HTTP ${response.status}${responseText(message) ? `: ${responseText(message)}` : ''}`,
|
|
104
|
+
{ status: response.status, rpcMessage: message },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return message;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function errorReason(error) {
|
|
112
|
+
if (error?.name === 'AbortError') return 'timed out';
|
|
113
|
+
return error?.cause?.code || error?.message || 'request failed';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function errorText(error) {
|
|
117
|
+
return error?.rpcMessage ? responseText(error.rpcMessage) : error?.message || 'request failed';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function authFailureCheck(message, httpStatus = null) {
|
|
121
|
+
const text = responseText(message);
|
|
122
|
+
const codes = [httpStatus, message?.error?.code, message?.result?.code].filter(Boolean).join(' ');
|
|
123
|
+
const combined = `${codes} ${text}`;
|
|
124
|
+
|
|
125
|
+
// Order matters, most-specific first. Rate limit before credits: rate-limit
|
|
126
|
+
// texts often say "credit rate limit" and must stay a warn. Credits before
|
|
127
|
+
// auth: the gateway maps 403 + "insufficient"/"credit" to CREDITS_EXHAUSTED,
|
|
128
|
+
// and a zero-balance user must not be told their key was rejected — the
|
|
129
|
+
// create/rotate remedy can itself fail on key-capped plans.
|
|
130
|
+
if (/\b429\b|rate[- ]?limit|too many requests/i.test(combined)) {
|
|
131
|
+
return check(
|
|
132
|
+
'mcp-auth',
|
|
133
|
+
'warn',
|
|
134
|
+
`MCP data call was rate limited; the paid data path is not verified${text ? `: ${text}` : ''}`,
|
|
135
|
+
'Retry the verification shortly.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (/\b402\b|payment required|insufficient|credits?\b/i.test(combined)) {
|
|
139
|
+
return check(
|
|
140
|
+
'mcp-auth',
|
|
141
|
+
'error',
|
|
142
|
+
`MCP server reports insufficient credits${text ? `: ${text}` : ''}`,
|
|
143
|
+
'Top up credits or check your Nansen plan.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (/\b40[13]\b|unauthorized|forbidden|api[- ]key.*(?:required|invalid|reject)|header is required/i.test(combined)) {
|
|
147
|
+
return check(
|
|
148
|
+
'mcp-auth',
|
|
149
|
+
'error',
|
|
150
|
+
`MCP server rejected the API key${text ? `: ${text}` : ''}`,
|
|
151
|
+
'Check the exact key in your MCP client\'s NANSEN-API-KEY header, or create/rotate it at https://app.nansen.ai/api?tab=api',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return check(
|
|
155
|
+
'mcp-auth',
|
|
156
|
+
'error',
|
|
157
|
+
`MCP authenticated data call failed${text ? `: ${text}` : ''}`,
|
|
158
|
+
'Check the MCP server response and retry.',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isRpcError(message) {
|
|
163
|
+
return Boolean(message?.error) || message?.result?.isError === true || message?.isError === true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function skippedAuth(message) {
|
|
167
|
+
return check('mcp-auth', 'info', `Skipped authenticated data call: ${message}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function keySourceLabel(source) {
|
|
171
|
+
if (source === 'env') return 'NANSEN_API_KEY env var';
|
|
172
|
+
if (source === 'config') return 'config file';
|
|
173
|
+
if (source === 'dev-config') return 'development config file';
|
|
174
|
+
return '--api-key';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run the unauthenticated reachability check and the paid authenticated canary.
|
|
179
|
+
* Every expected failure is represented as a check instead of escaping.
|
|
180
|
+
*/
|
|
181
|
+
export async function runMcpVerifyChecks({
|
|
182
|
+
apiKey,
|
|
183
|
+
url = DEFAULT_MCP_URL,
|
|
184
|
+
env = process.env,
|
|
185
|
+
fetchFn = fetch,
|
|
186
|
+
timeoutMs = 30_000,
|
|
187
|
+
devConfigPath,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const checks = [];
|
|
190
|
+
const auth = resolveAuthConfig(env, devConfigPath);
|
|
191
|
+
// An explicitly passed key — even a bogus null — must never silently fall
|
|
192
|
+
// back to the saved key: that would verify a key the caller never supplied.
|
|
193
|
+
const explicitKey = apiKey !== undefined;
|
|
194
|
+
const resolvedKey = explicitKey ? apiKey : auth.apiKey;
|
|
195
|
+
const key = typeof resolvedKey === 'string' ? resolvedKey.trim() : '';
|
|
196
|
+
|
|
197
|
+
if (key) {
|
|
198
|
+
checks.push(check('mcp-api-key', 'ok', `API key found (${maskKey(key)}, source: ${explicitKey ? '--api-key' : keySourceLabel(auth.apiKeySource)})`));
|
|
199
|
+
} else {
|
|
200
|
+
checks.push(check(
|
|
201
|
+
'mcp-api-key',
|
|
202
|
+
'error',
|
|
203
|
+
'No API key available for the authenticated MCP data-path check',
|
|
204
|
+
'Create an API key at https://app.nansen.ai/api?tab=api, then set NANSEN_API_KEY or save it with `nansen login --human`',
|
|
205
|
+
));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (key && url !== DEFAULT_MCP_URL) {
|
|
209
|
+
checks.push(check('mcp-url', 'warn', `Non-default MCP URL: ${url} — the API key will be sent to this host`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let serverReady = false;
|
|
213
|
+
try {
|
|
214
|
+
const message = await mcpRequest(url, 'tools/list', {}, { fetchFn, timeoutMs });
|
|
215
|
+
if (isRpcError(message) || !Array.isArray(message?.result?.tools)) {
|
|
216
|
+
const text = responseText(message);
|
|
217
|
+
checks.push(check(
|
|
218
|
+
'mcp-server',
|
|
219
|
+
'error',
|
|
220
|
+
`MCP tools/list failed${text ? `: ${text}` : ''}`,
|
|
221
|
+
`Check the MCP server URL and network: ${url}`,
|
|
222
|
+
));
|
|
223
|
+
} else {
|
|
224
|
+
serverReady = true;
|
|
225
|
+
checks.push(check(
|
|
226
|
+
'mcp-server',
|
|
227
|
+
'ok',
|
|
228
|
+
`MCP server reachable: tools/list returned ${message.result.tools.length} tools (unauthenticated; reachability only)`,
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
checks.push(check(
|
|
233
|
+
'mcp-server',
|
|
234
|
+
'error',
|
|
235
|
+
`MCP server unreachable: ${errorReason(error)}`,
|
|
236
|
+
`Check your network/proxy or try --url ${url}`,
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!key) {
|
|
241
|
+
checks.push(skippedAuth('no API key was provided'));
|
|
242
|
+
} else if (!serverReady) {
|
|
243
|
+
checks.push(skippedAuth('tools/list did not establish server reachability'));
|
|
244
|
+
} else {
|
|
245
|
+
try {
|
|
246
|
+
const message = await mcpRequest(
|
|
247
|
+
url,
|
|
248
|
+
'tools/call',
|
|
249
|
+
{ name: CANARY_TOOL, arguments: { request: {} } },
|
|
250
|
+
{ apiKey: key, fetchFn, timeoutMs },
|
|
251
|
+
);
|
|
252
|
+
if (isRpcError(message)) {
|
|
253
|
+
checks.push(authFailureCheck(message));
|
|
254
|
+
} else if (!Array.isArray(message?.result?.content)) {
|
|
255
|
+
// A tools/call result always carries a content array; anything else
|
|
256
|
+
// (e.g. a proxy echoing an unrelated payload) must not verify the key.
|
|
257
|
+
checks.push(check(
|
|
258
|
+
'mcp-auth',
|
|
259
|
+
'error',
|
|
260
|
+
'MCP data call returned a malformed tools/call result — the key is not verified',
|
|
261
|
+
`Check the MCP server URL: ${url}`,
|
|
262
|
+
));
|
|
263
|
+
} else {
|
|
264
|
+
checks.push(check(
|
|
265
|
+
'mcp-auth',
|
|
266
|
+
'ok',
|
|
267
|
+
'Authenticated MCP data call succeeded (~1 credit consumed).',
|
|
268
|
+
));
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const text = errorText(error);
|
|
272
|
+
const message = error.rpcMessage || (error.status ? { error: { message: text } } : null);
|
|
273
|
+
checks.push(message
|
|
274
|
+
? authFailureCheck(message, error.status)
|
|
275
|
+
: check('mcp-auth', 'error', `MCP authenticated data call failed: ${errorReason(error)}`, 'Check your network/proxy or try the verification again.'));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return checks;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function formatMcpVerifyReport(checks, url, verified) {
|
|
283
|
+
const lines = [`Nansen MCP verify — ${url}`, '', formatChecks(checks), ''];
|
|
284
|
+
if (verified) {
|
|
285
|
+
lines.push('Verified: the supplied API key works against the MCP server\'s paid data path (~1 credit consumed). Ensure this same key is in your client\'s NANSEN-API-KEY header.');
|
|
286
|
+
} else {
|
|
287
|
+
const errors = checks.filter(item => item.status === 'error').length;
|
|
288
|
+
const warnings = checks.filter(item => item.status === 'warn').length;
|
|
289
|
+
lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} found; MCP setup is not verified.`);
|
|
290
|
+
}
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
package/src/privy.js
CHANGED
|
@@ -10,6 +10,7 @@ import fs from "fs";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { parsePaymentRequirements } from "./x402.js";
|
|
12
12
|
import { isEvmNetwork } from "./x402-evm.js";
|
|
13
|
+
import { evaluatePaymentRequirement, resolvePaymentAmount, resolvePayTo } from "./x402-policy.js";
|
|
13
14
|
import {
|
|
14
15
|
isSvmNetwork,
|
|
15
16
|
getSolanaRpcUrl,
|
|
@@ -49,10 +50,20 @@ export class PrivyClient {
|
|
|
49
50
|
"Content-Type": "application/json",
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// Never follow a redirect on a request carrying Basic auth / privy-app-id —
|
|
54
|
+
// a redirect target could otherwise be handed the app credentials.
|
|
55
|
+
const opts = { method, headers, redirect: "error" };
|
|
53
56
|
if (body) opts.body = JSON.stringify(body);
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(`${this.baseUrl}${endpoint}`, opts);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// redirect: 'error' rejects with a bare TypeError on any server redirect;
|
|
63
|
+
// convert it (and genuine network failures) into an actionable message
|
|
64
|
+
// rather than surfacing an undecorated crash.
|
|
65
|
+
throw new Error(`Privy API request failed (${method} ${endpoint}): ${err.message}. If PRIVY_* points at a proxy that redirects, use the direct api.privy.io base URL.`, { cause: err });
|
|
66
|
+
}
|
|
56
67
|
|
|
57
68
|
if (!response.ok) {
|
|
58
69
|
let msg = `Privy API error: ${response.status}`;
|
|
@@ -287,6 +298,11 @@ export async function* createPrivyPaymentSignatures(response, url) {
|
|
|
287
298
|
const evmWallet = await getPrivyEvmWallet(client);
|
|
288
299
|
if (evmWallet) {
|
|
289
300
|
for (const requirement of evmRequirements) {
|
|
301
|
+
const decision = evaluatePaymentRequirement(requirement);
|
|
302
|
+
if (!decision.ok) {
|
|
303
|
+
console.error(`[x402] ${decision.reason}`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
290
306
|
try {
|
|
291
307
|
const typedData = buildEIP712TypedData({
|
|
292
308
|
fromAddress: evmWallet.address,
|
|
@@ -301,8 +317,8 @@ export async function* createPrivyPaymentSignatures(response, url) {
|
|
|
301
317
|
|
|
302
318
|
const authorization = {
|
|
303
319
|
from: evmWallet.address,
|
|
304
|
-
to: requirement
|
|
305
|
-
value: (requirement
|
|
320
|
+
to: resolvePayTo(requirement),
|
|
321
|
+
value: resolvePaymentAmount(requirement).toString(),
|
|
306
322
|
validAfter: typedData.message.validAfter.toString(),
|
|
307
323
|
validBefore: typedData.message.validBefore.toString(),
|
|
308
324
|
nonce: typedData.message.nonce,
|
|
@@ -332,6 +348,11 @@ export async function* createPrivyPaymentSignatures(response, url) {
|
|
|
332
348
|
const solWallet = await getPrivySolanaWallet(client);
|
|
333
349
|
if (solWallet) {
|
|
334
350
|
for (const requirement of svmRequirements) {
|
|
351
|
+
const svmDecision = evaluatePaymentRequirement(requirement);
|
|
352
|
+
if (!svmDecision.ok) {
|
|
353
|
+
console.error(`[x402] ${svmDecision.reason}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
335
356
|
try {
|
|
336
357
|
const rpcUrl = getSolanaRpcUrl(requirement.network);
|
|
337
358
|
const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
|
package/src/schema.json
CHANGED
|
@@ -1800,7 +1800,7 @@
|
|
|
1800
1800
|
}
|
|
1801
1801
|
},
|
|
1802
1802
|
"wallet": {
|
|
1803
|
-
"description": "Wallet management",
|
|
1803
|
+
"description": "Wallet management. x402 auto-payments are guarded by a client-side policy: per-payment USD cap via NANSEN_X402_MAX_AMOUNT (default 1.00; 'unlimited' to disable) and an optional recipient allowlist via NANSEN_X402_ALLOWED_PAYTO (comma-separated addresses).",
|
|
1804
1804
|
"subcommands": {
|
|
1805
1805
|
"create": {
|
|
1806
1806
|
"description": "Create a new wallet"
|
|
@@ -1962,6 +1962,60 @@
|
|
|
1962
1962
|
"text",
|
|
1963
1963
|
"tool_calls"
|
|
1964
1964
|
]
|
|
1965
|
+
},
|
|
1966
|
+
"mcp": {
|
|
1967
|
+
"description": "Install, uninstall, or verify the hosted Nansen MCP server (https://mcp.nansen.ai/ra/mcp)",
|
|
1968
|
+
"subcommands": {
|
|
1969
|
+
"verify": {
|
|
1970
|
+
"description": "Check MCP server reachability and verify an API key on the paid data path (~1 credit)",
|
|
1971
|
+
"options": {
|
|
1972
|
+
"api-key": {
|
|
1973
|
+
"type": "string",
|
|
1974
|
+
"description": "API key to test; overrides NANSEN_API_KEY and ~/.nansen/config.json"
|
|
1975
|
+
},
|
|
1976
|
+
"url": {
|
|
1977
|
+
"type": "string",
|
|
1978
|
+
"default": "https://mcp.nansen.ai/ra/mcp",
|
|
1979
|
+
"description": "Hosted MCP server URL"
|
|
1980
|
+
},
|
|
1981
|
+
"json": {
|
|
1982
|
+
"type": "boolean",
|
|
1983
|
+
"description": "Return machine-readable verification checks and exit non-zero on failure"
|
|
1984
|
+
}
|
|
1985
|
+
},
|
|
1986
|
+
"examples": [
|
|
1987
|
+
"npx -y nansen-cli mcp verify",
|
|
1988
|
+
"nansen mcp verify --json",
|
|
1989
|
+
"nansen mcp verify --url https://mcp.example.dev/ra/mcp --api-key <key>"
|
|
1990
|
+
]
|
|
1991
|
+
},
|
|
1992
|
+
"install": {
|
|
1993
|
+
"description": "Add the Nansen MCP server to a client's config. Client (positional): claude-code, claude-desktop, or cursor. Uses the API key from `nansen login` / NANSEN_API_KEY; re-run after key rotation to update the entry.",
|
|
1994
|
+
"options": {
|
|
1995
|
+
"dry-run": {
|
|
1996
|
+
"type": "boolean",
|
|
1997
|
+
"description": "Print the target config path and entry (API key redacted) without writing"
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
"examples": [
|
|
2001
|
+
"nansen mcp install claude-code",
|
|
2002
|
+
"nansen mcp install cursor --dry-run"
|
|
2003
|
+
]
|
|
2004
|
+
},
|
|
2005
|
+
"uninstall": {
|
|
2006
|
+
"description": "Remove the Nansen MCP server entry from a client's config. Client (positional): claude-code, claude-desktop, or cursor.",
|
|
2007
|
+
"options": {
|
|
2008
|
+
"dry-run": {
|
|
2009
|
+
"type": "boolean",
|
|
2010
|
+
"description": "Report whether the Nansen entry would be removed without writing"
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
"examples": [
|
|
2014
|
+
"nansen mcp uninstall claude-code",
|
|
2015
|
+
"nansen mcp uninstall cursor --dry-run"
|
|
2016
|
+
]
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
1965
2019
|
}
|
|
1966
2020
|
},
|
|
1967
2021
|
"globalOptions": {
|
package/src/swap-simulation.js
CHANGED
|
@@ -213,6 +213,12 @@ async function postSim(rpcUrl, apiKey, method, params, timeoutMs) {
|
|
|
213
213
|
const sendApiKey = Boolean(apiKey) && isNansenHostedUrl(rpcUrl);
|
|
214
214
|
const res = await fetch(rpcUrl, {
|
|
215
215
|
method: 'POST',
|
|
216
|
+
// Refuse redirects only when the apikey header is attached — undici
|
|
217
|
+
// forwards custom credential headers across a cross-origin redirect, so a
|
|
218
|
+
// redirect would hand the key to whatever host the response points at.
|
|
219
|
+
// An anonymous call to a user-configured third-party RPC carries nothing
|
|
220
|
+
// to leak, so it keeps following redirects as before.
|
|
221
|
+
redirect: sendApiKey ? 'error' : 'follow',
|
|
216
222
|
headers: {
|
|
217
223
|
'Content-Type': 'application/json',
|
|
218
224
|
...(sendApiKey ? { apikey: apiKey } : {}),
|
package/src/trade-validation.js
CHANGED
|
@@ -8,6 +8,7 @@ import { validateAddress } from './api.js';
|
|
|
8
8
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
9
9
|
import { parseTransactionMessage, resolveStaticAccount } from './solana-tx.js';
|
|
10
10
|
import { SOL_SENTINEL } from './solana-simulation.js';
|
|
11
|
+
import { EVM_NATIVE_SENTINEL } from './swap-simulation.js';
|
|
11
12
|
|
|
12
13
|
const SUPPORTED_CHAINS = ['solana', 'base'];
|
|
13
14
|
|
|
@@ -146,6 +147,21 @@ const NATIVE_TOKEN_ADDRESSES = {
|
|
|
146
147
|
base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
|
147
148
|
};
|
|
148
149
|
|
|
150
|
+
// Upper bound on native (ETH) that may leave the wallet as a *sibling* of a
|
|
151
|
+
// cross-chain bridge — some bridges pay a network fee via msg.value on a
|
|
152
|
+
// token-input route, which surfaces as a native outflow distinct from the input
|
|
153
|
+
// token. assertSwapOutcome's no-sibling-drain check (assertion 3) is otherwise
|
|
154
|
+
// strict-zero, so without a bound such a fee would false-block a legitimate
|
|
155
|
+
// bridge. The tolerance is the smaller of the transaction's declared native
|
|
156
|
+
// value and THIS cap (see verifySwapOutcome); capping it — rather than trusting
|
|
157
|
+
// the quote's value outright — is what keeps the check from being turned into a
|
|
158
|
+
// native-ETH drain (a hostile quote could otherwise set value to the whole
|
|
159
|
+
// balance). Conservative and gas-independent (gas is already excluded from the
|
|
160
|
+
// simulated native delta); a real bridge fee is far below it. This is a
|
|
161
|
+
// defence-in-depth loss ceiling, not a fee estimate — revisit if a route ever
|
|
162
|
+
// legitimately needs more.
|
|
163
|
+
export const EVM_BRIDGE_NATIVE_FEE_SLACK = 2_000_000_000_000_000n; // 0.002 ETH
|
|
164
|
+
|
|
149
165
|
// Native SOL has two on-chain spellings that denote the same asset: the
|
|
150
166
|
// canonical wrapped-SOL mint (what the CLI resolves `SOL` to and persists as
|
|
151
167
|
// the request intent) and the System Program address that aggregators and
|
|
@@ -919,7 +935,7 @@ export function assertSwapCalldataNotBareTransfer(data) {
|
|
|
919
935
|
* Derived from the immutable persisted request intent (not the loose
|
|
920
936
|
* quote/quoteData) so it can't drift between calls or across chains.
|
|
921
937
|
*/
|
|
922
|
-
function isBridgeRequest(request) {
|
|
938
|
+
export function isBridgeRequest(request) {
|
|
923
939
|
return request.toChain != null
|
|
924
940
|
&& String(request.toChain).toLowerCase() !== String(request.chain).toLowerCase();
|
|
925
941
|
}
|
|
@@ -956,8 +972,11 @@ function isBridgeRequest(request) {
|
|
|
956
972
|
* @param {Set<string>|string[]} [ctx.expectedSpenders] - spenders the wallet may
|
|
957
973
|
* legitimately (re)approve during the swap (e.g. the approval target and the
|
|
958
974
|
* router); anything else fails assertion 4. Compared case-insensitively.
|
|
959
|
-
* @param {bigint} [ctx.siblingDustThreshold=0n] -
|
|
960
|
-
* before assertion 3 fires
|
|
975
|
+
* @param {bigint} [ctx.siblingDustThreshold=0n] - native-token (ETH) sibling
|
|
976
|
+
* outflow tolerated before assertion 3 fires, and only for a cross-chain
|
|
977
|
+
* bridge (some bridges pay a fee via msg.value on a token-input route). ERC-20
|
|
978
|
+
* siblings and every same-chain swap stay strict 0. The caller must pass a
|
|
979
|
+
* bounded value (see EVM_BRIDGE_NATIVE_FEE_SLACK). Strict 0 default.
|
|
961
980
|
* @returns {{verified: true, outputAssertionSkipped: boolean}} outputAssertionSkipped
|
|
962
981
|
* is true for a cross-chain bridge, meaning assertion 2 did not run — the
|
|
963
982
|
* caller should surface this.
|
|
@@ -1127,10 +1146,18 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
|
|
|
1127
1146
|
}
|
|
1128
1147
|
|
|
1129
1148
|
// --- Assertion 3: no token other than the input leaves the wallet ---
|
|
1130
|
-
|
|
1149
|
+
// A bridge may pay a network fee in native ETH via msg.value on a
|
|
1150
|
+
// token-input route, which shows up here as a native sibling outflow. Tolerate
|
|
1151
|
+
// that — but only native, only for a bridge, and only up to the bounded
|
|
1152
|
+
// threshold the caller passes (gas is already excluded from the native delta).
|
|
1153
|
+
// ERC-20 siblings and every same-chain swap keep strict-zero. This mirrors the
|
|
1154
|
+
// native-only carve-out assertSolanaSwapOutcome already applies for SOL.
|
|
1155
|
+
const nativeDust = isBridge && siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
|
|
1131
1156
|
for (const [token, delta] of Object.entries(deltas)) {
|
|
1132
1157
|
if (token === inputToken) continue; // its outflow is bounded by assertion 1
|
|
1133
|
-
if (delta
|
|
1158
|
+
if (delta >= 0n) continue;
|
|
1159
|
+
const dust = token === EVM_NATIVE_SENTINEL ? nativeDust : 0n;
|
|
1160
|
+
if (-delta > dust) {
|
|
1134
1161
|
throw fail(`a token other than the one you are selling (${token}) left the wallet (delta ${delta}); a swap must not move any token except the input.`);
|
|
1135
1162
|
}
|
|
1136
1163
|
}
|