nansen-cli 1.28.0 → 1.29.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#423](https://github.com/nansen-ai/nansen-cli/pull/423) [`d10aa57`](https://github.com/nansen-ai/nansen-cli/commit/d10aa575c31f7702241ad114276fa5234f2bdf59) Thanks [@imhta](https://github.com/imhta)! - Add Relay aggregator support for Base↔Solana cross-chain swaps. Users now see Relay quotes alongside Li.Fi in `nansen trade quote --to-chain ...`, can execute them through `trade execute`, and optionally use Relay's gasless path with `--gasless` (local/Privy wallets only — not WalletConnect). `trade bridge-status` auto-detects which aggregator produced a tx (via a local tx record) and polls the right backend.
8
+
3
9
  ## 1.28.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -51,7 +51,16 @@ nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
51
51
  nansen trade execute --quote <quoteId>
52
52
  ```
53
53
 
54
- Amounts are in base units (lamports, wei). Common symbols (`SOL`, `ETH`, `USDC`, `USDT`) resolve automatically. A wallet is required set one with `nansen wallet default <name>`.
54
+ Cross-chain swaps work the same way add `--to-chain`. Bridge providers (Li.Fi or Relay) are selected automatically based on best price.
55
+
56
+ ```bash
57
+ nansen trade quote --chain base --to-chain solana --from ETH --to SOL --amount 0.0003 --amount-unit token
58
+ nansen trade execute --quote <quoteId> # signed broadcast
59
+ nansen trade execute --quote <quoteId> --gasless # Relay-only: solver pays gas
60
+ nansen trade bridge-status --tx-hash <hash> --from-chain base --to-chain solana
61
+ ```
62
+
63
+ Amounts are in base units (lamports, wei) by default — use `--amount-unit token|usd|percent` for friendlier inputs. Common symbols (`SOL`, `ETH`, `USDC`, `USDT`) resolve automatically. A wallet is required — set one with `nansen wallet default <name>`.
55
64
 
56
65
  ## Wallet
57
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -1520,12 +1520,12 @@ SYMBOLS:
1520
1520
 
1521
1521
  CROSS-CHAIN NOTES (when using --to-chain):
1522
1522
  Supported combos:
1523
- native → native (ETH <-> SOL) — requires $5+ per trade
1523
+ native → native (ETH <-> SOL)
1524
1524
  USDC → USDC (both directions)
1525
1525
  USDC → native (USDC → ETH or SOL)
1526
1526
  native → USDC (ETH/SOL → USDC)
1527
1527
  non-native → non-native — not supported (use USDC as intermediate)
1528
- Bridge provider: Li.Fi
1528
+ Bridge providers: Li.Fi or Relay (selected automatically based on best price)
1529
1529
  Typical bridge time: 1-5 minutes`);
1530
1530
  return;
1531
1531
  }
package/src/schema.json CHANGED
@@ -852,7 +852,7 @@
852
852
  },
853
853
  "to-chain": {
854
854
  "type": "string",
855
- "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported — swap to USDC first, then bridge. Minimum ~$5 per cross-chain trade (Li.Fi bridge)."
855
+ "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported — swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay."
856
856
  },
857
857
  "from": {
858
858
  "type": "string",
@@ -880,6 +880,10 @@
880
880
  "to-wallet": {
881
881
  "type": "string",
882
882
  "description": "Destination wallet address for cross-chain swaps. Auto-derived from wallet if omitted."
883
+ },
884
+ "aggregator": {
885
+ "type": "string",
886
+ "description": "Force a specific aggregator: lifi, relay, jupiter, or okx. Filters the returned quote list client-side; errors if no quote from that aggregator was returned."
883
887
  }
884
888
  },
885
889
  "prerequisites": [
@@ -897,11 +901,15 @@
897
901
  "wallet": {
898
902
  "type": "string",
899
903
  "description": "Wallet name, or \"walletconnect\"/\"wc\" for WalletConnect (EVM only)"
904
+ },
905
+ "gasless": {
906
+ "type": "boolean",
907
+ "description": "Relay-only: have Relay's solver pay gas + broadcast (user signs only). Requires the selected quote's aggregator to be \"relay\". Not supported via WalletConnect."
900
908
  }
901
909
  }
902
910
  },
903
911
  "bridge-status": {
904
- "description": "Check cross-chain bridge transaction status",
912
+ "description": "Check cross-chain bridge transaction status. Aggregator (Li.Fi or Relay) is auto-detected from a local tx record saved at execute time (kept 30 days); pass --aggregator to override when polling from a different machine.",
905
913
  "options": {
906
914
  "tx-hash": {
907
915
  "type": "string",
@@ -917,6 +925,10 @@
917
925
  "type": "string",
918
926
  "required": true,
919
927
  "description": "Destination chain (solana or base)"
928
+ },
929
+ "aggregator": {
930
+ "type": "string",
931
+ "description": "lifi or relay. Overrides auto-detection — use when polling from a different machine or after the 30-day local record TTL has expired."
920
932
  }
921
933
  }
922
934
  },
