nansen-cli 1.23.0 → 1.24.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 +29 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +32 -3
- package/src/cli.js +8 -4
- package/src/commands/alerts.js +4 -1
- package/src/schema.json +31 -3
- package/src/trade-validation.js +270 -0
- package/src/trading.js +254 -12
- package/src/transfer.js +2 -22
- package/src/wallet.js +11 -0
- package/src/x402-svm.js +1 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.24.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#333](https://github.com/nansen-ai/nansen-cli/pull/333) [`c8fe79c`](https://github.com/nansen-ai/nansen-cli/commit/c8fe79c4ec5e1ba6acf2498d9bfbe14c726a913c) Thanks [@imhta](https://github.com/imhta)! - Add cross-chain swap support between Solana and Base via Li.Fi bridge.
|
|
8
|
+
|
|
9
|
+
`nansen trade quote --chain base --to-chain solana --from ETH --to SOL --amount 0.01 --amount-unit token`
|
|
10
|
+
`nansen trade execute --quote <id>`
|
|
11
|
+
|
|
12
|
+
Bridge status can be checked with `nansen trade bridge-status`.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- [#365](https://github.com/nansen-ai/nansen-cli/pull/365) [`56335af`](https://github.com/nansen-ai/nansen-cli/commit/56335af600e51436b30ad2fc1530aa754bac2a2f) Thanks [@TimNooren](https://github.com/TimNooren)! - Add balance pre-check before quote API calls. Validates sell token balance, auto-adjusts near-full-balance trades (≤2% over), and reserves gas fees for native token swaps (SOL/ETH).
|
|
17
|
+
|
|
18
|
+
## 1.23.1
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- [#361](https://github.com/nansen-ai/nansen-cli/pull/361) [`ff22da3`](https://github.com/nansen-ai/nansen-cli/commit/ff22da376c1072ef06e84054f6ee31c058e6e08c) Thanks [@TimNooren](https://github.com/TimNooren)! - fix(alerts): error when --webhook-secret is passed without --webhook
|
|
23
|
+
|
|
24
|
+
Previously, passing --webhook-secret with a non-webhook channel (e.g. --telegram)
|
|
25
|
+
silently discarded the secret with no warning. The alert was created successfully
|
|
26
|
+
but without any signing, giving the false impression that the secret was active.
|
|
27
|
+
|
|
28
|
+
Now throws an actionable error: "--webhook-secret requires --webhook".
|
|
29
|
+
|
|
30
|
+
- [#358](https://github.com/nansen-ai/nansen-cli/pull/358) [`70ee712`](https://github.com/nansen-ai/nansen-cli/commit/70ee71205343fb2d003eb5f50144e266bdc6109e) Thanks [@TimNooren](https://github.com/TimNooren)! - Add pre-quote trade input validation: rejects same-token swaps, invalid address formats, and non-positive amounts before any network call.
|
|
31
|
+
|
|
3
32
|
## 1.23.0
|
|
4
33
|
|
|
5
34
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ nansen schema [command] [--pretty] # full command reference (no API key neede
|
|
|
36
36
|
|
|
37
37
|
**Research categories:** `smart-money` (`sm`), `token` (`tgm`), `profiler` (`prof`), `portfolio` (`port`), `prediction-market` (`pm`), `search`, `perp`, `points`
|
|
38
38
|
|
|
39
|
-
**Trade:** `quote`, `execute` — DEX swaps on Solana and Base.
|
|
39
|
+
**Trade:** `quote`, `execute`, `bridge-status` — DEX swaps on Solana and Base, including cross-chain bridges.
|
|
40
40
|
|
|
41
41
|
**Wallet:** `create`, `list`, `show`, `export`, `default`, `delete`, `send` — local or Privy server-side wallets (EVM + Solana).
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nansen-trading
|
|
3
|
-
description: Execute DEX swaps on Solana or Base. Use when buying or selling a token, getting a swap quote, or executing a trade.
|
|
3
|
+
description: Execute DEX swaps on Solana or Base, including cross-chain bridges. Use when buying or selling a token, getting a swap quote, or executing a trade.
|
|
4
4
|
metadata:
|
|
5
5
|
openclaw:
|
|
6
6
|
requires:
|
|
@@ -41,6 +41,31 @@ Symbols resolve automatically: `SOL`, `ETH`, `USDC`, `USDT`, `WETH`. Raw address
|
|
|
41
41
|
nansen trade execute --quote <quote-id>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
## Cross-Chain Swap
|
|
45
|
+
|
|
46
|
+
Bridge tokens between Solana and Base using `--to-chain`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
nansen trade quote \
|
|
50
|
+
--chain base \
|
|
51
|
+
--to-chain solana \
|
|
52
|
+
--from USDC \
|
|
53
|
+
--to USDC \
|
|
54
|
+
--amount 1000000
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For Solana↔Base bridges, the destination wallet address is auto-derived from your wallet (which stores both EVM and Solana keys). Override with `--to-wallet <address>` if needed.
|
|
58
|
+
|
|
59
|
+
Note: you need gas on the **source** chain to submit the initial transaction (e.g. SOL for Solana→Base, ETH for Base→Solana).
|
|
60
|
+
|
|
61
|
+
## Bridge Status
|
|
62
|
+
|
|
63
|
+
After executing a cross-chain swap, the CLI polls bridge status automatically. To check manually:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
nansen trade bridge-status --tx-hash <hash> --from-chain base --to-chain solana
|
|
67
|
+
```
|
|
68
|
+
|
|
44
69
|
## Agent pattern
|
|
45
70
|
|
|
46
71
|
```bash
|
|
@@ -81,15 +106,19 @@ If the user says "$20 worth of X", you must convert USD → token amount, then e
|
|
|
81
106
|
|
|
82
107
|
| Flag | Purpose |
|
|
83
108
|
|------|---------|
|
|
84
|
-
| `--chain` | `solana` or `base` |
|
|
109
|
+
| `--chain` | Source chain: `solana` or `base` |
|
|
110
|
+
| `--to-chain` | Destination chain for cross-chain swap (omit for same-chain) |
|
|
85
111
|
| `--from` | Source token (symbol or address) |
|
|
86
|
-
| `--to` | Destination token (symbol or address) |
|
|
112
|
+
| `--to` | Destination token (symbol or address, resolved against destination chain) |
|
|
87
113
|
| `--amount` | Amount in base units (integer), or token units with `--amount-unit token` |
|
|
88
114
|
| `--amount-unit` | Set to `token` to specify amount in token units (e.g. 0.5 SOL) |
|
|
89
115
|
| `--wallet` | Wallet name (default: default wallet) |
|
|
116
|
+
| `--to-wallet` | Destination wallet address (auto-derived for cross-chain if omitted) |
|
|
90
117
|
| `--slippage` | Slippage tolerance as decimal (e.g. 0.03) |
|
|
91
118
|
| `--quote` | Quote ID for execute |
|
|
92
119
|
| `--no-simulate` | Skip pre-broadcast simulation |
|
|
120
|
+
| `--tx-hash` | Source tx hash (for bridge-status) |
|
|
121
|
+
| `--from-chain` | Source chain (for bridge-status) |
|
|
93
122
|
|
|
94
123
|
## Environment Variables
|
|
95
124
|
|
package/src/cli.js
CHANGED
|
@@ -1447,18 +1447,22 @@ export function buildCommands(deps = {}) {
|
|
|
1447
1447
|
log(`nansen trade — DEX trading commands
|
|
1448
1448
|
|
|
1449
1449
|
SUBCOMMANDS:
|
|
1450
|
-
quote
|
|
1451
|
-
execute
|
|
1450
|
+
quote Get a swap quote (price, route, fees)
|
|
1451
|
+
execute Sign and broadcast a quoted swap
|
|
1452
|
+
bridge-status Check cross-chain bridge transaction status
|
|
1452
1453
|
|
|
1453
1454
|
USAGE:
|
|
1454
1455
|
nansen trade quote --chain <chain> --from <token> --to <token> --amount <units> [--wallet <name>]
|
|
1456
|
+
nansen trade quote --chain <chain> --to-chain <chain> --from <token> --to <token> --amount <units>
|
|
1455
1457
|
nansen trade execute --quote <quoteId> [--wallet <name>]
|
|
1458
|
+
nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1456
1459
|
|
|
1457
1460
|
EXAMPLES:
|
|
1458
1461
|
nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
1459
1462
|
nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
1460
|
-
nansen trade quote --chain base --from
|
|
1463
|
+
nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
|
|
1461
1464
|
nansen trade execute --quote 1708900000000-abc123
|
|
1465
|
+
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
|
|
1462
1466
|
|
|
1463
1467
|
WALLET:
|
|
1464
1468
|
--wallet <name> Use a named wallet, or "walletconnect" / "wc" for WalletConnect (EVM only).
|
|
@@ -1470,7 +1474,7 @@ SYMBOLS:
|
|
|
1470
1474
|
return;
|
|
1471
1475
|
}
|
|
1472
1476
|
if (!tradingCmds[sub]) {
|
|
1473
|
-
throw new NansenError(`Unknown trade subcommand: ${sub}. Available: quote, execute`, ErrorCode.UNKNOWN);
|
|
1477
|
+
throw new NansenError(`Unknown trade subcommand: ${sub}. Available: quote, execute, bridge-status`, ErrorCode.UNKNOWN);
|
|
1474
1478
|
}
|
|
1475
1479
|
return tradingCmds[sub](args.slice(1), apiInstance, flags, options);
|
|
1476
1480
|
};
|
package/src/commands/alerts.js
CHANGED
|
@@ -560,8 +560,11 @@ USAGE:
|
|
|
560
560
|
return;
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
// Build channels array from --telegram/--slack/--discord flags
|
|
563
|
+
// Build channels array from --telegram/--slack/--discord/--webhook flags
|
|
564
564
|
function buildChannels() {
|
|
565
|
+
if (options["webhook-secret"] && !options.webhook) {
|
|
566
|
+
throw new NansenError('--webhook-secret requires --webhook', ErrorCode.INVALID_PARAMS);
|
|
567
|
+
}
|
|
565
568
|
const channels = [];
|
|
566
569
|
if (options.telegram) channels.push({ type: 'telegram', data: { chatId: String(options.telegram) } });
|
|
567
570
|
if (options.slack) channels.push({ type: 'slack', data: { webhookUrl: options.slack } });
|
package/src/schema.json
CHANGED
|
@@ -772,12 +772,16 @@
|
|
|
772
772
|
"description": "DEX trading commands",
|
|
773
773
|
"subcommands": {
|
|
774
774
|
"quote": {
|
|
775
|
-
"description": "Get a DEX swap quote (chain
|
|
775
|
+
"description": "Get a DEX swap quote (same-chain or cross-chain)",
|
|
776
776
|
"options": {
|
|
777
777
|
"chain": {
|
|
778
778
|
"type": "string",
|
|
779
779
|
"default": "base",
|
|
780
|
-
"description": "
|
|
780
|
+
"description": "Source blockchain (solana or base)"
|
|
781
|
+
},
|
|
782
|
+
"to-chain": {
|
|
783
|
+
"type": "string",
|
|
784
|
+
"description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain."
|
|
781
785
|
},
|
|
782
786
|
"from": {
|
|
783
787
|
"type": "string",
|
|
@@ -787,7 +791,7 @@
|
|
|
787
791
|
"to": {
|
|
788
792
|
"type": "string",
|
|
789
793
|
"required": true,
|
|
790
|
-
"description": "Token to buy (address or symbol)"
|
|
794
|
+
"description": "Token to buy (address or symbol, resolved against destination chain for cross-chain)"
|
|
791
795
|
},
|
|
792
796
|
"amount": {
|
|
793
797
|
"type": "string",
|
|
@@ -801,6 +805,10 @@
|
|
|
801
805
|
"wallet": {
|
|
802
806
|
"type": "string",
|
|
803
807
|
"description": "Wallet name (or \"walletconnect\"/\"wc\" for WalletConnect, EVM only). A configured wallet is required \u2014 run `nansen wallet create` if you haven't set one up yet."
|
|
808
|
+
},
|
|
809
|
+
"to-wallet": {
|
|
810
|
+
"type": "string",
|
|
811
|
+
"description": "Destination wallet address for cross-chain swaps. Auto-derived from wallet if omitted."
|
|
804
812
|
}
|
|
805
813
|
},
|
|
806
814
|
"prerequisites": [
|
|
@@ -820,6 +828,26 @@
|
|
|
820
828
|
"description": "Wallet name, or \"walletconnect\"/\"wc\" for WalletConnect (EVM only)"
|
|
821
829
|
}
|
|
822
830
|
}
|
|
831
|
+
},
|
|
832
|
+
"bridge-status": {
|
|
833
|
+
"description": "Check cross-chain bridge transaction status",
|
|
834
|
+
"options": {
|
|
835
|
+
"tx-hash": {
|
|
836
|
+
"type": "string",
|
|
837
|
+
"required": true,
|
|
838
|
+
"description": "Source chain transaction hash"
|
|
839
|
+
},
|
|
840
|
+
"from-chain": {
|
|
841
|
+
"type": "string",
|
|
842
|
+
"required": true,
|
|
843
|
+
"description": "Source chain (solana or base)"
|
|
844
|
+
},
|
|
845
|
+
"to-chain": {
|
|
846
|
+
"type": "string",
|
|
847
|
+
"required": true,
|
|
848
|
+
"description": "Destination chain (solana or base)"
|
|
849
|
+
}
|
|
850
|
+
}
|
|
823
851
|
}
|
|
824
852
|
}
|
|
825
853
|
},
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trade input validation for the Nansen CLI.
|
|
3
|
+
* Catches common agent errors (wrong addresses, same-token swaps,
|
|
4
|
+
* bad amounts) before any network call.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { validateAddress } from './api.js';
|
|
8
|
+
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
9
|
+
|
|
10
|
+
const SUPPORTED_CHAINS = ['solana', 'base'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate quote inputs before any network call.
|
|
14
|
+
* Throws on validation failure with an actionable error message.
|
|
15
|
+
*/
|
|
16
|
+
export function validateQuoteInput({ chain, toChain, from, to, amount }) {
|
|
17
|
+
// 1. Chain must be supported
|
|
18
|
+
const normalizedChain = chain?.toLowerCase();
|
|
19
|
+
if (!SUPPORTED_CHAINS.includes(normalizedChain)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Unsupported chain "${chain}". Supported chains: ${SUPPORTED_CHAINS.join(', ')}.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalizedToChain = toChain ? toChain.toLowerCase() : normalizedChain;
|
|
26
|
+
if (toChain && !SUPPORTED_CHAINS.includes(normalizedToChain)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Unsupported destination chain "${toChain}". Supported chains: ${SUPPORTED_CHAINS.join(', ')}.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Amount must be a positive finite number
|
|
33
|
+
const numAmount = Number(amount);
|
|
34
|
+
if (!Number.isFinite(numAmount) || numAmount <= 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Invalid amount "${amount}". Must be a positive number.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Token address format must match the chain (reuses api.js validateAddress)
|
|
41
|
+
const fromResult = validateAddress(from, normalizedChain);
|
|
42
|
+
if (!fromResult.valid) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid sell token address for ${normalizedChain}. ${fromResult.error}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const toResult = validateAddress(to, normalizedToChain);
|
|
48
|
+
if (!toResult.valid) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Invalid buy token address for ${normalizedToChain}. ${toResult.error}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. Sell and buy token must be different (only applies to same-chain swaps)
|
|
55
|
+
if (normalizedChain === normalizedToChain) {
|
|
56
|
+
const fromNorm = normalizedChain === 'solana' ? from : from.toLowerCase();
|
|
57
|
+
const toNorm = normalizedChain === 'solana' ? to : to.toLowerCase();
|
|
58
|
+
if (fromNorm === toNorm) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot swap ${from} for itself. Sell and buy tokens must be different.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Native token decimals per chain (for converting balance from base units)
|
|
67
|
+
const NATIVE_DECIMALS = { solana: 9, base: 18 };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fetch the native token balance (ETH or SOL) for a wallet.
|
|
71
|
+
* Returns balance in human-readable token units (e.g. 1.5 ETH), or null on RPC failure.
|
|
72
|
+
*
|
|
73
|
+
* Uses Number (not BigInt) for the result — acceptable precision loss for a
|
|
74
|
+
* best-effort pre-check with 2% tolerance. Transaction amounts use BigInt elsewhere.
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchNativeBalance(chain, walletAddress) {
|
|
77
|
+
try {
|
|
78
|
+
const rpcUrl = CHAIN_RPCS[chain];
|
|
79
|
+
if (!rpcUrl) return null;
|
|
80
|
+
|
|
81
|
+
const chainType = chain === 'solana' ? 'solana' : 'evm';
|
|
82
|
+
|
|
83
|
+
if (chainType === 'evm') {
|
|
84
|
+
const res = await fetch(rpcUrl, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getBalance', params: [walletAddress, 'latest'] }),
|
|
88
|
+
});
|
|
89
|
+
const body = await res.json();
|
|
90
|
+
if (body.error || body.result === undefined) return null;
|
|
91
|
+
const wei = BigInt(body.result);
|
|
92
|
+
return Number(wei) / 10 ** NATIVE_DECIMALS[chain];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Solana — getBalance returns { value: <lamports> }
|
|
96
|
+
const res = await fetch(rpcUrl, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [walletAddress] }),
|
|
100
|
+
});
|
|
101
|
+
const body = await res.json();
|
|
102
|
+
if (body.error || body.result?.value === undefined) return null;
|
|
103
|
+
return body.result.value / 10 ** NATIVE_DECIMALS[chain];
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Addresses that represent native tokens (SOL, ETH) — not ERC-20/SPL contracts.
|
|
110
|
+
const NATIVE_TOKEN_ADDRESSES = {
|
|
111
|
+
solana: 'So11111111111111111111111111111111111111112',
|
|
112
|
+
base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Native token symbols for error messages.
|
|
116
|
+
const NATIVE_SYMBOLS = { solana: 'SOL', base: 'ETH' };
|
|
117
|
+
|
|
118
|
+
const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
|
|
119
|
+
const HIGH_PERCENTAGE_THRESHOLD = 95;
|
|
120
|
+
const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if an address is the native token for a chain (case-insensitive for EVM).
|
|
124
|
+
*/
|
|
125
|
+
function isNativeAddress(address, chain) {
|
|
126
|
+
const native = NATIVE_TOKEN_ADDRESSES[chain];
|
|
127
|
+
if (!native) return false;
|
|
128
|
+
if (chain === 'solana') return address === native;
|
|
129
|
+
return address.toLowerCase() === native.toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate that the wallet has sufficient balance of the sell token.
|
|
134
|
+
* Only applies when amountUnit is 'token' (human-readable amounts).
|
|
135
|
+
*
|
|
136
|
+
* Returns { adjustedAmount } — may differ from input if auto-adjusted
|
|
137
|
+
* to 100% of balance (when amount exceeds balance by ≤2%).
|
|
138
|
+
*
|
|
139
|
+
* Throws on validation failure. Returns without action if RPC fails (best-effort).
|
|
140
|
+
*/
|
|
141
|
+
export async function validateBalance({ chain, from, amount, amountUnit, walletAddress, decimals, symbol: callerSymbol }) {
|
|
142
|
+
// Only validate when amount is in token units — we can compare directly.
|
|
143
|
+
if (amountUnit !== 'token') return { adjustedAmount: amount };
|
|
144
|
+
|
|
145
|
+
const normalizedChain = chain.toLowerCase();
|
|
146
|
+
const isNative = isNativeAddress(from, normalizedChain);
|
|
147
|
+
const symbol = callerSymbol
|
|
148
|
+
|| (isNative ? NATIVE_SYMBOLS[normalizedChain] : null)
|
|
149
|
+
|| from;
|
|
150
|
+
|
|
151
|
+
let balance;
|
|
152
|
+
if (isNative) {
|
|
153
|
+
balance = await fetchNativeBalance(normalizedChain, walletAddress);
|
|
154
|
+
} else {
|
|
155
|
+
if (decimals === undefined) return { adjustedAmount: amount };
|
|
156
|
+
balance = await fetchTokenBalance(normalizedChain, from, walletAddress, decimals);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Best-effort: if RPC failed, proceed without validation.
|
|
160
|
+
if (balance === null) return { adjustedAmount: amount };
|
|
161
|
+
|
|
162
|
+
// Check 1: wallet must hold the token
|
|
163
|
+
if (balance === 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`No ${symbol} balance in wallet. You cannot trade a token you don't own.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check 2: amount vs balance
|
|
170
|
+
let numAmount = Number(amount);
|
|
171
|
+
if (numAmount > balance) {
|
|
172
|
+
const excessPercent = ((numAmount - balance) / balance) * 100;
|
|
173
|
+
if (excessPercent > AUTO_ADJUST_THRESHOLD_PERCENT) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Insufficient balance. You have ${balance} ${symbol} but the trade requires ${amount} ${symbol}.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
// Auto-adjust to 100% of balance
|
|
179
|
+
const adjustedAmount = String(balance);
|
|
180
|
+
numAmount = balance;
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`Warning: Amount ${amount} exceeds balance ${balance}. Auto-adjusting to ${adjustedAmount} ${symbol}.\n`
|
|
183
|
+
);
|
|
184
|
+
if (!isNative) return { adjustedAmount };
|
|
185
|
+
// Native tokens fall through to the fee buffer check below — selling 100%
|
|
186
|
+
// of a native balance still needs a gas reserve applied.
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check 3: native token fee buffer when selling ≥95% of balance
|
|
190
|
+
if (isNative) {
|
|
191
|
+
const percentOfBalance = (numAmount / balance) * 100;
|
|
192
|
+
if (percentOfBalance >= HIGH_PERCENTAGE_THRESHOLD) {
|
|
193
|
+
const reserve = FEE_BUFFER[normalizedChain] || 0;
|
|
194
|
+
const maxSellable = parseFloat((balance - reserve).toFixed(NATIVE_DECIMALS[normalizedChain]));
|
|
195
|
+
if (maxSellable <= 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Insufficient ${symbol} balance after reserving gas fees.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (numAmount > maxSellable) {
|
|
201
|
+
const adjustedAmount = String(maxSellable);
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`Warning: Reserving ${reserve} ${symbol} for gas. Adjusted sell amount to ${adjustedAmount} ${symbol}.\n`
|
|
204
|
+
);
|
|
205
|
+
return { adjustedAmount };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { adjustedAmount: amount };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch an ERC-20 or SPL token balance for a wallet.
|
|
215
|
+
* Returns balance in human-readable token units, or null on RPC failure.
|
|
216
|
+
* Requires `decimals` to convert from base units.
|
|
217
|
+
*
|
|
218
|
+
* Uses Number (not BigInt) for the result — see fetchNativeBalance note on precision.
|
|
219
|
+
*/
|
|
220
|
+
export async function fetchTokenBalance(chain, tokenAddress, walletAddress, decimals) {
|
|
221
|
+
try {
|
|
222
|
+
const rpcUrl = CHAIN_RPCS[chain];
|
|
223
|
+
if (!rpcUrl) return null;
|
|
224
|
+
|
|
225
|
+
const chainType = chain === 'solana' ? 'solana' : 'evm';
|
|
226
|
+
|
|
227
|
+
if (chainType === 'evm') {
|
|
228
|
+
// balanceOf(address) selector = 0x70a08231, address padded to 32 bytes
|
|
229
|
+
const paddedAddress = walletAddress.replace('0x', '').toLowerCase().padStart(64, '0');
|
|
230
|
+
const data = '0x70a08231' + paddedAddress;
|
|
231
|
+
const res = await fetch(rpcUrl, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'Content-Type': 'application/json' },
|
|
234
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: tokenAddress, data }, 'latest'] }),
|
|
235
|
+
});
|
|
236
|
+
const body = await res.json();
|
|
237
|
+
if (body.error || !body.result) return null;
|
|
238
|
+
const raw = BigInt(body.result);
|
|
239
|
+
return Number(raw) / 10 ** decimals;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Solana — getTokenAccountsByOwner with the mint filter
|
|
243
|
+
const res = await fetch(rpcUrl, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
jsonrpc: '2.0', id: 1,
|
|
248
|
+
method: 'getTokenAccountsByOwner',
|
|
249
|
+
params: [
|
|
250
|
+
walletAddress,
|
|
251
|
+
{ mint: tokenAddress },
|
|
252
|
+
{ encoding: 'jsonParsed' },
|
|
253
|
+
],
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
const body = await res.json();
|
|
257
|
+
if (body.error) return null;
|
|
258
|
+
const accounts = body.result?.value || [];
|
|
259
|
+
if (accounts.length === 0) return 0;
|
|
260
|
+
// Sum across all token accounts for this mint (rare but possible)
|
|
261
|
+
let total = 0n;
|
|
262
|
+
for (const acct of accounts) {
|
|
263
|
+
const amount = acct.account?.data?.parsed?.info?.tokenAmount?.amount;
|
|
264
|
+
if (amount) total += BigInt(amount);
|
|
265
|
+
}
|
|
266
|
+
return Number(total) / 10 ** decimals;
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/trading.js
CHANGED
|
@@ -13,6 +13,7 @@ import { base58Decode } from './transfer.js';
|
|
|
13
13
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
14
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
15
|
import { retrievePassword } from './keychain.js';
|
|
16
|
+
import { validateQuoteInput, validateBalance } from './trade-validation.js';
|
|
16
17
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
17
18
|
|
|
18
19
|
// ============= Constants =============
|
|
@@ -20,8 +21,8 @@ import { CHAIN_RPCS } from './rpc-urls.js';
|
|
|
20
21
|
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
21
22
|
|
|
22
23
|
const CHAIN_MAP = {
|
|
23
|
-
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/' },
|
|
24
|
-
base: { index: '8453', type: 'evm', chainId: 8453, name: 'Base', explorer: 'https://basescan.org/tx/' },
|
|
24
|
+
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
|
|
25
|
+
base: { index: '8453', type: 'evm', chainId: 8453, name: 'Base', explorer: 'https://basescan.org/tx/', lifiChainId: '8453' },
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
// Extend when adding new EVM chains (e.g. arbitrum WETH, polygon WMATIC)
|
|
@@ -198,13 +199,93 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
198
199
|
throw lastError;
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// ============= Bridge Status =============
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check the status of a cross-chain bridge transaction.
|
|
206
|
+
* @param {string} txHash - Source chain transaction hash
|
|
207
|
+
* @param {string} fromChain - Source chain name (e.g. 'base')
|
|
208
|
+
* @param {string} toChain - Destination chain name (e.g. 'solana')
|
|
209
|
+
* @returns {Promise<object>} Bridge status
|
|
210
|
+
*/
|
|
211
|
+
export async function getBridgeStatus(txHash, fromChain, toChain) {
|
|
212
|
+
const fromConfig = resolveChain(fromChain);
|
|
213
|
+
const toConfig = resolveChain(toChain);
|
|
214
|
+
const url = new URL('/bridge/status', TRADING_API_URL);
|
|
215
|
+
url.searchParams.set('txHash', txHash);
|
|
216
|
+
url.searchParams.set('fromChain', fromConfig.lifiChainId || fromConfig.index);
|
|
217
|
+
url.searchParams.set('toChain', toConfig.lifiChainId || toConfig.index);
|
|
218
|
+
|
|
219
|
+
const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
|
|
220
|
+
const text = await res.text();
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = JSON.parse(text);
|
|
224
|
+
} catch {
|
|
225
|
+
throw Object.assign(
|
|
226
|
+
new Error(`Bridge status API returned non-JSON response (status ${res.status}).`),
|
|
227
|
+
{ code: 'BRIDGE_STATUS_ERROR', status: res.status, details: text.slice(0, 200) }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
throw Object.assign(
|
|
232
|
+
new Error(body.message || `Bridge status check failed with status ${res.status}`),
|
|
233
|
+
{ code: body.code || 'BRIDGE_STATUS_ERROR', status: res.status, details: body.details }
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return body;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Poll bridge status until completion or timeout.
|
|
241
|
+
* @param {string} txHash - Source chain transaction hash
|
|
242
|
+
* @param {string} fromChain - Source chain name
|
|
243
|
+
* @param {string} toChain - Destination chain name
|
|
244
|
+
* @param {object} [opts]
|
|
245
|
+
* @param {number} [opts.timeoutMs=600000] - Timeout (default 10 min)
|
|
246
|
+
* @param {number} [opts.pollMs=10000] - Poll interval (default 10s)
|
|
247
|
+
* @param {Function} [opts.log=console.log] - Logger
|
|
248
|
+
* @returns {Promise<object>} Final bridge status
|
|
249
|
+
*/
|
|
250
|
+
export async function pollBridgeStatus(txHash, fromChain, toChain, { timeoutMs = 600000, pollMs = 10000, log = console.log } = {}) {
|
|
251
|
+
const start = Date.now();
|
|
252
|
+
while (Date.now() - start < timeoutMs) {
|
|
253
|
+
let status;
|
|
254
|
+
try {
|
|
255
|
+
status = await getBridgeStatus(txHash, fromChain, toChain);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Transient errors (502, 503, network failures) — retry after poll interval.
|
|
258
|
+
log(` Bridge: poll error (${err.status || err.code || 'unknown'}) — retrying...`);
|
|
259
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const sending = status.sending?.status || status.status || 'pending';
|
|
263
|
+
const receiving = status.receiving?.status || 'pending';
|
|
264
|
+
log(` Bridge: ${sending} → ${receiving}`);
|
|
265
|
+
|
|
266
|
+
if (status.status === 'DONE' || status.receiving?.status === 'DONE') return status;
|
|
267
|
+
if (status.status === 'FAILED') {
|
|
268
|
+
throw Object.assign(
|
|
269
|
+
new Error(`Bridge failed: ${status.substatusMessage || 'unknown error'}`),
|
|
270
|
+
{ code: 'BRIDGE_FAILED', details: status }
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
275
|
+
}
|
|
276
|
+
throw Object.assign(
|
|
277
|
+
new Error(`Bridge status polling timed out after ${timeoutMs / 1000}s. Check manually with: nansen trade bridge-status --tx-hash ${txHash} --from-chain ${fromChain} --to-chain ${toChain}`),
|
|
278
|
+
{ code: 'BRIDGE_TIMEOUT' }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
201
282
|
// ============= Quote Storage =============
|
|
202
283
|
|
|
203
284
|
/**
|
|
204
285
|
* Save a quote response to disk for later execution.
|
|
205
286
|
* @returns {string} Quote ID
|
|
206
287
|
*/
|
|
207
|
-
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null) {
|
|
288
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null) {
|
|
208
289
|
const dir = getQuotesDir();
|
|
209
290
|
if (!fs.existsSync(dir)) {
|
|
210
291
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -215,6 +296,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
|
|
|
215
296
|
const quoteId = `${timestamp}-${hash}`;
|
|
216
297
|
|
|
217
298
|
const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
|
|
299
|
+
if (toChain) data.toChain = toChain;
|
|
218
300
|
if (privyWalletIds) data.privyWalletIds = privyWalletIds;
|
|
219
301
|
|
|
220
302
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
@@ -807,6 +889,15 @@ export function formatQuote(quote, index) {
|
|
|
807
889
|
if (quote.tradingFeeInUsd) lines.push(` Trading Fee: $${quote.tradingFeeInUsd}`);
|
|
808
890
|
if (quote.networkFeeInUsd) lines.push(` Network Fee: $${quote.networkFeeInUsd}`);
|
|
809
891
|
if (quote.approvalAddress && !isNativeToken(quote.inputMint)) lines.push(` ⚠ Requires token approval to: ${quote.approvalAddress}`);
|
|
892
|
+
const meta = quote.metadata || {};
|
|
893
|
+
if (meta.isCrossChain) {
|
|
894
|
+
if (meta.bridgeTool) lines.push(` Bridge: ${meta.bridgeTool}`);
|
|
895
|
+
if (meta.executionDuration) lines.push(` Est. Time: ~${Math.round(meta.executionDuration / 60)} min`);
|
|
896
|
+
if (meta.feeCosts?.length) {
|
|
897
|
+
const totalFees = meta.feeCosts.reduce((sum, f) => sum + parseFloat(f.amountUSD || 0), 0);
|
|
898
|
+
if (totalFees > 0) lines.push(` Bridge Fees: $${totalFees.toFixed(2)}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
810
901
|
if (quote.priceImpactPct) {
|
|
811
902
|
const impactAbs = Math.abs(parseFloat(quote.priceImpactPct));
|
|
812
903
|
if (impactAbs > 5) {
|
|
@@ -827,12 +918,15 @@ export function buildTradingCommands(deps = {}) {
|
|
|
827
918
|
return {
|
|
828
919
|
'quote': async (args, apiInstance, flags, options) => {
|
|
829
920
|
const chain = options.chain || args[0];
|
|
921
|
+
const toChainRaw = options['to-chain'];
|
|
830
922
|
const fromRaw = options.from || options['from-token'] || args[1];
|
|
831
923
|
const toRaw = options.to || options['to-token'] || args[2];
|
|
924
|
+
const effectiveToChain = toChainRaw || chain;
|
|
832
925
|
const from = resolveTokenAddress(fromRaw, chain);
|
|
833
|
-
const to = resolveTokenAddress(toRaw,
|
|
926
|
+
const to = resolveTokenAddress(toRaw, effectiveToChain);
|
|
834
927
|
const amount = options.amount || args[3];
|
|
835
928
|
const walletName = options.wallet;
|
|
929
|
+
const toWallet = options['to-wallet'];
|
|
836
930
|
const slippage = options.slippage;
|
|
837
931
|
const autoSlippage = flags['auto-slippage'];
|
|
838
932
|
const maxAutoSlippage = options['max-auto-slippage'];
|
|
@@ -849,12 +943,14 @@ PREREQUISITE:
|
|
|
849
943
|
Set one up with: nansen wallet create
|
|
850
944
|
|
|
851
945
|
OPTIONS:
|
|
852
|
-
--chain <chain>
|
|
946
|
+
--chain <chain> Source chain: solana, base
|
|
947
|
+
--to-chain <chain> Destination chain for cross-chain swap (e.g. solana, base)
|
|
853
948
|
--from <symbol|address> Input token (symbol like SOL, USDC or address)
|
|
854
949
|
--to <symbol|address> Output token (symbol like USDC, ETH or address)
|
|
855
950
|
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
|
|
856
951
|
--amount-unit <unit> "token" to specify amount in token units (e.g. 0.5 SOL)
|
|
857
952
|
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
|
|
953
|
+
--to-wallet <address> Destination wallet address (auto-derived for cross-chain if omitted)
|
|
858
954
|
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
|
|
859
955
|
--auto-slippage Enable auto slippage calculation
|
|
860
956
|
--max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
|
|
@@ -864,7 +960,8 @@ EXAMPLES:
|
|
|
864
960
|
nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
865
961
|
nansen trade quote --chain solana --from SOL --to USDC --amount 0.5 --amount-unit token
|
|
866
962
|
nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
867
|
-
nansen trade quote --chain solana --from
|
|
963
|
+
nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
|
|
964
|
+
nansen trade quote --chain solana --to-chain base --from SOL --to ETH --amount 1000000000
|
|
868
965
|
`);
|
|
869
966
|
exit(1);
|
|
870
967
|
return;
|
|
@@ -877,14 +974,25 @@ EXAMPLES:
|
|
|
877
974
|
return;
|
|
878
975
|
}
|
|
879
976
|
|
|
977
|
+
// Static input validation — catches common agent errors (wrong addresses,
|
|
978
|
+
// same-token swaps, bad amounts) before any network or wallet call.
|
|
979
|
+
try {
|
|
980
|
+
validateQuoteInput({ chain, toChain: toChainRaw || null, from, to, amount });
|
|
981
|
+
} catch (validationErr) {
|
|
982
|
+
log(`Error: ${validationErr.message}`);
|
|
983
|
+
exit(1);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
880
987
|
// When --amount-unit token is used, resolve decimals and convert to base units.
|
|
881
988
|
// Otherwise, validate that the amount is already in base units (integer).
|
|
882
989
|
let resolvedAmount = amount;
|
|
990
|
+
let resolvedDecimals;
|
|
883
991
|
if (amountUnit === 'token') {
|
|
884
992
|
try {
|
|
885
993
|
const tokenForDecimals = swapMode === 'exactOut' ? to : from;
|
|
886
|
-
|
|
887
|
-
resolvedAmount = convertToBaseUnits(amount,
|
|
994
|
+
resolvedDecimals = await resolveTokenDecimals(tokenForDecimals, chain);
|
|
995
|
+
resolvedAmount = convertToBaseUnits(amount, resolvedDecimals);
|
|
888
996
|
} catch (err) {
|
|
889
997
|
log(`Error resolving token decimals: ${err.message}`);
|
|
890
998
|
exit(1);
|
|
@@ -944,7 +1052,39 @@ EXAMPLES:
|
|
|
944
1052
|
return;
|
|
945
1053
|
}
|
|
946
1054
|
|
|
947
|
-
|
|
1055
|
+
// Balance pre-check — catches zero balances and insufficient funds
|
|
1056
|
+
// before wasting a quote API call. Only runs for --amount-unit token
|
|
1057
|
+
// in exactIn mode (in exactOut, the amount is the buy amount so
|
|
1058
|
+
// comparing it against the sell token balance is meaningless).
|
|
1059
|
+
if (amountUnit === 'token' && swapMode !== 'exactOut') {
|
|
1060
|
+
try {
|
|
1061
|
+
const { adjustedAmount: balanceAdjusted } = await validateBalance({
|
|
1062
|
+
chain,
|
|
1063
|
+
from,
|
|
1064
|
+
amount,
|
|
1065
|
+
amountUnit,
|
|
1066
|
+
walletAddress,
|
|
1067
|
+
decimals: resolvedDecimals,
|
|
1068
|
+
symbol: fromRaw,
|
|
1069
|
+
});
|
|
1070
|
+
if (balanceAdjusted !== amount) {
|
|
1071
|
+
resolvedAmount = convertToBaseUnits(balanceAdjusted, resolvedDecimals);
|
|
1072
|
+
}
|
|
1073
|
+
} catch (balanceErr) {
|
|
1074
|
+
log(`Error: ${balanceErr.message}`);
|
|
1075
|
+
exit(1);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const toChainConfig = toChainRaw ? resolveChain(toChainRaw) : null;
|
|
1081
|
+
const isCrossChain = toChainConfig && toChainConfig.index !== chainConfig.index;
|
|
1082
|
+
|
|
1083
|
+
if (isCrossChain) {
|
|
1084
|
+
log(`\nFetching cross-chain quote: ${chainConfig.name} → ${toChainConfig.name}...`);
|
|
1085
|
+
} else {
|
|
1086
|
+
log(`\nFetching quote on ${chainConfig.name}...`);
|
|
1087
|
+
}
|
|
948
1088
|
log(` Wallet: ${walletAddress}`);
|
|
949
1089
|
|
|
950
1090
|
const fromWarning = getWrappedNativeFromWarning(from, chain);
|
|
@@ -957,6 +1097,20 @@ EXAMPLES:
|
|
|
957
1097
|
amount: resolvedAmount,
|
|
958
1098
|
userWalletAddress: walletAddress,
|
|
959
1099
|
};
|
|
1100
|
+
if (isCrossChain) {
|
|
1101
|
+
params.toChainIndex = toChainConfig.index;
|
|
1102
|
+
if (toWallet) {
|
|
1103
|
+
params.toWalletAddress = toWallet;
|
|
1104
|
+
} else if (chainConfig.type !== toChainConfig.type) {
|
|
1105
|
+
// Solana↔Base: auto-derive the destination address from the same wallet
|
|
1106
|
+
const effectiveWalletName = walletName || getWalletConfig()?.defaultWallet;
|
|
1107
|
+
if (effectiveWalletName) {
|
|
1108
|
+
const walletData = showWallet(effectiveWalletName);
|
|
1109
|
+
params.toWalletAddress = toChainConfig.type === 'solana' ? walletData.solana : walletData.evm;
|
|
1110
|
+
log(` Destination wallet: ${params.toWalletAddress}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
960
1114
|
if (slippage) params.slippagePercent = slippage;
|
|
961
1115
|
if (autoSlippage) params.autoSlippage = true;
|
|
962
1116
|
if (maxAutoSlippage) params.maxAutoSlippagePercent = maxAutoSlippage;
|
|
@@ -977,7 +1131,7 @@ EXAMPLES:
|
|
|
977
1131
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
978
1132
|
|
|
979
1133
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
980
|
-
const quoteId = saveQuote(response, chain, signerType, privyWalletIds);
|
|
1134
|
+
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
|
|
981
1135
|
log(`\n Quote ID: ${quoteId}`);
|
|
982
1136
|
log(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
983
1137
|
if (response.quotes.length > 1) {
|
|
@@ -1481,6 +1635,23 @@ EXAMPLES:
|
|
|
1481
1635
|
log(` Tx Hash: ${wcResult.txHash}`);
|
|
1482
1636
|
log(` Chain: ${chainConfig.name}`);
|
|
1483
1637
|
log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
1638
|
+
|
|
1639
|
+
// Cross-chain: poll bridge status after source tx success
|
|
1640
|
+
if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
|
|
1641
|
+
log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
|
|
1642
|
+
try {
|
|
1643
|
+
const bridgeResult = await pollBridgeStatus(wcResult.txHash, quoteData.chain, quoteData.toChain, { log });
|
|
1644
|
+
log(`\n ✓ Bridge completed!`);
|
|
1645
|
+
if (bridgeResult.receiving?.txHash) {
|
|
1646
|
+
const toChainConfig = resolveChain(quoteData.toChain);
|
|
1647
|
+
log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
|
|
1648
|
+
}
|
|
1649
|
+
} catch (bridgeErr) {
|
|
1650
|
+
log(`\n Bridge status: ${bridgeErr.message}`);
|
|
1651
|
+
log(` Check later with: nansen trade bridge-status --tx-hash ${wcResult.txHash} --from-chain ${quoteData.chain} --to-chain ${quoteData.toChain}`);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1484
1655
|
log('');
|
|
1485
1656
|
return undefined; // Success
|
|
1486
1657
|
}
|
|
@@ -1667,6 +1838,23 @@ EXAMPLES:
|
|
|
1667
1838
|
log(` ${e.inputAmount} ${e.inputMint?.slice(0, 8)}... → ${e.outputAmount} ${e.outputMint?.slice(0, 8)}...`);
|
|
1668
1839
|
});
|
|
1669
1840
|
}
|
|
1841
|
+
|
|
1842
|
+
// Cross-chain: poll bridge status after source tx success
|
|
1843
|
+
if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
|
|
1844
|
+
log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
|
|
1845
|
+
try {
|
|
1846
|
+
const bridgeResult = await pollBridgeStatus(txId, quoteData.chain, quoteData.toChain, { log });
|
|
1847
|
+
log(`\n ✓ Bridge completed!`);
|
|
1848
|
+
if (bridgeResult.receiving?.txHash) {
|
|
1849
|
+
const toChainConfig = resolveChain(quoteData.toChain);
|
|
1850
|
+
log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
|
|
1851
|
+
}
|
|
1852
|
+
} catch (bridgeErr) {
|
|
1853
|
+
log(`\n Bridge status: ${bridgeErr.message}`);
|
|
1854
|
+
log(` Check later with: nansen trade bridge-status --tx-hash ${txId} --from-chain ${quoteData.chain} --to-chain ${quoteData.toChain}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1670
1858
|
log('');
|
|
1671
1859
|
return undefined; // Success — done
|
|
1672
1860
|
} else {
|
|
@@ -1677,8 +1865,12 @@ EXAMPLES:
|
|
|
1677
1865
|
}
|
|
1678
1866
|
|
|
1679
1867
|
} catch (quoteErr) {
|
|
1680
|
-
|
|
1681
|
-
|
|
1868
|
+
const msg = quoteErr.message || '';
|
|
1869
|
+
log(` ❌ Quote ${quoteName} failed: ${msg}`);
|
|
1870
|
+
if (msg.includes('AccountNotFound') && chainType === 'solana') {
|
|
1871
|
+
log(` Hint: Your Solana wallet may not have enough SOL to cover transaction fees (~0.005 SOL minimum).`);
|
|
1872
|
+
}
|
|
1873
|
+
lastQuoteError = `${quoteName}: ${msg}`;
|
|
1682
1874
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1683
1875
|
}
|
|
1684
1876
|
}
|
|
@@ -1695,5 +1887,55 @@ EXAMPLES:
|
|
|
1695
1887
|
exit(1);
|
|
1696
1888
|
}
|
|
1697
1889
|
},
|
|
1890
|
+
|
|
1891
|
+
'bridge-status': async (args, _apiInstance, _flags, options) => {
|
|
1892
|
+
const txHash = options['tx-hash'] || args[0];
|
|
1893
|
+
const fromChain = options['from-chain'] || args[1];
|
|
1894
|
+
const toChain = options['to-chain'] || args[2];
|
|
1895
|
+
|
|
1896
|
+
if (!txHash || !fromChain || !toChain) {
|
|
1897
|
+
log(`
|
|
1898
|
+
Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1899
|
+
|
|
1900
|
+
Check the status of a cross-chain bridge transaction.
|
|
1901
|
+
|
|
1902
|
+
OPTIONS:
|
|
1903
|
+
--tx-hash <hash> Source chain transaction hash
|
|
1904
|
+
--from-chain <chain> Source chain (solana or base)
|
|
1905
|
+
--to-chain <chain> Destination chain (solana or base)
|
|
1906
|
+
|
|
1907
|
+
EXAMPLES:
|
|
1908
|
+
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
|
|
1909
|
+
`);
|
|
1910
|
+
exit(1);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
try {
|
|
1915
|
+
const status = await getBridgeStatus(txHash, fromChain, toChain);
|
|
1916
|
+
log(`\nBridge Status: ${status.status || 'unknown'}`);
|
|
1917
|
+
if (status.substatus) log(` Substatus: ${status.substatus}`);
|
|
1918
|
+
if (status.substatusMessage) log(` Message: ${status.substatusMessage}`);
|
|
1919
|
+
if (status.tool) log(` Bridge: ${status.tool}`);
|
|
1920
|
+
if (status.sending?.txHash) {
|
|
1921
|
+
log(` Sending:`);
|
|
1922
|
+
log(` Tx: ${status.sending.txHash}`);
|
|
1923
|
+
if (status.sending.amount) log(` Amount: ${status.sending.amount}`);
|
|
1924
|
+
if (status.sending.txLink) log(` Explorer: ${status.sending.txLink}`);
|
|
1925
|
+
}
|
|
1926
|
+
if (status.receiving?.txHash) {
|
|
1927
|
+
log(` Receiving:`);
|
|
1928
|
+
log(` Tx: ${status.receiving.txHash}`);
|
|
1929
|
+
if (status.receiving.amount) log(` Amount: ${status.receiving.amount}`);
|
|
1930
|
+
if (status.receiving.txLink) log(` Explorer: ${status.receiving.txLink}`);
|
|
1931
|
+
}
|
|
1932
|
+
if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
|
|
1933
|
+
log('');
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
log(`Error: ${err.message}`);
|
|
1936
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
1937
|
+
exit(1);
|
|
1938
|
+
}
|
|
1939
|
+
},
|
|
1698
1940
|
};
|
|
1699
1941
|
}
|
package/src/transfer.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
-
import {
|
|
8
|
-
import { base58Encode, exportWallet, getWalletConfig, verifyPassword, showWallet } from './wallet.js';
|
|
7
|
+
import { base58Encode, base58Decode, base58DecodePubkey, exportWallet, getWalletConfig, verifyPassword, showWallet } from './wallet.js';
|
|
9
8
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
10
9
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect } from './walletconnect-trading.js';
|
|
11
10
|
import { EVM_CHAIN_IDS } from './chain-ids.js';
|
|
@@ -22,19 +21,6 @@ const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
|
|
|
22
21
|
// Alias: buildEvmTransaction uses 'evm' as a generic fallback
|
|
23
22
|
const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
|
|
24
23
|
|
|
25
|
-
// ============= Base58 =============
|
|
26
|
-
|
|
27
|
-
function base58Decode(str) {
|
|
28
|
-
return Buffer.from(base58.decode(str));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function base58DecodePubkey(str) {
|
|
32
|
-
const raw = base58Decode(str);
|
|
33
|
-
if (raw.length === 32) return raw;
|
|
34
|
-
if (raw.length < 32) return Buffer.concat([Buffer.alloc(32 - raw.length), raw]);
|
|
35
|
-
return raw.subarray(raw.length - 32);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
24
|
// ============= Address Validation =============
|
|
39
25
|
|
|
40
26
|
function validateEvmAddress(address) {
|
|
@@ -825,13 +811,7 @@ async function sendTokensViaWalletConnect({ to, amount, chain, token, max, dryRu
|
|
|
825
811
|
let txTo, txValue, txData;
|
|
826
812
|
|
|
827
813
|
if (token) {
|
|
828
|
-
|
|
829
|
-
const code = await rpcCall(rpcUrl, 'eth_getCode', [token, 'latest']);
|
|
830
|
-
if (!code || code === '0x' || code === '0x0') {
|
|
831
|
-
throw new Error(`Address ${token} is not a contract — not a valid ERC-20 token`);
|
|
832
|
-
}
|
|
833
|
-
const decResult = await rpcCall(rpcUrl, 'eth_call', [{ to: token, data: '0x313ce567' }, 'latest']);
|
|
834
|
-
const decimals = parseInt(decResult, 16);
|
|
814
|
+
const decimals = await validateErc20Token(rpcUrl, token);
|
|
835
815
|
|
|
836
816
|
if (max) {
|
|
837
817
|
// Max ERC-20: full token balance
|
package/src/wallet.js
CHANGED
|
@@ -40,6 +40,17 @@ export function base58Encode(buf) {
|
|
|
40
40
|
return base58.encode(buf instanceof Uint8Array ? buf : Uint8Array.from(buf));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function base58Decode(str) {
|
|
44
|
+
return Buffer.from(base58.decode(str));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function base58DecodePubkey(str) {
|
|
48
|
+
const raw = base58Decode(str);
|
|
49
|
+
if (raw.length === 32) return raw;
|
|
50
|
+
if (raw.length < 32) return Buffer.concat([Buffer.alloc(32 - raw.length), raw]);
|
|
51
|
+
return raw.subarray(raw.length - 32);
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
// ============= Encryption =============
|
|
44
55
|
|
|
45
56
|
/**
|
package/src/x402-svm.js
CHANGED
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
// ============= Base58 Encode =============
|
|
10
|
-
|
|
11
|
-
export function base58Encode(buf) {
|
|
12
|
-
return base58.encode(buf instanceof Uint8Array ? buf : Uint8Array.from(buf));
|
|
13
|
-
}
|
|
7
|
+
import { base58Encode, base58DecodePubkey } from './wallet.js';
|
|
14
8
|
|
|
15
9
|
// ============= Constants =============
|
|
16
10
|
|
|
@@ -24,25 +18,6 @@ const _SYSTEM_PROGRAM = '11111111111111111111111111111111';
|
|
|
24
18
|
const DEFAULT_COMPUTE_UNIT_LIMIT = 20000;
|
|
25
19
|
const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
|
|
26
20
|
|
|
27
|
-
// ============= Base58 Decode =============
|
|
28
|
-
|
|
29
|
-
export function base58Decode(str) {
|
|
30
|
-
return Buffer.from(base58.decode(str));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Decode a base58 string to exactly 32 bytes (left-pad with zeros).
|
|
35
|
-
* Use for Solana public keys and hashes.
|
|
36
|
-
*/
|
|
37
|
-
export function base58DecodePubkey(str) {
|
|
38
|
-
const raw = base58Decode(str);
|
|
39
|
-
if (raw.length === 32) return raw;
|
|
40
|
-
if (raw.length < 32) {
|
|
41
|
-
return Buffer.concat([Buffer.alloc(32 - raw.length), raw]);
|
|
42
|
-
}
|
|
43
|
-
return raw.subarray(raw.length - 32);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
21
|
// ============= Compact-u16 Encoding =============
|
|
47
22
|
// (Solana's variable-length integer format, from trading.js pattern)
|
|
48
23
|
|