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.
@@ -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
- const opts = { method, headers };
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
- const res = await fetch(url.toString(), opts);
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
- const opts = { method, headers };
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
- const response = await fetch(`${this.baseUrl}${endpoint}`, opts);
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.payTo,
305
- value: (requirement.amount || requirement.maxAmountRequired).toString(),
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": {
@@ -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 } : {}),
@@ -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] - non-input outflow tolerated
960
- * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
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
- const dust = siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
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 < 0n && -delta > dust) {
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
  }