package/src/trading.js CHANGED
@@ -37,6 +37,10 @@ const WRAPPED_NATIVE_TOKENS = {
37
37
  // Wrapped-native addresses (WETH) are derived from WRAPPED_NATIVE_TOKENS
38
38
  // to avoid duplication — keep that map as the single source of truth.
39
39
  const EVM_NATIVE = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
40
+ // Relay returns the Solana System Program mint as the sentinel for native SOL.
41
+ // Recognise it as native so approval/value validation behaves correctly when
42
+ // cross-chain quotes route through Relay. (LiFi/Jupiter still use WSOL.)
43
+ const NATIVE_SOL_SYSTEM_MINT = '11111111111111111111111111111111';
40
44
  const TOKEN_SYMBOLS = {
41
45
  solana: {
42
46
  SOL: 'So11111111111111111111111111111111111111112',
@@ -206,37 +210,60 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
206
210
 
207
211
  /**
208
212
  * Check the status of a cross-chain bridge transaction.
213
+ * Retries on 502/503 (Cloudflare/upstream gateway hiccups) like executeTransaction.
209
214
  * @param {string} txHash - Source chain transaction hash
210
215
  * @param {string} fromChain - Source chain name (e.g. 'base')
211
216
  * @param {string} toChain - Destination chain name (e.g. 'solana')
217
+ * @param {object} [opts]
218
+ * @param {string} [opts.aggregator] - 'lifi' (default) or 'relay'. Relay txHashes
219
+ * return NOT_FOUND when polled with the LiFi default, so this must be set.
220
+ * @param {number} [opts.retries=2] - Retry count for 502/503.
221
+ * @param {number} [opts.retryDelayMs=1500] - Delay between retries.
212
222
  * @returns {Promise<object>} Bridge status
213
223
  */
214
- export async function getBridgeStatus(txHash, fromChain, toChain) {
224
+ export async function getBridgeStatus(txHash, fromChain, toChain, { aggregator, retries = 2, retryDelayMs = 1500 } = {}) {
215
225
  const fromConfig = resolveChain(fromChain);
216
226
  const toConfig = resolveChain(toChain);
217
227
  const url = new URL('/bridge/status', TRADING_API_URL);
218
228
  url.searchParams.set('txHash', txHash);
219
229
  url.searchParams.set('fromChain', fromConfig.lifiChainId || fromConfig.index);
220
230
  url.searchParams.set('toChain', toConfig.lifiChainId || toConfig.index);
231
+ if (aggregator) url.searchParams.set('aggregator', aggregator);
221
232
 
222
- const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT } });
223
- const text = await res.text();
224
- let body;
225
- try {
226
- body = JSON.parse(text);
227
- } catch {
228
- throw Object.assign(
229
- new Error(`Bridge status API returned non-JSON response (status ${res.status}).`),
230
- { code: 'BRIDGE_STATUS_ERROR', status: res.status, details: text.slice(0, 200) }
231
- );
232
- }
233
- if (!res.ok) {
234
- throw Object.assign(
235
- new Error(body.message || `Bridge status check failed with status ${res.status}`),
236
- { code: body.code || 'BRIDGE_STATUS_ERROR', status: res.status, details: body.details }
237
- );
233
+ let lastError;
234
+ for (let attempt = 0; attempt <= retries; attempt++) {
235
+ if (attempt > 0) await new Promise(r => setTimeout(r, retryDelayMs));
236
+
237
+ const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT } });
238
+ const text = await res.text();
239
+ let body;
240
+ try {
241
+ body = JSON.parse(text);
242
+ } catch {
243
+ // Non-JSON response (typically Cloudflare HTML on 502/503). Don't leak the
244
+ // HTML body to the user — surface a clean status hint and a retry tip.
245
+ const hint = res.status === 502 || res.status === 503
246
+ ? ' Upstream bridge service is temporarily unavailable. Retry in a moment, or check the source-chain explorer to confirm the tx landed.'
247
+ : '';
248
+ lastError = Object.assign(
249
+ new Error(`Bridge status API returned non-JSON response (status ${res.status}).${hint}`),
250
+ { code: 'BRIDGE_STATUS_ERROR', status: res.status }
251
+ );
252
+ if ((res.status === 502 || res.status === 503) && attempt < retries) continue;
253
+ throw lastError;
254
+ }
255
+ if (!res.ok) {
256
+ const isTransient = res.status === 502 || res.status === 503;
257
+ lastError = Object.assign(
258
+ new Error(body.message || `Bridge status check failed with status ${res.status}`),
259
+ { code: body.code || 'BRIDGE_STATUS_ERROR', status: res.status, details: body.details }
260
+ );
261
+ if (isTransient && attempt < retries) continue;
262
+ throw lastError;
263
+ }
264
+ return body;
238
265
  }
