nansen-cli 1.28.0 → 1.30.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#422](https://github.com/nansen-ai/nansen-cli/pull/422) [`10da2f0`](https://github.com/nansen-ai/nansen-cli/commit/10da2f03284a501d94c433f543b9f1866005d3fc) Thanks [@gulshngill](https://github.com/gulshngill)! - Add x402 support for paying with USDT0 on X Layer alongside Base USDC and Solana SPL USDC. The CLI auto-signs the payment using whatever the API advertises in the 402 `accepts` list — no client-side allowlist, since `src/x402-evm.js` already reads `extra.name`, `extra.version`, and `asset` generically. New `NANSEN_XLAYER_RPC` env var overrides the default X Layer RPC, and `checkX402Balance()` now picks the right token + RPC based on the requirement's `network` field.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#422](https://github.com/nansen-ai/nansen-cli/pull/422) [`dc9d1c1`](https://github.com/nansen-ai/nansen-cli/commit/dc9d1c1d740128a8667e79a7a4afb3ff31ed1cc5) Thanks [@gulshngill](https://github.com/gulshngill)! - Document MPP (Tempo) as a third paid-access rail alongside API key and x402. Adds a `nansen-mpp-payment` skill, a README section explaining when to reach for the separate `tempo` CLI, and updates the no-API-key 402 error to mention tempo as a third option.
12
+
13
+ - [#422](https://github.com/nansen-ai/nansen-cli/pull/422) [`93e6a6d`](https://github.com/nansen-ai/nansen-cli/commit/93e6a6d6655380d311739e5f814dda2876b0206a) Thanks [@gulshngill](https://github.com/gulshngill)! - Fix x402 low-balance warning to use the actual stablecoin symbol (USDC or USDT0) returned by `checkX402Balance()` instead of hardcoding "USDC".
14
+
15
+ - [#422](https://github.com/nansen-ai/nansen-cli/pull/422) [`8f9397f`](https://github.com/nansen-ai/nansen-cli/commit/8f9397f2e1940a7a501cd450eae58a3b243b4782) Thanks [@gulshngill](https://github.com/gulshngill)! - Fix x402 payment header decoding and WalletConnect payment payload encoding to use UTF-8 instead of Latin-1. Previously the `Payment-Required` header was decoded with `atob()`, which corrupted multi-byte UTF-8 chars in fields like `extra.name = 'USD₮0'`. The corrupted name then signed the wrong EIP-712 domain and the server rejected with `invalid_exact_evm_signature`. X Layer USDT0 payments now sign correctly; Base USDC was unaffected because `'USD Coin'` is pure ASCII.
16
+
17
+ ## 1.29.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [#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.
22
+
3
23
  ## 1.28.0
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -14,14 +14,20 @@ npx skills add nansen-ai/nansen-cli # load agent skill files
14
14
 
15
15
  ## Auth
16
16
 
17
- ```bash
18
- nansen login --api-key <key> # save key to ~/.nansen/config.json
19
- nansen login --human # interactive prompt
20
- export NANSEN_API_KEY=... # env var (highest priority)
21
- nansen logout # remove saved key
22
- ```
17
+ Three options — pick whichever fits your setup:
18
+
19
+ 1. **API key** (subscription):
20
+ ```bash
21
+ nansen login --api-key <key> # save key to ~/.nansen/config.json
22
+ nansen login --human # interactive prompt
23
+ export NANSEN_API_KEY=... # env var (highest priority)
24
+ nansen logout # remove saved key
25
+ ```
26
+ Get your API key at [app.nansen.ai/auth/agent-setup](https://app.nansen.ai/auth/agent-setup).
23
27
 
24
- Get your API key at [app.nansen.ai/auth/agent-setup](https://app.nansen.ai/auth/agent-setup).
28
+ 2. **x402 micropayment** (no key needed): `nansen wallet create`, fund with USDC on Base or Solana, or USDT0 on X Layer, then call any endpoint — the CLI signs `Payment-Signature` headers automatically on 402 responses. See [Wallet](#wallet).
29
+
30
+ 3. **MPP via tempo** (no key needed): install the [tempo CLI](https://docs.tempo.xyz) separately, run `tempo wallet login` to set up, then call the Nansen API through `tempo request`. The Nansen API selects the MPP rail when it sees `Authorization: Payment ...`. See [MPP / Tempo](#mpp--tempo) below.
25
31
 
26
32
  ## Commands
27
33
 
@@ -51,7 +57,16 @@ nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
51
57
  nansen trade execute --quote <quoteId>
52
58
  ```
53
59
 
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>`.
60
+ Cross-chain swaps work the same way add `--to-chain`. Bridge providers (Li.Fi or Relay) are selected automatically based on best price.
61
+
62
+ ```bash
63
+ nansen trade quote --chain base --to-chain solana --from ETH --to SOL --amount 0.0003 --amount-unit token
64
+ nansen trade execute --quote <quoteId> # signed broadcast
65
+ nansen trade execute --quote <quoteId> --gasless # Relay-only: solver pays gas
66
+ nansen trade bridge-status --tx-hash <hash> --from-chain base --to-chain solana
67
+ ```
68
+
69
+ 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
70
 
56
71
  ## Wallet
57
72
 
@@ -67,6 +82,40 @@ nansen wallet send --wallet <name> --to <addr> --amount <n> --chain <chain>
67
82
 
68
83
  **Privy wallets** are server-side — no password, no local key storage. Requires `PRIVY_APP_ID` and `PRIVY_APP_SECRET` env vars. Get credentials at [dashboard.privy.io](https://dashboard.privy.io).
69
84
 
85
+ ## MPP / Tempo
86
+
87
+ The Nansen API supports [MPP](https://mpp.dev/protocol) (Tempo's stablecoin payment rail) as an alternative to API keys and x402. MPP is handled by the **separate** [tempo CLI](https://docs.tempo.xyz) — `nansen-cli` itself does not sign MPP credentials. You use the two CLIs side-by-side.
88
+
89
+ **One-time setup:**
90
+
91
+ ```bash
92
+ # 1. Install the tempo CLI
93
+ curl -fsSL https://tempo.xyz/install | bash
94
+ # 2. Log in + fund the tempo wallet
95
+ tempo wallet login
96
+ tempo wallet fund # follow the on-screen instructions to deposit USDC
97
+ ```
98
+
99
+ **Calling the Nansen API via tempo:**
100
+
101
+ ```bash
102
+ tempo request POST https://api.nansen.ai/api/v1/smart-money/netflow \
103
+ --json '{"chains":["solana"],"pagination":{"page":1,"page_size":10}}'
104
+ ```
105
+
106
+ `tempo request` handles the full `Authorization: Payment` challenge/response: on a 402 with `WWW-Authenticate: Payment ...` it signs a Tempo credential, retries, and surfaces the `Payment-Receipt` header on success.
107
+
108
+ **When to use which rail:**
109
+
110
+ | Situation | Rail |
111
+ |---|---|
112
+ | You have a subscription | API key |
113
+ | You want anonymous pay-per-call with a Base/Solana wallet you already manage | x402 (`nansen wallet`) |
114
+ | You hold USDT0 on X Layer and want to pay from there | x402 (`nansen wallet`) |
115
+ | You already use tempo for other paid APIs, or want micropayments without managing your own wallet keys | MPP (`tempo request`) |
116
+
117
+ > Note: MPP is server-side opt-in (`MPP_ENABLED=true` on the API). It's available on dev today and rolling out to prod — if `tempo request` returns a non-MPP 402, fall back to x402 or an API key.
118
+
70
119
  ## Key Options
71
120
 
72
121
  | Option | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: nansen-mpp-payment
3
+ description: Pay-per-call access to the Nansen API via MPP (Tempo). Use when a user wants anonymous Nansen access without an API key and without managing their own Base/Solana wallet — they install the tempo CLI separately and call the API through `tempo request`.
4
+ metadata:
5
+ openclaw:
6
+ requires:
7
+ bins:
8
+ - tempo
9
+ install:
10
+ - kind: external
11
+ name: tempo
12
+ docs: https://docs.tempo.xyz
13
+ allowed-tools: Bash(tempo:*), Bash(nansen:*)
14
+ ---
15
+
16
+ # MPP / Tempo
17
+
18
+ The Nansen API supports three paid-access rails: API key, x402 (handled by `nansen-cli`), and MPP via Tempo (handled by the **separate** [tempo CLI](https://docs.tempo.xyz)). This skill covers the third.
19
+
20
+ `nansen-cli` does **not** sign MPP credentials. Use this skill when the user wants to call the Nansen API through `tempo request` because they already use tempo, want micropayments without managing wallet keys themselves, or don't want to fund a Base/Solana USDC wallet.
21
+
22
+ For API-key auth, see `nansen-wallet-manager`. For x402 micropayment with a local wallet, see `nansen-trading` / `nansen-wallet-manager`.
23
+
24
+ ## When to use this skill
25
+
26
+ - User says "MPP", "tempo", "Authorization: Payment", or "Payment-Receipt".
27
+ - User has no Nansen API key and doesn't want to set up a Base/Solana wallet.
28
+ - User is already paying for other APIs through tempo and wants Nansen on the same rail.
29
+
30
+ ## One-time setup
31
+
32
+ ```bash
33
+ # 1. Install the tempo CLI
34
+ curl -fsSL https://tempo.xyz/install | bash
35
+ # 2. Log in (creates / unlocks the tempo wallet)
36
+ tempo wallet login
37
+ # 3. Fund it with USDC on the chain tempo selects for your environment
38
+ tempo wallet fund
39
+ # 4. Confirm the wallet is ready
40
+ tempo wallet whoami
41
+ ```
42
+
43
+ ## Calling the Nansen API
44
+
45
+ `tempo request` handles the full MPP challenge/response: it sends the request, signs the `Authorization: Payment` credential when the API responds 402 + `WWW-Authenticate: Payment ...`, retries, and exposes the `Payment-Receipt` header on success.
46
+
47
+ ```bash
48
+ # Smart Money netflow
49
+ tempo request POST https://api.nansen.ai/api/v1/smart-money/netflow \
50
+ --json '{"chains":["solana"],"pagination":{"page":1,"page_size":10}}'
51
+
52
+ # TGM holders
53
+ tempo request POST https://api.nansen.ai/api/v1/tgm/holders \
54
+ --json '{"token_address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","chain":"solana"}'
55
+ ```
56
+
57
+ Endpoint paths and request shapes are the same as the rest of the Nansen API — run `nansen schema <command>` (no API key required) to look up the body shape, then call the matching `/api/v1/...` path through `tempo request`.
58
+
59
+ ## Discovering paid endpoints
60
+
61
+ ```bash
62
+ curl https://api.nansen.ai/.well-known/x402
63
+ ```
64
+
65
+ Returns `paymentProtocols: ["x402", "mpp"]` (when MPP is enabled server-side) and the list of paid resources.
66
+
67
+ ## How MPP differs from x402
68
+
69
+ | | **x402** (nansen-cli native) | **MPP via tempo** (this skill) |
70
+ |---|---|---|
71
+ | Header sent on retry | `Payment-Signature: <base64>` | `Authorization: Payment <credential>` |
72
+ | 402 challenge header | `Payment-Required: <base64>` | `WWW-Authenticate: Payment ...` |
73
+ | Success header | _(none)_ | `Payment-Receipt: <base64>` |
74
+ | Wallet | local, Privy, or WalletConnect — managed by `nansen-cli` | tempo-managed (separate CLI) |
75
+ | Chains | Base USDC, Solana SPL USDC, X Layer USDT0 | Tempo's chain (mainnet `USDC` in prod, moderato `pathUSD` in dev) |
76
+ | nansen-cli code path | `src/x402.js` auto-signs on 402 | not handled — call via `tempo request` directly |
77
+
78
+ ## Notes
79
+
80
+ - MPP is server-side opt-in. If `tempo request` returns a 402 without `WWW-Authenticate: Payment`, MPP isn't enabled for that endpoint/environment — fall back to an API key or x402.
81
+ - Don't try to add `--mpp-*` flags to `nansen-cli` — the supported integration is "use tempo separately". If the user asks for tighter integration, point them at this skill and confirm the requirement before adding code.
82
+ - Per-request price is the same as x402 (1 credit ≈ $0.001 with 10x markup, e.g. 1-credit endpoints cost $0.01).
83
+
84
+ ## Source
85
+
86
+ - npm: https://www.npmjs.com/package/nansen-cli
87
+ - GitHub: https://github.com/nansen-ai/nansen-cli
88
+ - MPP protocol: https://mpp.dev/protocol
89
+ - Tempo docs: https://docs.tempo.xyz
package/src/api.js CHANGED
@@ -498,9 +498,9 @@ export class NansenAPI {
498
498
  if (network) {
499
499
  try {
500
500
  const { checkX402Balance } = await import('./x402.js');
501
- const balance = await checkX402Balance(network);
502
- if (balance !== null && balance < 0.25) {
503
- console.error(`[x402] Warning: USDC balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
501
+ const result = await checkX402Balance(network);
502
+ if (result !== null && result.balance < 0.25) {
503
+ console.error(`[x402] Warning: ${result.symbol} balance low ($${result.balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
504
504
  }
505
505
  } catch { /* balance check is best-effort */ }
506
506
  }
@@ -648,7 +648,7 @@ export class NansenAPI {
648
648
  const paymentHeader = response.headers.get('payment-required');
649
649
  if (paymentHeader) {
650
650
  try {
651
- paymentRequirements = JSON.parse(atob(paymentHeader));
651
+ paymentRequirements = JSON.parse(Buffer.from(paymentHeader, 'base64').toString('utf8'));
652
652
  } catch {
653
653
  data.paymentRequiredRaw = paymentHeader;
654
654
  }
@@ -665,9 +665,10 @@ export class NansenAPI {
665
665
  if (result !== null) return result;
666
666
  } catch (x402Err) {
667
667
  if (!this.apiKey) {
668
- message = 'No API key configured. Two ways to authenticate:\n' +
668
+ message = 'No API key configured. Three ways to authenticate:\n' +
669
669
  ' 1. API key: nansen login --api-key <key> (get key at https://app.nansen.ai/auth/agent-setup)\n' +
670
- ' 2. x402 micropayment: nansen wallet create + fund with USDC (no API key needed)';
670
+ ' 2. x402 micropayment: nansen wallet create + fund with USDC on Base/Solana or USDT0 on X Layer (no API key needed)\n' +
671
+ ' 3. MPP via tempo: install tempo CLI, run `tempo wallet login`, then call the API with `tempo request` (see skills/nansen-mpp-payment)';
671
672
  } else {
672
673
  message = `x402 auto-payment failed: ${x402Err.message}`;
673
674
  }
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/rpc-urls.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Override env vars:
9
9
  * NANSEN_EVM_RPC Custom Ethereum RPC (also used as generic EVM fallback)
10
10
  * NANSEN_BASE_RPC Custom Base RPC
11
+ * NANSEN_XLAYER_RPC Custom X Layer RPC
11
12
  * NANSEN_SOLANA_RPC Custom Solana RPC
12
13
  *
13
14
  * Backward-compat aliases (deprecated — prefer the forms above):
@@ -19,11 +20,13 @@
19
20
 
20
21
  const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
21
22
  const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
23
+ const DEFAULT_XLAYER_RPC = 'https://rpc.xlayer.tech';
22
24
  const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
23
25
 
24
26
  export const CHAIN_RPCS = {
25
27
  ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
26
28
  evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback
27
29
  base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC,
30
+ xlayer: process.env.NANSEN_XLAYER_RPC || DEFAULT_XLAYER_RPC,
28
31
  solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
29
32
  };
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;
@@ -113,7 +113,7 @@ export function buildPaymentSignatureHeader({ signature, authorization, resource
113
113
  authorization,
114
114
  },
115
115
  };
116
- return btoa(JSON.stringify(paymentPayload));
116
+ return Buffer.from(JSON.stringify(paymentPayload), 'utf8').toString('base64');
117
117
  }
118
118
 
119
119
  /**
package/src/x402.js CHANGED
@@ -24,7 +24,9 @@ export function parsePaymentRequirements(response) {
24
24
  if (!header) return null;
25
25
 
26
26
  try {
27
- const decoded = JSON.parse(atob(header));
27
+ // UTF-8 decode (not atob → Latin-1) — server sends UTF-8 bytes
28
+ // for fields like extra.name = 'USD₮0'.
29
+ const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
28
30
  // V2 format: { accepts: [...], ... }
29
31
  if (decoded.accepts && Array.isArray(decoded.accepts)) {
30
32
  return decoded.accepts;
@@ -155,8 +157,8 @@ export async function createPaymentSignature(response, url, options = {}) {
155
157
  }
156
158
 
157
159
  /**
158
- * Check USDC balance for x402 payment wallet.
159
- * Returns balance in USD (number) or null if check fails.
160
+ * Check stablecoin balance for x402 payment wallet on the given network.
161
+ * Returns `{ balance, symbol }` (USD amount + token symbol) or null if check fails.
160
162
  */
161
163
  export async function checkX402Balance(network) {
162
164
  try {
@@ -183,25 +185,32 @@ export async function checkX402Balance(network) {
183
185
  });
184
186
  const data = await resp.json();
185
187
  const accounts = data.result?.value || [];
186
- if (accounts.length === 0) return 0;
187
- return parseFloat(accounts[0].account.data.parsed.info.tokenAmount.uiAmountString || '0');
188
+ const balance = accounts.length === 0
189
+ ? 0
190
+ : parseFloat(accounts[0].account.data.parsed.info.tokenAmount.uiAmountString || '0');
191
+ return { balance, symbol: 'USDC' };
188
192
  }
189
193
 
190
194
  if (network.startsWith('eip155:')) {
191
- // Base USDC balance check — RPC URL from shared registry so NANSEN_BASE_RPC override applies
192
- const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
195
+ // Per-network token + RPC. Both tokens are 6-decimals.
196
+ // Default to Base USDC if the network is unknown so existing wallets keep working.
197
+ const EVM_NETWORKS = {
198
+ 'eip155:8453': { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', rpc: CHAIN_RPCS.base, symbol: 'USDC' }, // Base USDC
199
+ 'eip155:196': { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', rpc: CHAIN_RPCS.xlayer, symbol: 'USDT0' }, // X Layer USDT0
200
+ };
201
+ const { token, rpc, symbol } = EVM_NETWORKS[network] || EVM_NETWORKS['eip155:8453'];
193
202
  const addr = walletInfo.evm.replace('0x', '').toLowerCase().padStart(64, '0');
194
- const resp = await fetch(CHAIN_RPCS.base, {
203
+ const resp = await fetch(rpc, {
195
204
  method: 'POST',
196
205
  headers: { 'Content-Type': 'application/json' },
197
206
  body: JSON.stringify({
198
207
  jsonrpc: '2.0', id: 1,
199
208
  method: 'eth_call',
200
- params: [{ to: USDC_BASE, data: `0x70a08231${addr}` }, 'latest'],
209
+ params: [{ to: token, data: `0x70a08231${addr}` }, 'latest'],
201
210
  }),
202
211
  });
203
212
  const data = await resp.json();
204
- return parseInt(data.result, 16) / 1e6;
213
+ return { balance: parseInt(data.result, 16) / 1e6, symbol };
205
214
  }
206
215
 
207
216
  return null;