nansen-cli 1.41.0 → 1.42.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 +47 -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 +12 -2
- package/src/schema.json +55 -0
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +185 -37
- package/src/trading.js +208 -31
- package/src/wallet.js +20 -14
- package/src/x402-svm.js +9 -5
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
|
@@ -49,10 +49,20 @@ export class PrivyClient {
|
|
|
49
49
|
"Content-Type": "application/json",
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// Never follow a redirect on a request carrying Basic auth / privy-app-id —
|
|
53
|
+
// a redirect target could otherwise be handed the app credentials.
|
|
54
|
+
const opts = { method, headers, redirect: "error" };
|
|
53
55
|
if (body) opts.body = JSON.stringify(body);
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch(`${this.baseUrl}${endpoint}`, opts);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// redirect: 'error' rejects with a bare TypeError on any server redirect;
|
|
62
|
+
// convert it (and genuine network failures) into an actionable message
|
|
63
|
+
// rather than surfacing an undecorated crash.
|
|
64
|
+
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 });
|
|
65
|
+
}
|
|
56
66
|
|
|
57
67
|
if (!response.ok) {
|
|
58
68
|
let msg = `Privy API error: ${response.status}`;
|
package/src/schema.json
CHANGED
|
@@ -1570,6 +1570,7 @@
|
|
|
1570
1570
|
"swap-mode": {
|
|
1571
1571
|
"type": "string",
|
|
1572
1572
|
"default": "exactIn",
|
|
1573
|
+
"enum": ["exactIn", "exactOut"],
|
|
1573
1574
|
"description": "\"exactIn\" (default) to spend exactly --amount of the sell token, or \"exactOut\" to receive exactly --amount of the buy token. Not supported together with --amount-unit percent."
|
|
1574
1575
|
},
|
|
1575
1576
|
"slippage": {
|
|
@@ -1961,6 +1962,60 @@
|
|
|
1961
1962
|
"text",
|
|
1962
1963
|
"tool_calls"
|
|
1963
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
|
+
}
|
|
1964
2019
|
}
|
|
1965
2020
|
},
|
|
1966
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 } : {}),
|