239
- return body;
266
+ throw lastError;
240
267
  }
241
268
 
242
269
  /**
@@ -248,14 +275,15 @@ export async function getBridgeStatus(txHash, fromChain, toChain) {
248
275
  * @param {number} [opts.timeoutMs=600000] - Timeout (default 10 min)
249
276
  * @param {number} [opts.pollMs=10000] - Poll interval (default 10s)
250
277
  * @param {Function} [opts.log=console.log] - Logger
278
+ * @param {string} [opts.aggregator] - 'lifi' or 'relay'; forwarded to bridge-status query.
251
279
  * @returns {Promise<object>} Final bridge status
252
280
  */
253
- export async function pollBridgeStatus(txHash, fromChain, toChain, { timeoutMs = 600000, pollMs = 10000, log = console.log } = {}) {
281
+ export async function pollBridgeStatus(txHash, fromChain, toChain, { timeoutMs = 600000, pollMs = 10000, log = console.log, aggregator } = {}) {
254
282
  const start = Date.now();
255
283
  while (Date.now() - start < timeoutMs) {
256
284
  let status;
257
285
  try {
258
- status = await getBridgeStatus(txHash, fromChain, toChain);
286
+ status = await getBridgeStatus(txHash, fromChain, toChain, { aggregator });
259
287
  } catch (err) {
260
288
  // Transient errors (502, 503, network failures) — retry after poll interval.
261
289
  log(` Bridge: poll error (${err.status || err.code || 'unknown'}) — retrying...`);
@@ -266,7 +294,13 @@ export async function pollBridgeStatus(txHash, fromChain, toChain, { timeoutMs =
266
294
  const receiving = status.receiving?.status || 'pending';
267
295
  log(` Bridge: ${sending} → ${receiving}`);
268
296
 
269
- if (status.status === 'DONE' || status.receiving?.status === 'DONE') return status;
297
+ const isTerminal = status.status === 'DONE' || status.receiving?.status === 'DONE';
298
+ if (isTerminal) {
299
+ if (status.substatus === 'REFUNDED') {
300
+ log(` Bridge: REFUNDED — funds returned on source chain`);
301
+ }
302
+ return status;
303
+ }
270
304
  if (status.status === 'FAILED') {
271
305
  throw Object.assign(
272
306
  new Error(`Bridge failed: ${status.substatusMessage || 'unknown error'}`),
@@ -282,6 +316,43 @@ export async function pollBridgeStatus(txHash, fromChain, toChain, { timeoutMs =
282
316
  );
283
317
  }
284
318
 
319
+ // Tx records keep aggregator metadata for `bridge-status`. They're not stale-able
320
+ // the way quotes are (a finished tx doesn't expire), but cap them to bound disk use.
321
+ const TX_RECORD_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
322
+
323
+ /**
324
+ * Persist a tx → aggregator mapping for cross-chain swaps so `bridge-status`
325
+ * can pass the right `aggregator` query param without a new CLI flag.
326
+ * Lives next to saved quotes; uses a 30-day TTL (longer than quotes) so users
327
+ * can still resolve the aggregator hours/days after the swap.
328
+ */
329
+ export function saveTxRecord(txHash, { aggregator, requestId, fromChain, toChain }) {
330
+ if (!txHash) return;
331
+ const dir = getQuotesDir();
332
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
333
+ const data = { txHash, aggregator, requestId, fromChain, toChain, timestamp: Date.now() };
334
+ fs.writeFileSync(path.join(dir, `tx-${txHash}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
335
+ }
336
+
337
+ /**
338
+ * Load a previously saved tx record. Returns null if not found or older than 30 days.
339
+ */
340
+ export function loadTxRecord(txHash) {
341
+ if (!txHash) return null;
342
+ const filePath = path.join(getQuotesDir(), `tx-${txHash}.json`);
343
+ if (!fs.existsSync(filePath)) return null;
344
+ try {
345
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
346
+ if (Date.now() - data.timestamp > TX_RECORD_TTL_MS) {
347
+ fs.unlinkSync(filePath);
348
+ return null;
349
+ }
350
+ return data;
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+
285
356
  // ============= Quote Storage =============
286
357
 
287
358
  /**
@@ -324,7 +395,9 @@ export function loadQuote(quoteId) {
324
395
  }
325
396
 
326
397
  /**
327
- * Remove quotes older than 1 hour.
398
+ * Remove stale files from the quotes dir. Quote files use a 1-hour TTL because
399
+ * the price is stale; tx records use a 30-day TTL because a finalized tx hash
400
+ * is permanent and `bridge-status` needs the aggregator hint long after execute.
328
401
  */
329
402
  export function cleanupQuotes() {
330
403
  const dir = getQuotesDir();
@@ -332,9 +405,10 @@ export function cleanupQuotes() {
332
405
  const now = Date.now();
333
406
  for (const file of fs.readdirSync(dir)) {
334
407
  if (!file.endsWith('.json')) continue;
408
+ const ttl = file.startsWith('tx-') ? TX_RECORD_TTL_MS : 3600000;
335
409
  try {
336
410
  const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
337
- if (now - data.timestamp > 3600000) fs.unlinkSync(path.join(dir, file));
411
+ if (now - data.timestamp > ttl) fs.unlinkSync(path.join(dir, file));
338
412
  } catch { /* ignore */ }
339
413
  }
340
414
  }
@@ -732,7 +806,11 @@ function resolveTradePassword() {
732
806
  }
733
807
 
734
808
  function isNativeToken(mintAddress) {
735
- return /^0x[eE]{40}$/.test(mintAddress);
809
+ if (!mintAddress) return false;
810
+ if (mintAddress.startsWith('0x')) return /^0x[eE]{40}$/.test(mintAddress);
811
+ // Solana: WSOL mint (Jupiter/LiFi) and System Program (Relay) both denote native SOL.
812
+ return mintAddress === 'So11111111111111111111111111111111111111112'
813
+ || mintAddress === NATIVE_SOL_SYSTEM_MINT;
736
814
  }
737
815
 
738
816
  /**
@@ -767,6 +845,7 @@ export function getWrappedNativeFromWarning(tokenAddress, chain) {
767
845
  const KNOWN_DECIMALS = {
768
846
  // Solana
769
847
  'So11111111111111111111111111111111111111112': 9, // SOL/WSOL
848
+ '11111111111111111111111111111111': 9, // Native SOL (Relay system-mint sentinel)
770
849
  'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': 6, // USDC
771
850
  'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': 6, // USDT
772
851
  // Base (EVM) — lowercase for case-insensitive matching
@@ -912,12 +991,17 @@ export function formatQuote(quote, index) {
912
991
  }
913
992
  if (quote.tradingFeeInUsd) lines.push(` Trading Fee: $${quote.tradingFeeInUsd}`);
914
993
  if (quote.networkFeeInUsd) lines.push(` Network Fee: $${quote.networkFeeInUsd}`);
915
- if (quote.approvalAddress && !isNativeToken(quote.inputMint)) lines.push(` ⚠ Requires token approval to: ${quote.approvalAddress}`);
994
+ // Empty string is Relay's "no approval needed" sentinel — gate on truthy + non-empty.
995
+ if (quote.approvalAddress && quote.approvalAddress !== '' && !isNativeToken(quote.inputMint)) {
996
+ lines.push(` ⚠ Requires token approval to: ${quote.approvalAddress}`);
997
+ }
916
998
  const meta = quote.metadata || {};
917
999
  if (meta.isCrossChain) {
918
1000
  if (meta.bridgeTool) lines.push(` Bridge: ${meta.bridgeTool}`);
919
- if (meta.executionDuration) {
920
- const mins = Math.round(meta.executionDuration / 60);
1001
+ // LiFi uses executionDuration; Relay uses estimatedTimeSeconds.
1002
+ const durationSec = meta.executionDuration ?? meta.estimatedTimeSeconds;
1003
+ if (durationSec) {
1004
+ const mins = Math.round(durationSec / 60);
921
1005
  lines.push(` Est. Time: ${mins < 1 ? '< 1 min' : `~${mins} min`}`);
922
1006
  }
923
1007
  if (meta.feeCosts?.length) {
@@ -962,6 +1046,13 @@ export function buildTradingCommands(deps = {}) {
962
1046
  const maxAutoSlippage = options['max-auto-slippage'];
963
1047
  const swapMode = options['swap-mode'] || 'exactIn';
964
1048
  const amountUnit = options['amount-unit'];
1049
+ const aggregatorFilter = options.aggregator;
1050
+ if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
1051
+ throw new CommandError(
1052
+ `Invalid --aggregator: "${aggregatorFilter}". Use one of: lifi, relay, jupiter, okx.`,
1053
+ 'INVALID_AGGREGATOR'
1054
+ );
1055
+ }
965
1056
 
966
1057
  if (!chain || !from || !to || !amount) {
967
1058
  throw new CommandError(`
@@ -985,6 +1076,8 @@ OPTIONS:
985
1076
  --auto-slippage Enable auto slippage calculation
986
1077
  --max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
987
1078
  --swap-mode <mode> exactIn (default) or exactOut
1079
+ --aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
1080
+ Filters the quote list client-side; errors if none match.
988
1081
 
989
1082
  EXAMPLES:
990
1083
  nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
@@ -997,13 +1090,13 @@ EXAMPLES:
997
1090
 
998
1091
  CROSS-CHAIN NOTES (when using --to-chain):
999
1092
  Supported combos:
1000
- native → native (ETH <-> SOL) — requires $5+ per trade
1093
+ native → native (ETH <-> SOL)
1001
1094
  USDC → USDC (both directions)
1002
1095
  USDC → native (USDC → ETH or SOL)
1003
1096
  native → USDC (ETH/SOL → USDC)
1004
1097
  non-native → non-native — not supported (use USDC as intermediate)
1005
- Bridge provider: Li.Fi
1006
- Typical bridge time: 1-5 minutes
1098
+ Bridge providers: Li.Fi or Relay (selected automatically based on best price)
1099
+ Typical bridge time: seconds to a few minutes (Relay is usually faster)
1007
1100
  `, 'MISSING_ARGS');
1008
1101
  }
1009
1102
 
@@ -1167,10 +1260,8 @@ CROSS-CHAIN NOTES (when using --to-chain):
1167
1260
  };
1168
1261
  if (isCrossChain) {
1169
1262
  params.toChainIndex = toChainConfig.index;
1170
- // Opt out of Relay aggregator: CLI bypasses backend /execute so the
1171
- // Redis aggregator hint is never set, and /bridge/status defaults to
1172
- // LiFi — polling a Relay txHash there returns NOT_FOUND.
1173
- params.disabledAggregators = 'relay';
1263
+ // Relay and LiFi are both first-class cross-chain aggregators; backend picks per quote.
1264
+ // bridge-status auto-detects which aggregator produced a tx via the local tx record.
1174
1265
  if (toWallet) {
1175
1266
  params.toWalletAddress = toWallet;
1176
1267
  log(` Destination wallet: ${toWallet}`);
@@ -1199,6 +1290,22 @@ CROSS-CHAIN NOTES (when using --to-chain):
1199
1290
  throw new CommandError(msg, 'NO_QUOTES');
1200
1291
  }
1201
1292
 
1293
+ // Client-side filter: if --aggregator is passed, drop everything else.
1294
+ // Done client-side because the backend's aggregator-selection knob
1295
+ // (disabledAggregators) silently accepts unknown values, so a server
1296
+ // filter would mask typos. This way we own the validation.
1297
+ if (aggregatorFilter) {
1298
+ const matching = response.quotes.filter(q => q.aggregator === aggregatorFilter);
1299
+ if (!matching.length) {
1300
+ const seen = [...new Set(response.quotes.map(q => q.aggregator))].join(', ') || 'none';
1301
+ throw new CommandError(
1302
+ `No quotes from aggregator "${aggregatorFilter}" for this pair. Backend returned: ${seen}.`,
1303
+ 'AGGREGATOR_NOT_AVAILABLE'
1304
+ );
1305
+ }
1306
+ response.quotes = matching;
1307
+ }
1308
+
1202
1309
  log('');
1203
1310
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1204
1311
 
@@ -1217,7 +1324,8 @@ CROSS-CHAIN NOTES (when using --to-chain):
1217
1324
  log(` Pin #1: nansen trade execute --quote ${quoteId} --quote-index 0`);
1218
1325
  }
1219
1326
 
1220
- if (response.quotes[0]?.approvalAddress && !isNativeToken(response.quotes[0]?.inputMint)) {
1327
+ const firstQuote = response.quotes[0];
1328
+ if (firstQuote?.approvalAddress && firstQuote.approvalAddress !== '' && !isNativeToken(firstQuote.inputMint)) {
1221
1329
  log(`\n Warning: This token swap requires an ERC-20 approval step.`);
1222
1330
  log(` The execute command will handle this automatically.`);
1223
1331
  }
@@ -1241,6 +1349,7 @@ CROSS-CHAIN NOTES (when using --to-chain):
1241
1349
  const quoteId = options.quote || options['quote-id'] || args[0];
1242
1350
  const walletName = options.wallet;
1243
1351
  const noSimulate = flags['no-simulate'];
1352
+ const gasless = Boolean(flags.gasless);
1244
1353
 
1245
1354
  if (!quoteId) {
1246
1355
  throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
@@ -1249,6 +1358,7 @@ OPTIONS:
1249
1358
  --quote <id> Quote ID from 'nansen quote'
1250
1359
  --wallet <name> Wallet name (default: default wallet)
1251
1360
  --no-simulate Skip pre-broadcast simulation
1361
+ --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
1252
1362
 
1253
1363
  EXAMPLES:
1254
1364
  nansen trade execute --quote 1708900000000-abc123`, 'MISSING_ARGS');
@@ -1346,6 +1456,22 @@ EXAMPLES:
1346
1456
  continue;
1347
1457
  }
1348
1458
 
1459
+ const isRelay = currentQuote.aggregator === 'relay';
1460
+ if (gasless) {
1461
+ if (!isRelay) {
1462
+ throw new CommandError(
1463
+ `--gasless is only supported for Relay quotes. Selected quote ${quoteName} is from "${currentQuote.aggregator}". Re-run with --quote-index to pin a Relay quote, or omit --gasless.`,
1464
+ 'GASLESS_UNSUPPORTED_AGGREGATOR'
1465
+ );
1466
+ }
1467
+ if (isWalletConnect) {
1468
+ throw new CommandError(
1469
+ 'Gasless swaps are not supported via WalletConnect (mobile wallets typically auto-broadcast, breaking the gasless flow). Use a local or Privy wallet.',
1470
+ 'GASLESS_UNSUPPORTED_WALLET'
1471
+ );
1472
+ }
1473
+ }
1474
+
1349
1475
  log(`\nExecuting trade on ${chainConfig.name}...`);
1350
1476
  if (endIndex - startIndex > 1) {
1351
1477
  log(` Trying quote ${qi + 1}/${allQuotes.length} (${quoteName})...`);
@@ -1399,7 +1525,8 @@ EXAMPLES:
1399
1525
  }
1400
1526
 
1401
1527
  // Handle approval if needed
1402
- if (currentQuote.approvalAddress && !isNative) {
1528
+ // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1529
+ if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
1403
1530
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
1404
1531
  const existingAllowance = await checkErc20Allowance(
1405
1532
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
@@ -1449,7 +1576,7 @@ EXAMPLES:
1449
1576
  }
1450
1577
 
1451
1578
  // Pre-flight simulation
1452
- if (!noSimulate) {
1579
+ if (!noSimulate && !gasless) {
1453
1580
  const sim = await simulateEvmCall(chain, {
1454
1581
  from: walletAddress,
1455
1582
  to: currentQuote.transaction.to,
@@ -1587,7 +1714,8 @@ EXAMPLES:
1587
1714
  }
1588
1715
 
1589
1716
  // Handle approval via WalletConnect if needed
1590
- if (currentQuote.approvalAddress && !isNative) {
1717
+ // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1718
+ if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
1591
1719
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
1592
1720
  const existingAllowance = await checkErc20Allowance(
1593
1721
  chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
@@ -1635,7 +1763,7 @@ EXAMPLES:
1635
1763
  }
1636
1764
 
1637
1765
  // Pre-flight simulation
1638
- if (!noSimulate) {
1766
+ if (!noSimulate && !gasless) {
1639
1767
  const txData = currentQuote.transaction;
1640
1768
  const sim = await simulateEvmCall(chain, {
1641
1769
  from: wcAddress,
@@ -1700,13 +1828,24 @@ EXAMPLES:
1700
1828
 
1701
1829
  // Cross-chain: poll bridge status after source tx success
1702
1830
  if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
1831
+ saveTxRecord(wcResult.txHash, {
1832
+ aggregator: currentQuote.aggregator,
1833
+ requestId: currentQuote.metadata?.requestId,
1834
+ fromChain: quoteData.chain,
1835
+ toChain: quoteData.toChain,
1836
+ });
1703
1837
  log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
1704
1838
  try {
1705
- const bridgeResult = await pollBridgeStatus(wcResult.txHash, quoteData.chain, quoteData.toChain, { log });
1706
- log(`\n ✓ Bridge completed!`);
1707
- if (bridgeResult.receiving?.txHash) {
1708
- const toChainConfig = resolveChain(quoteData.toChain);
1709
- log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
1839
+ const bridgeResult = await pollBridgeStatus(wcResult.txHash, quoteData.chain, quoteData.toChain, { log, aggregator: currentQuote.aggregator });
1840
+ if (bridgeResult.substatus === 'REFUNDED') {
1841
+ log(`\n ⚠ Bridge refunded — funds returned on source chain.`);
1842
+ if (bridgeResult.substatusMessage) log(` Reason: ${bridgeResult.substatusMessage}`);
1843
+ } else {
1844
+ log(`\n ✓ Bridge completed!`);
1845
+ if (bridgeResult.receiving?.txHash) {
1846
+ const toChainConfig = resolveChain(quoteData.toChain);
1847
+ log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
1848
+ }
1710
1849
  }
1711
1850
  } catch (bridgeErr) {
1712
1851
  log(`\n Bridge status: ${bridgeErr.message}`);
@@ -1752,7 +1891,8 @@ EXAMPLES:
1752
1891
  }
1753
1892
  }
1754
1893
 
1755
- if (currentQuote.approvalAddress && !isNative) {
1894
+ // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1895
+ if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
1756
1896
  // Check if sufficient allowance already exists
1757
1897
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || currentQuote.transaction?.value || '0');
1758
1898
  const existingAllowance = await checkErc20Allowance(
@@ -1808,7 +1948,7 @@ EXAMPLES:
1808
1948
  // Pre-flight simulation (EVM only) — catch logic reverts before spending gas
1809
1949
  // Runs AFTER approval so eth_call sees the current allowance state
1810
1950
  // Simulates WITHOUT gas limit to check swap logic; gas re-estimation is separate
1811
- if (!noSimulate) {
1951
+ if (!noSimulate && !gasless) {
1812
1952
  const txData = currentQuote.transaction;
1813
1953
  const sim = await simulateEvmCall(chain, {
1814
1954
  from: walletAddress,
@@ -1851,13 +1991,35 @@ EXAMPLES:
1851
1991
  );
1852
1992
  }
1853
1993
 
1854
- log(' Broadcasting...');
1994
+ log(gasless ? ' Forwarding to Relay solver (gasless)...' : ' Broadcasting...');
1855
1995
  const execParams = {
1856
1996
  signedTransaction,
1857
1997
  chain,
1858
- simulate: !noSimulate,
1998
+ simulate: !noSimulate && !gasless,
1859
1999
  };
1860
- if (requestId) execParams.requestId = requestId;
2000
+ // The backend's /execute schema is strict; sending fields it doesn't expect
2001
+ // for the (chain × aggregator × gasless) combination causes 502s or
2002
+ // "Unrecognized keys" rejections. The matrix we've validated against the
2003
+ // live backend:
2004
+ // - EVM signed (any aggregator): no extra fields. requestId/aggregator
2005
+ // trigger schema errors.
2006
+ // - Solana signed (Jupiter/OKX): include requestId for Jupiter Ultra
2007
+ // intent resolution.
2008
+ // - Solana signed (Relay): omit requestId — backend tries to look it up
2009
+ // as a Jupiter intent and 502s.
2010
+ // - Gasless (EVM): aggregator + gasless + steps + requestId.
2011
+ // - Gasless (Solana): currently rejected by the backend ("Unrecognized
2012
+ // keys"); we still send the gasless envelope and let the backend
2013
+ // surface the error so users notice when support lands.
2014
+ if (gasless) {
2015
+ execParams.aggregator = 'relay';
2016
+ execParams.gasless = true;
2017
+ const gaslessRequestId = requestId || currentQuote.metadata?.requestId;
2018
+ if (gaslessRequestId) execParams.requestId = gaslessRequestId;
2019
+ if (currentQuote.metadata?.steps) execParams.steps = currentQuote.metadata.steps;
2020
+ } else if (requestId && !isRelay) {
2021
+ execParams.requestId = requestId; // Solana Jupiter Ultra
2022
+ }
1861
2023
 
1862
2024
  const result = await executeTransaction(execParams);
1863
2025
 
@@ -1900,13 +2062,27 @@ EXAMPLES:
1900
2062
 
1901
2063
  // Cross-chain: poll bridge status after source tx success
1902
2064
  if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
2065
+ saveTxRecord(txId, {
2066
+ aggregator: currentQuote.aggregator,
2067
+ requestId: currentQuote.metadata?.requestId,
2068
+ fromChain: quoteData.chain,
2069
+ toChain: quoteData.toChain,
2070
+ });
2071
+ if (isRelay && currentQuote.metadata?.requestId) {
2072
+ log(` Relay: https://relay.link/transaction/${currentQuote.metadata.requestId}`);
2073
+ }
1903
2074
  log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
1904
2075
  try {
1905
- const bridgeResult = await pollBridgeStatus(txId, quoteData.chain, quoteData.toChain, { log });
1906
- log(`\n ✓ Bridge completed!`);
1907
- if (bridgeResult.receiving?.txHash) {
1908
- const toChainConfig = resolveChain(quoteData.toChain);
1909
- log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
2076
+ const bridgeResult = await pollBridgeStatus(txId, quoteData.chain, quoteData.toChain, { log, aggregator: currentQuote.aggregator });
2077
+ if (bridgeResult.substatus === 'REFUNDED') {
2078
+ log(`\n ⚠ Bridge refunded — funds returned on source chain.`);
2079
+ if (bridgeResult.substatusMessage) log(` Reason: ${bridgeResult.substatusMessage}`);
2080
+ } else {
2081
+ log(`\n ✓ Bridge completed!`);
2082
+ if (bridgeResult.receiving?.txHash) {
2083
+ const toChainConfig = resolveChain(quoteData.toChain);
2084
+ log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
2085
+ }
1910
2086
  }
1911
2087
  } catch (bridgeErr) {
1912
2088
  log(`\n Bridge status: ${bridgeErr.message}`);
@@ -1950,8 +2126,13 @@ EXAMPLES:
1950
2126
  const fromChain = options['from-chain'] || args[1];
1951
2127
  const toChain = options['to-chain'] || args[2];
1952
2128
 
2129
+ const aggregatorOverride = options.aggregator;
2130
+ if (aggregatorOverride && aggregatorOverride !== 'lifi' && aggregatorOverride !== 'relay') {
2131
+ throw new CommandError(`Invalid --aggregator: "${aggregatorOverride}". Use "lifi" or "relay".`, 'INVALID_AGGREGATOR');
2132
+ }
2133
+
1953
2134
  if (!txHash || !fromChain || !toChain) {
1954
- throw new CommandError(`Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
2135
+ throw new CommandError(`Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain> [--aggregator <lifi|relay>]
1955
2136
 
1956
2137
  Check the status of a cross-chain bridge transaction.
1957
2138
 
@@ -1959,15 +2140,28 @@ OPTIONS:
1959
2140
  --tx-hash <hash> Source chain transaction hash
1960
2141
  --from-chain <chain> Source chain (solana or base)
1961
2142
  --to-chain <chain> Destination chain (solana or base)
2143
+ --aggregator <name> lifi or relay. Overrides auto-detection from the
2144
+ local tx record. Use this when polling from a
2145
+ different machine or after the record has expired.
1962
2146
 
1963
2147
  EXAMPLES:
1964
- nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana`, 'MISSING_ARGS');
2148
+ nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
2149
+ nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana --aggregator relay`, 'MISSING_ARGS');
1965
2150
  }
1966
2151
 
1967
2152
  try {
1968
- const status = await getBridgeStatus(txHash, fromChain, toChain);
2153
+ // Resolution order: explicit --aggregator flag → local tx record → backend
2154
+ // default (LiFi). The override matters when polling from a fresh machine
2155
+ // or after the 30-day record TTL expires.
2156
+ const txRecord = loadTxRecord(txHash);
2157
+ const aggregator = aggregatorOverride || txRecord?.aggregator;
2158
+ const status = await getBridgeStatus(txHash, fromChain, toChain, { aggregator });
1969
2159
  log(`\nBridge Status: ${status.status || 'unknown'}`);
1970
- if (status.substatus) log(` Substatus: ${status.substatus}`);
2160
+ if (status.substatus === 'REFUNDED') {
2161
+ log(` ⚠ REFUNDED — funds returned on source chain`);
2162
+ } else if (status.substatus) {
2163
+ log(` Substatus: ${status.substatus}`);
2164
+ }
1971
2165
  if (status.substatusMessage) log(` Message: ${status.substatusMessage}`);
1972
2166
  if (status.tool) log(` Bridge: ${status.tool}`);
1973
2167
  if (status.sending?.txHash) {
@@ -1982,7 +2176,15 @@ EXAMPLES:
1982
2176
  if (status.receiving.amount) log(` Amount: ${status.receiving.amount}`);
1983
2177
  if (status.receiving.txLink) log(` Explorer: ${status.receiving.txLink}`);
1984
2178
  }
1985
- if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
2179
+ const explorerLink = status.lifiExplorerLink || status.relayExplorerLink || status.explorerLink;
2180
+ if (explorerLink) log(` Explorer: ${explorerLink}`);
2181
+ if (aggregator === 'relay' && txRecord?.requestId) {
2182
+ log(` Relay: https://relay.link/transaction/${txRecord.requestId}`);
2183
+ } else if (aggregator === 'relay') {
2184
+ // No local record (cross-machine / expired). Surface the explorer
2185
+ // by tx hash so users can still cross-reference manually.
2186
+ log(` Relay: https://relay.link/transaction/${txHash}`);
2187
+ }
1986
2188
  log('');
1987
2189
  } catch (err) {
1988
2190
  if (err instanceof CommandError) throw err;