nansen-cli 1.23.1 → 1.25.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/api.js +3 -2
- package/src/cli.js +9 -5
- package/src/schema.json +35 -7
- package/src/trade-validation.js +224 -8
- package/src/trading.js +297 -18
- package/src/transfer.js +2 -22
- package/src/wallet.js +11 -0
- package/src/x402-svm.js +2 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#363](https://github.com/nansen-ai/nansen-cli/pull/363) [`6ae402e`](https://github.com/nansen-ai/nansen-cli/commit/6ae402ef1e5bdeacf83fe04bcf6e8e0c9f9c91b7) Thanks [@TimNooren](https://github.com/TimNooren)! - Add `--amount-unit usd` to trade commands — specify swap amounts in USD
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [#374](https://github.com/nansen-ai/nansen-cli/pull/374) [`0f14803`](https://github.com/nansen-ai/nansen-cli/commit/0f148031ef7590f3405c1dba9f31ad83768a7141) Thanks [@TimNooren](https://github.com/TimNooren)! - Fix cross-chain quote display: show adaptive precision for sub-cent bridge fees, "< 1 min" for fast bridges, and echo --to-wallet address in output
|
|
12
|
+
|
|
13
|
+
- [#366](https://github.com/nansen-ai/nansen-cli/pull/366) [`f358fff`](https://github.com/nansen-ai/nansen-cli/commit/f358fffcc80136c4e609f2e056f2c5ffb052d626) Thanks [@kome12](https://github.com/kome12)! - fix(token): replace dead `--days` param with working `--timeframe` for `token flow-intelligence`
|
|
14
|
+
|
|
15
|
+
The `--days` option was accepted but never sent to the API, resulting in always fetching `1d` data. This replaces it with `--timeframe` (enum: `1h | 6h | 12h | 1d | 7d`, default `1d`) which maps correctly to the API parameter.
|
|
16
|
+
|
|
17
|
+
## 1.24.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- [#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.
|
|
22
|
+
|
|
23
|
+
`nansen trade quote --chain base --to-chain solana --from ETH --to SOL --amount 0.01 --amount-unit token`
|
|
24
|
+
`nansen trade execute --quote <id>`
|
|
25
|
+
|
|
26
|
+
Bridge status can be checked with `nansen trade bridge-status`.
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- [#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).
|
|
31
|
+
|
|
3
32
|
## 1.23.1
|
|
4
33
|
|
|
5
34
|
### Patch 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/api.js
CHANGED
|
@@ -1061,14 +1061,15 @@ export class NansenAPI {
|
|
|
1061
1061
|
}
|
|
1062
1062
|
|
|
1063
1063
|
async tokenFlowIntelligence(params = {}) {
|
|
1064
|
-
const { tokenAddress, chain = 'solana' } = params;
|
|
1064
|
+
const { tokenAddress, chain = 'solana', timeframe = '1d' } = params;
|
|
1065
1065
|
if (tokenAddress) {
|
|
1066
1066
|
const validation = validateTokenAddress(tokenAddress, chain);
|
|
1067
1067
|
if (!validation.valid) throw new NansenError(validation.error, validation.code);
|
|
1068
1068
|
}
|
|
1069
1069
|
return this.request('/api/v1/tgm/flow-intelligence', {
|
|
1070
1070
|
token_address: tokenAddress,
|
|
1071
|
-
chain
|
|
1071
|
+
chain,
|
|
1072
|
+
timeframe
|
|
1072
1073
|
});
|
|
1073
1074
|
}
|
|
1074
1075
|
|
package/src/cli.js
CHANGED
|
@@ -1250,7 +1250,7 @@ export function buildCommands(deps = {}) {
|
|
|
1250
1250
|
const buyOrSell = (options['buy-or-sell'] || 'BUY').toUpperCase();
|
|
1251
1251
|
return apiInstance.tokenWhoBoughtSold({ tokenAddress, chain, buyOrSell, filters, orderBy, pagination, days, date });
|
|
1252
1252
|
},
|
|
1253
|
-
'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain,
|
|
1253
|
+
'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain, timeframe: options.timeframe || '1d' }),
|
|
1254
1254
|
'transfers': () => {
|
|
1255
1255
|
// Inject --from/--to into filters
|
|
1256
1256
|
if (options.from) filters.from_address = options.from;
|
|
@@ -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/schema.json
CHANGED
|
@@ -408,8 +408,8 @@
|
|
|
408
408
|
"token": {
|
|
409
409
|
"required": true
|
|
410
410
|
},
|
|
411
|
-
"
|
|
412
|
-
"default":
|
|
411
|
+
"timeframe": {
|
|
412
|
+
"default": "1d"
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
},
|
|
@@ -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,20 +791,24 @@
|
|
|
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",
|
|
794
798
|
"required": true,
|
|
795
|
-
"description": "Amount to swap (base units by default, or token units with --amount-unit token)"
|
|
799
|
+
"description": "Amount to swap (base units by default, or token units with --amount-unit token, or USD with --amount-unit usd)"
|
|
796
800
|
},
|
|
797
801
|
"amount-unit": {
|
|
798
802
|
"type": "string",
|
|
799
|
-
"description": "\"token\" to specify amount in token units (e.g. 0.5 SOL), or \"base\" for base units (default).
|
|
803
|
+
"description": "\"token\" to specify amount in token units (e.g. 0.5 SOL), \"usd\" to specify amount in USD (e.g. 50), or \"base\" for base units (default). The CLI resolves the current token price and decimals locally; the API always receives base units."
|
|
800
804
|
},
|
|
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
|
},
|
package/src/trade-validation.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { validateAddress } from './api.js';
|
|
8
|
+
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
8
9
|
|
|
9
10
|
const SUPPORTED_CHAINS = ['solana', 'base'];
|
|
10
11
|
|
|
@@ -12,7 +13,7 @@ const SUPPORTED_CHAINS = ['solana', 'base'];
|
|
|
12
13
|
* Validate quote inputs before any network call.
|
|
13
14
|
* Throws on validation failure with an actionable error message.
|
|
14
15
|
*/
|
|
15
|
-
export function validateQuoteInput({ chain, from, to, amount }) {
|
|
16
|
+
export function validateQuoteInput({ chain, toChain, from, to, amount }) {
|
|
16
17
|
// 1. Chain must be supported
|
|
17
18
|
const normalizedChain = chain?.toLowerCase();
|
|
18
19
|
if (!SUPPORTED_CHAINS.includes(normalizedChain)) {
|
|
@@ -21,6 +22,13 @@ export function validateQuoteInput({ chain, from, to, amount }) {
|
|
|
21
22
|
);
|
|
22
23
|
}
|
|
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
|
+
|
|
24
32
|
// 2. Amount must be a positive finite number
|
|
25
33
|
const numAmount = Number(amount);
|
|
26
34
|
if (!Number.isFinite(numAmount) || numAmount <= 0) {
|
|
@@ -36,19 +44,227 @@ export function validateQuoteInput({ chain, from, to, amount }) {
|
|
|
36
44
|
`Invalid sell token address for ${normalizedChain}. ${fromResult.error}`
|
|
37
45
|
);
|
|
38
46
|
}
|
|
39
|
-
const toResult = validateAddress(to,
|
|
47
|
+
const toResult = validateAddress(to, normalizedToChain);
|
|
40
48
|
if (!toResult.valid) {
|
|
41
49
|
throw new Error(
|
|
42
|
-
`Invalid buy token address for ${
|
|
50
|
+
`Invalid buy token address for ${normalizedToChain}. ${toResult.error}`
|
|
43
51
|
);
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// 4. Sell and buy
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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) {
|
|
50
164
|
throw new Error(
|
|
51
|
-
`
|
|
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`
|
|
52
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;
|
|
53
269
|
}
|
|
54
270
|
}
|
package/src/trading.js
CHANGED
|
@@ -13,7 +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 } from './trade-validation.js';
|
|
16
|
+
import { validateQuoteInput, validateBalance } from './trade-validation.js';
|
|
17
17
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
18
18
|
|
|
19
19
|
// ============= Constants =============
|
|
@@ -21,8 +21,8 @@ import { CHAIN_RPCS } from './rpc-urls.js';
|
|
|
21
21
|
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
22
22
|
|
|
23
23
|
const CHAIN_MAP = {
|
|
24
|
-
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/' },
|
|
25
|
-
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' },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
// Extend when adding new EVM chains (e.g. arbitrum WETH, polygon WMATIC)
|
|
@@ -199,13 +199,93 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
199
199
|
throw lastError;
|
|
200
200
|
}
|
|
201
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
|
+
|
|
202
282
|
// ============= Quote Storage =============
|
|
203
283
|
|
|
204
284
|
/**
|
|
205
285
|
* Save a quote response to disk for later execution.
|
|
206
286
|
* @returns {string} Quote ID
|
|
207
287
|
*/
|
|
208
|
-
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null) {
|
|
288
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null) {
|
|
209
289
|
const dir = getQuotesDir();
|
|
210
290
|
if (!fs.existsSync(dir)) {
|
|
211
291
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -216,6 +296,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
|
|
|
216
296
|
const quoteId = `${timestamp}-${hash}`;
|
|
217
297
|
|
|
218
298
|
const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
|
|
299
|
+
if (toChain) data.toChain = toChain;
|
|
219
300
|
if (privyWalletIds) data.privyWalletIds = privyWalletIds;
|
|
220
301
|
|
|
221
302
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
@@ -772,6 +853,27 @@ export function convertToBaseUnits(amount, decimals) {
|
|
|
772
853
|
return raw;
|
|
773
854
|
}
|
|
774
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Fetch the current USD price for a token via the Nansen search API.
|
|
858
|
+
* Used by --amount-unit usd to convert dollar amounts to token amounts.
|
|
859
|
+
*/
|
|
860
|
+
export async function resolveUsdPrice(apiInstance, tokenAddress, chain) {
|
|
861
|
+
const result = await apiInstance.generalSearch({
|
|
862
|
+
query: tokenAddress,
|
|
863
|
+
resultType: 'token',
|
|
864
|
+
chain,
|
|
865
|
+
limit: 1,
|
|
866
|
+
});
|
|
867
|
+
const isEvm = tokenAddress.startsWith('0x');
|
|
868
|
+
const token = result.tokens?.find(t =>
|
|
869
|
+
isEvm ? t.address?.toLowerCase() === tokenAddress.toLowerCase() : t.address === tokenAddress
|
|
870
|
+
);
|
|
871
|
+
if (!token?.price) {
|
|
872
|
+
throw new Error(`Could not resolve USD price for ${tokenAddress} on ${chain}. The token may not have pricing data.`);
|
|
873
|
+
}
|
|
874
|
+
return token.price;
|
|
875
|
+
}
|
|
876
|
+
|
|
775
877
|
/**
|
|
776
878
|
* Check if amount contains a decimal point (i.e. not in base units).
|
|
777
879
|
* Returns an error string if invalid, or null if OK. Pure function.
|
|
@@ -808,6 +910,21 @@ export function formatQuote(quote, index) {
|
|
|
808
910
|
if (quote.tradingFeeInUsd) lines.push(` Trading Fee: $${quote.tradingFeeInUsd}`);
|
|
809
911
|
if (quote.networkFeeInUsd) lines.push(` Network Fee: $${quote.networkFeeInUsd}`);
|
|
810
912
|
if (quote.approvalAddress && !isNativeToken(quote.inputMint)) lines.push(` ⚠ Requires token approval to: ${quote.approvalAddress}`);
|
|
913
|
+
const meta = quote.metadata || {};
|
|
914
|
+
if (meta.isCrossChain) {
|
|
915
|
+
if (meta.bridgeTool) lines.push(` Bridge: ${meta.bridgeTool}`);
|
|
916
|
+
if (meta.executionDuration) {
|
|
917
|
+
const mins = Math.round(meta.executionDuration / 60);
|
|
918
|
+
lines.push(` Est. Time: ${mins < 1 ? '< 1 min' : `~${mins} min`}`);
|
|
919
|
+
}
|
|
920
|
+
if (meta.feeCosts?.length) {
|
|
921
|
+
const totalFees = meta.feeCosts.reduce((sum, f) => sum + parseFloat(f.amountUSD || 0), 0);
|
|
922
|
+
if (totalFees > 0) {
|
|
923
|
+
const feeStr = totalFees < 0.01 ? totalFees.toPrecision(1) : totalFees.toFixed(2);
|
|
924
|
+
lines.push(` Bridge Fees: $${feeStr}`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
811
928
|
if (quote.priceImpactPct) {
|
|
812
929
|
const impactAbs = Math.abs(parseFloat(quote.priceImpactPct));
|
|
813
930
|
if (impactAbs > 5) {
|
|
@@ -828,12 +945,15 @@ export function buildTradingCommands(deps = {}) {
|
|
|
828
945
|
return {
|
|
829
946
|
'quote': async (args, apiInstance, flags, options) => {
|
|
830
947
|
const chain = options.chain || args[0];
|
|
948
|
+
const toChainRaw = options['to-chain'];
|
|
831
949
|
const fromRaw = options.from || options['from-token'] || args[1];
|
|
832
950
|
const toRaw = options.to || options['to-token'] || args[2];
|
|
951
|
+
const effectiveToChain = toChainRaw || chain;
|
|
833
952
|
const from = resolveTokenAddress(fromRaw, chain);
|
|
834
|
-
const to = resolveTokenAddress(toRaw,
|
|
953
|
+
const to = resolveTokenAddress(toRaw, effectiveToChain);
|
|
835
954
|
const amount = options.amount || args[3];
|
|
836
955
|
const walletName = options.wallet;
|
|
956
|
+
const toWallet = options['to-wallet'];
|
|
837
957
|
const slippage = options.slippage;
|
|
838
958
|
const autoSlippage = flags['auto-slippage'];
|
|
839
959
|
const maxAutoSlippage = options['max-auto-slippage'];
|
|
@@ -850,12 +970,14 @@ PREREQUISITE:
|
|
|
850
970
|
Set one up with: nansen wallet create
|
|
851
971
|
|
|
852
972
|
OPTIONS:
|
|
853
|
-
--chain <chain>
|
|
973
|
+
--chain <chain> Source chain: solana, base
|
|
974
|
+
--to-chain <chain> Destination chain for cross-chain swap (e.g. solana, base)
|
|
854
975
|
--from <symbol|address> Input token (symbol like SOL, USDC or address)
|
|
855
976
|
--to <symbol|address> Output token (symbol like USDC, ETH or address)
|
|
856
977
|
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
|
|
857
|
-
--amount-unit <unit> "token"
|
|
978
|
+
--amount-unit <unit> "token" for token units (e.g. 0.5 SOL), "usd" for USD (e.g. 50)
|
|
858
979
|
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
|
|
980
|
+
--to-wallet <address> Destination wallet address (auto-derived for cross-chain if omitted)
|
|
859
981
|
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
|
|
860
982
|
--auto-slippage Enable auto slippage calculation
|
|
861
983
|
--max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
|
|
@@ -864,16 +986,18 @@ OPTIONS:
|
|
|
864
986
|
EXAMPLES:
|
|
865
987
|
nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
866
988
|
nansen trade quote --chain solana --from SOL --to USDC --amount 0.5 --amount-unit token
|
|
989
|
+
nansen trade quote --chain solana --from SOL --to USDC --amount 50 --amount-unit usd
|
|
867
990
|
nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
868
|
-
nansen trade quote --chain solana --from
|
|
991
|
+
nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
|
|
992
|
+
nansen trade quote --chain solana --to-chain base --from SOL --to ETH --amount 1000000000
|
|
869
993
|
`);
|
|
870
994
|
exit(1);
|
|
871
995
|
return;
|
|
872
996
|
}
|
|
873
997
|
|
|
874
998
|
// Validate --amount-unit if provided
|
|
875
|
-
if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base') {
|
|
876
|
-
log(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base`);
|
|
999
|
+
if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd') {
|
|
1000
|
+
log(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd`);
|
|
877
1001
|
exit(1);
|
|
878
1002
|
return;
|
|
879
1003
|
}
|
|
@@ -881,7 +1005,7 @@ EXAMPLES:
|
|
|
881
1005
|
// Static input validation — catches common agent errors (wrong addresses,
|
|
882
1006
|
// same-token swaps, bad amounts) before any network or wallet call.
|
|
883
1007
|
try {
|
|
884
|
-
validateQuoteInput({ chain, from, to, amount });
|
|
1008
|
+
validateQuoteInput({ chain, toChain: toChainRaw || null, from, to, amount });
|
|
885
1009
|
} catch (validationErr) {
|
|
886
1010
|
log(`Error: ${validationErr.message}`);
|
|
887
1011
|
exit(1);
|
|
@@ -891,11 +1015,28 @@ EXAMPLES:
|
|
|
891
1015
|
// When --amount-unit token is used, resolve decimals and convert to base units.
|
|
892
1016
|
// Otherwise, validate that the amount is already in base units (integer).
|
|
893
1017
|
let resolvedAmount = amount;
|
|
894
|
-
|
|
1018
|
+
let resolvedDecimals;
|
|
1019
|
+
let usdTokenAmount; // token-unit amount after USD conversion (for balance pre-check)
|
|
1020
|
+
if (amountUnit === 'usd') {
|
|
1021
|
+
try {
|
|
1022
|
+
const tokenForPrice = swapMode === 'exactOut' ? to : from;
|
|
1023
|
+
const price = await resolveUsdPrice(apiInstance, tokenForPrice, chain);
|
|
1024
|
+
resolvedDecimals = await resolveTokenDecimals(tokenForPrice, chain);
|
|
1025
|
+
// Convert USD to token amount, then to base units via string math.
|
|
1026
|
+
// Use toFixed() instead of String() to avoid scientific notation for small values.
|
|
1027
|
+
const tokenAmount = parseFloat(amount) / price;
|
|
1028
|
+
usdTokenAmount = tokenAmount.toFixed(resolvedDecimals);
|
|
1029
|
+
resolvedAmount = convertToBaseUnits(usdTokenAmount, resolvedDecimals);
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
log(`Error converting USD amount: ${err.message}`);
|
|
1032
|
+
exit(1);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
} else if (amountUnit === 'token') {
|
|
895
1036
|
try {
|
|
896
1037
|
const tokenForDecimals = swapMode === 'exactOut' ? to : from;
|
|
897
|
-
|
|
898
|
-
resolvedAmount = convertToBaseUnits(amount,
|
|
1038
|
+
resolvedDecimals = await resolveTokenDecimals(tokenForDecimals, chain);
|
|
1039
|
+
resolvedAmount = convertToBaseUnits(amount, resolvedDecimals);
|
|
899
1040
|
} catch (err) {
|
|
900
1041
|
log(`Error resolving token decimals: ${err.message}`);
|
|
901
1042
|
exit(1);
|
|
@@ -955,7 +1096,42 @@ EXAMPLES:
|
|
|
955
1096
|
return;
|
|
956
1097
|
}
|
|
957
1098
|
|
|
958
|
-
|
|
1099
|
+
// Balance pre-check — catches zero balances and insufficient funds
|
|
1100
|
+
// before wasting a quote API call. Runs for --amount-unit token and
|
|
1101
|
+
// usd (after USD→token conversion) in exactIn mode. In exactOut the
|
|
1102
|
+
// amount is the buy amount so comparing against sell balance is meaningless.
|
|
1103
|
+
if ((amountUnit === 'token' || amountUnit === 'usd') && swapMode !== 'exactOut') {
|
|
1104
|
+
try {
|
|
1105
|
+
// For USD, pass the converted token-unit amount so validateBalance
|
|
1106
|
+
// can compare against the wallet balance in token units.
|
|
1107
|
+
const tokenUnitAmount = amountUnit === 'usd' ? usdTokenAmount : amount;
|
|
1108
|
+
const { adjustedAmount: balanceAdjusted } = await validateBalance({
|
|
1109
|
+
chain,
|
|
1110
|
+
from,
|
|
1111
|
+
amount: tokenUnitAmount,
|
|
1112
|
+
amountUnit: 'token',
|
|
1113
|
+
walletAddress,
|
|
1114
|
+
decimals: resolvedDecimals,
|
|
1115
|
+
symbol: fromRaw,
|
|
1116
|
+
});
|
|
1117
|
+
if (balanceAdjusted !== tokenUnitAmount) {
|
|
1118
|
+
resolvedAmount = convertToBaseUnits(balanceAdjusted, resolvedDecimals);
|
|
1119
|
+
}
|
|
1120
|
+
} catch (balanceErr) {
|
|
1121
|
+
log(`Error: ${balanceErr.message}`);
|
|
1122
|
+
exit(1);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const toChainConfig = toChainRaw ? resolveChain(toChainRaw) : null;
|
|
1128
|
+
const isCrossChain = toChainConfig && toChainConfig.index !== chainConfig.index;
|
|
1129
|
+
|
|
1130
|
+
if (isCrossChain) {
|
|
1131
|
+
log(`\nFetching cross-chain quote: ${chainConfig.name} → ${toChainConfig.name}...`);
|
|
1132
|
+
} else {
|
|
1133
|
+
log(`\nFetching quote on ${chainConfig.name}...`);
|
|
1134
|
+
}
|
|
959
1135
|
log(` Wallet: ${walletAddress}`);
|
|
960
1136
|
|
|
961
1137
|
const fromWarning = getWrappedNativeFromWarning(from, chain);
|
|
@@ -968,6 +1144,21 @@ EXAMPLES:
|
|
|
968
1144
|
amount: resolvedAmount,
|
|
969
1145
|
userWalletAddress: walletAddress,
|
|
970
1146
|
};
|
|
1147
|
+
if (isCrossChain) {
|
|
1148
|
+
params.toChainIndex = toChainConfig.index;
|
|
1149
|
+
if (toWallet) {
|
|
1150
|
+
params.toWalletAddress = toWallet;
|
|
1151
|
+
log(` Destination wallet: ${toWallet}`);
|
|
1152
|
+
} else if (chainConfig.type !== toChainConfig.type) {
|
|
1153
|
+
// Solana↔Base: auto-derive the destination address from the same wallet
|
|
1154
|
+
const effectiveWalletName = walletName || getWalletConfig()?.defaultWallet;
|
|
1155
|
+
if (effectiveWalletName) {
|
|
1156
|
+
const walletData = showWallet(effectiveWalletName);
|
|
1157
|
+
params.toWalletAddress = toChainConfig.type === 'solana' ? walletData.solana : walletData.evm;
|
|
1158
|
+
log(` Destination wallet: ${params.toWalletAddress}`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
971
1162
|
if (slippage) params.slippagePercent = slippage;
|
|
972
1163
|
if (autoSlippage) params.autoSlippage = true;
|
|
973
1164
|
if (maxAutoSlippage) params.maxAutoSlippagePercent = maxAutoSlippage;
|
|
@@ -988,7 +1179,7 @@ EXAMPLES:
|
|
|
988
1179
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
989
1180
|
|
|
990
1181
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
991
|
-
const quoteId = saveQuote(response, chain, signerType, privyWalletIds);
|
|
1182
|
+
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
|
|
992
1183
|
log(`\n Quote ID: ${quoteId}`);
|
|
993
1184
|
log(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
994
1185
|
if (response.quotes.length > 1) {
|
|
@@ -1492,6 +1683,23 @@ EXAMPLES:
|
|
|
1492
1683
|
log(` Tx Hash: ${wcResult.txHash}`);
|
|
1493
1684
|
log(` Chain: ${chainConfig.name}`);
|
|
1494
1685
|
log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
1686
|
+
|
|
1687
|
+
// Cross-chain: poll bridge status after source tx success
|
|
1688
|
+
if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
|
|
1689
|
+
log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
|
|
1690
|
+
try {
|
|
1691
|
+
const bridgeResult = await pollBridgeStatus(wcResult.txHash, quoteData.chain, quoteData.toChain, { log });
|
|
1692
|
+
log(`\n ✓ Bridge completed!`);
|
|
1693
|
+
if (bridgeResult.receiving?.txHash) {
|
|
1694
|
+
const toChainConfig = resolveChain(quoteData.toChain);
|
|
1695
|
+
log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
|
|
1696
|
+
}
|
|
1697
|
+
} catch (bridgeErr) {
|
|
1698
|
+
log(`\n Bridge status: ${bridgeErr.message}`);
|
|
1699
|
+
log(` Check later with: nansen trade bridge-status --tx-hash ${wcResult.txHash} --from-chain ${quoteData.chain} --to-chain ${quoteData.toChain}`);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1495
1703
|
log('');
|
|
1496
1704
|
return undefined; // Success
|
|
1497
1705
|
}
|
|
@@ -1678,6 +1886,23 @@ EXAMPLES:
|
|
|
1678
1886
|
log(` ${e.inputAmount} ${e.inputMint?.slice(0, 8)}... → ${e.outputAmount} ${e.outputMint?.slice(0, 8)}...`);
|
|
1679
1887
|
});
|
|
1680
1888
|
}
|
|
1889
|
+
|
|
1890
|
+
// Cross-chain: poll bridge status after source tx success
|
|
1891
|
+
if (quoteData.toChain && quoteData.toChain !== quoteData.chain) {
|
|
1892
|
+
log(`\n Cross-chain bridge in progress (${chainConfig.name} → ${resolveChain(quoteData.toChain).name})...`);
|
|
1893
|
+
try {
|
|
1894
|
+
const bridgeResult = await pollBridgeStatus(txId, quoteData.chain, quoteData.toChain, { log });
|
|
1895
|
+
log(`\n ✓ Bridge completed!`);
|
|
1896
|
+
if (bridgeResult.receiving?.txHash) {
|
|
1897
|
+
const toChainConfig = resolveChain(quoteData.toChain);
|
|
1898
|
+
log(` Destination tx: ${toChainConfig.explorer}${bridgeResult.receiving.txHash}`);
|
|
1899
|
+
}
|
|
1900
|
+
} catch (bridgeErr) {
|
|
1901
|
+
log(`\n Bridge status: ${bridgeErr.message}`);
|
|
1902
|
+
log(` Check later with: nansen trade bridge-status --tx-hash ${txId} --from-chain ${quoteData.chain} --to-chain ${quoteData.toChain}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1681
1906
|
log('');
|
|
1682
1907
|
return undefined; // Success — done
|
|
1683
1908
|
} else {
|
|
@@ -1688,8 +1913,12 @@ EXAMPLES:
|
|
|
1688
1913
|
}
|
|
1689
1914
|
|
|
1690
1915
|
} catch (quoteErr) {
|
|
1691
|
-
|
|
1692
|
-
|
|
1916
|
+
const msg = quoteErr.message || '';
|
|
1917
|
+
log(` ❌ Quote ${quoteName} failed: ${msg}`);
|
|
1918
|
+
if (msg.includes('AccountNotFound') && chainType === 'solana') {
|
|
1919
|
+
log(` Hint: Your Solana wallet may not have enough SOL to cover transaction fees (~0.005 SOL minimum).`);
|
|
1920
|
+
}
|
|
1921
|
+
lastQuoteError = `${quoteName}: ${msg}`;
|
|
1693
1922
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1694
1923
|
}
|
|
1695
1924
|
}
|
|
@@ -1706,5 +1935,55 @@ EXAMPLES:
|
|
|
1706
1935
|
exit(1);
|
|
1707
1936
|
}
|
|
1708
1937
|
},
|
|
1938
|
+
|
|
1939
|
+
'bridge-status': async (args, _apiInstance, _flags, options) => {
|
|
1940
|
+
const txHash = options['tx-hash'] || args[0];
|
|
1941
|
+
const fromChain = options['from-chain'] || args[1];
|
|
1942
|
+
const toChain = options['to-chain'] || args[2];
|
|
1943
|
+
|
|
1944
|
+
if (!txHash || !fromChain || !toChain) {
|
|
1945
|
+
log(`
|
|
1946
|
+
Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1947
|
+
|
|
1948
|
+
Check the status of a cross-chain bridge transaction.
|
|
1949
|
+
|
|
1950
|
+
OPTIONS:
|
|
1951
|
+
--tx-hash <hash> Source chain transaction hash
|
|
1952
|
+
--from-chain <chain> Source chain (solana or base)
|
|
1953
|
+
--to-chain <chain> Destination chain (solana or base)
|
|
1954
|
+
|
|
1955
|
+
EXAMPLES:
|
|
1956
|
+
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
|
|
1957
|
+
`);
|
|
1958
|
+
exit(1);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
try {
|
|
1963
|
+
const status = await getBridgeStatus(txHash, fromChain, toChain);
|
|
1964
|
+
log(`\nBridge Status: ${status.status || 'unknown'}`);
|
|
1965
|
+
if (status.substatus) log(` Substatus: ${status.substatus}`);
|
|
1966
|
+
if (status.substatusMessage) log(` Message: ${status.substatusMessage}`);
|
|
1967
|
+
if (status.tool) log(` Bridge: ${status.tool}`);
|
|
1968
|
+
if (status.sending?.txHash) {
|
|
1969
|
+
log(` Sending:`);
|
|
1970
|
+
log(` Tx: ${status.sending.txHash}`);
|
|
1971
|
+
if (status.sending.amount) log(` Amount: ${status.sending.amount}`);
|
|
1972
|
+
if (status.sending.txLink) log(` Explorer: ${status.sending.txLink}`);
|
|
1973
|
+
}
|
|
1974
|
+
if (status.receiving?.txHash) {
|
|
1975
|
+
log(` Receiving:`);
|
|
1976
|
+
log(` Tx: ${status.receiving.txHash}`);
|
|
1977
|
+
if (status.receiving.amount) log(` Amount: ${status.receiving.amount}`);
|
|
1978
|
+
if (status.receiving.txLink) log(` Explorer: ${status.receiving.txLink}`);
|
|
1979
|
+
}
|
|
1980
|
+
if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
|
|
1981
|
+
log('');
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
log(`Error: ${err.message}`);
|
|
1984
|
+
if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
|
|
1985
|
+
exit(1);
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1709
1988
|
};
|
|
1710
1989
|
}
|
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,8 @@
|
|
|
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';
|
|
8
|
+
import { encodeCompactU16 } from './transfer.js';
|
|
14
9
|
|
|
15
10
|
// ============= Constants =============
|
|
16
11
|
|
|
@@ -24,41 +19,6 @@ const _SYSTEM_PROGRAM = '11111111111111111111111111111111';
|
|
|
24
19
|
const DEFAULT_COMPUTE_UNIT_LIMIT = 20000;
|
|
25
20
|
const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
|
|
26
21
|
|
|
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
|
-
// ============= Compact-u16 Encoding =============
|
|
47
|
-
// (Solana's variable-length integer format, from trading.js pattern)
|
|
48
|
-
|
|
49
|
-
export function encodeCompactU16(value) {
|
|
50
|
-
if (value < 0x80) return Buffer.from([value]);
|
|
51
|
-
if (value < 0x4000) return Buffer.from([
|
|
52
|
-
(value & 0x7f) | 0x80,
|
|
53
|
-
(value >> 7) & 0x7f,
|
|
54
|
-
]);
|
|
55
|
-
return Buffer.from([
|
|
56
|
-
(value & 0x7f) | 0x80,
|
|
57
|
-
((value >> 7) & 0x7f) | 0x80,
|
|
58
|
-
(value >> 14) & 0x03,
|
|
59
|
-
]);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
22
|
// ============= PDA Derivation =============
|
|
63
23
|
|
|
64
24
|
/**
|