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 +20 -0
- package/README.md +57 -8
- package/package.json +1 -1
- package/skills/nansen-mpp-payment/SKILL.md +89 -0
- package/src/api.js +7 -6
- package/src/cli.js +2 -2
- package/src/rpc-urls.js +3 -0
- package/src/schema.json +14 -2
- package/src/trading.js +261 -59
- package/src/walletconnect-x402.js +1 -1
- package/src/x402.js +19 -10
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
nansen
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
502
|
-
if (
|
|
503
|
-
console.error(`[x402] Warning:
|
|
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(
|
|
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.
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
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)
|
|
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
|
|
1006
|
-
Typical bridge time:
|
|
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
|
-
//
|
|
1171
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
159
|
-
* Returns balance
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
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(
|
|
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:
|
|
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;
|