nansen-cli 1.25.1 → 1.26.1
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 +18 -0
- package/package.json +1 -1
- package/src/api.js +24 -1
- package/src/cli.js +30 -31
- package/src/schema.json +2 -2
- package/src/telemetry.js +8 -6
- package/src/trade-validation.js +86 -0
- package/src/trading.js +81 -89
- package/src/transfer.js +1 -2
- package/src/wallet.js +54 -101
- package/src/x402-svm.js +3 -75
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.26.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#392](https://github.com/nansen-ai/nansen-cli/pull/392) [`025993d`](https://github.com/nansen-ai/nansen-cli/commit/025993df798ddb406340511e0dada1f9a962be56) Thanks [@TimNooren](https://github.com/TimNooren)! - Add gas balance validation: rejects trades when the wallet lacks sufficient native token for gas fees.
|
|
8
|
+
|
|
9
|
+
## 1.26.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#380](https://github.com/nansen-ai/nansen-cli/pull/380) [`12e4e25`](https://github.com/nansen-ai/nansen-cli/commit/12e4e25d50f50ff1ebbae160ba1016abd1cdbb4d) Thanks [@TimNooren](https://github.com/TimNooren)! - Add `--amount-unit percent` to trade commands, allowing trades as a percentage of wallet balance (e.g. `--amount 100 --amount-unit percent` to sell all)
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#382](https://github.com/nansen-ai/nansen-cli/pull/382) [`d9c87ef`](https://github.com/nansen-ai/nansen-cli/commit/d9c87ef9df51a3e9c53ea59674ad9efe9aa33fb7) Thanks [@kome12](https://github.com/kome12)! - fix: default `profiler balance` chain to `'all'` instead of `'ethereum'`
|
|
18
|
+
|
|
19
|
+
Previously, `nansen profiler balance --address <addr>` without `--chain` defaulted to `ethereum`, returning empty results for wallets with no ETH mainnet holdings (e.g. Base-only or Solana-only wallets). Now defaults to `'all'`, letting the API auto-route based on address format.
|
|
20
|
+
|
|
3
21
|
## 1.25.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -60,6 +60,29 @@ export const ErrorCode = {
|
|
|
60
60
|
UNKNOWN: 'UNKNOWN', // Unclassified error
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Error thrown by command handlers for user-facing failures (validation errors,
|
|
65
|
+
* missing args, etc.).
|
|
66
|
+
*
|
|
67
|
+
* Handlers must throw rather than calling log() + exit() directly, because
|
|
68
|
+
* direct exits bypass runCLI's catch block and skip telemetry tracking.
|
|
69
|
+
*
|
|
70
|
+
* runCLI outputs CommandError.message as plain text (not JSON-formatted like
|
|
71
|
+
* NansenError), then fires trackCommandFailed before exiting.
|
|
72
|
+
*
|
|
73
|
+
* When `data` is provided, runCLI outputs JSON.stringify(data) instead of the
|
|
74
|
+
* plain message — this preserves structured JSON output for errors that agents
|
|
75
|
+
* parse (e.g. PASSWORD_REQUIRED, API_KEY_REQUIRED).
|
|
76
|
+
*/
|
|
77
|
+
export class CommandError extends Error {
|
|
78
|
+
constructor(message, code = 'COMMAND_ERROR', data = null) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'CommandError';
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.data = data;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
/**
|
|
64
87
|
* Custom error class with structured error codes
|
|
65
88
|
*/
|
|
@@ -766,7 +789,7 @@ export class NansenAPI {
|
|
|
766
789
|
// ============= Profiler Endpoints =============
|
|
767
790
|
|
|
768
791
|
async addressBalance(params = {}) {
|
|
769
|
-
const { address, entityName, chain = '
|
|
792
|
+
const { address, entityName, chain = 'all', hideSpamToken = true, filters = {}, orderBy } = params;
|
|
770
793
|
if (address) {
|
|
771
794
|
const validation = validateAddress(address, chain);
|
|
772
795
|
if (!validation.valid) throw new NansenError(validation.error, validation.code);
|
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Extracted from index.js for coverage
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
|
|
6
|
+
import { NansenAPI, NansenError, CommandError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
|
|
7
7
|
import { buildWalletCommands } from './wallet.js';
|
|
8
8
|
import { buildTradingCommands } from './trading.js';
|
|
9
9
|
import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
|
|
@@ -793,7 +793,6 @@ export function buildCommands(deps = {}) {
|
|
|
793
793
|
saveConfigFn = saveConfig,
|
|
794
794
|
deleteConfigFn = deleteConfig,
|
|
795
795
|
getConfigFileFn = getConfigFile,
|
|
796
|
-
exit = process.exit,
|
|
797
796
|
isTTY = process.stdin.isTTY
|
|
798
797
|
} = deps;
|
|
799
798
|
|
|
@@ -895,12 +894,10 @@ export function buildCommands(deps = {}) {
|
|
|
895
894
|
|
|
896
895
|
if (!apiKey && flags.human) {
|
|
897
896
|
if (!isTTY) {
|
|
898
|
-
|
|
897
|
+
throw new CommandError('--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.', 'NOT_A_TTY', {
|
|
899
898
|
error: 'NOT_A_TTY',
|
|
900
899
|
message: '--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.',
|
|
901
|
-
})
|
|
902
|
-
exit(1);
|
|
903
|
-
return;
|
|
900
|
+
});
|
|
904
901
|
}
|
|
905
902
|
log('Nansen CLI Login\n');
|
|
906
903
|
log('Get your API key at: https://app.nansen.ai/auth/agent-setup\n');
|
|
@@ -908,7 +905,7 @@ export function buildCommands(deps = {}) {
|
|
|
908
905
|
}
|
|
909
906
|
|
|
910
907
|
if (!apiKey || apiKey.trim().length === 0) {
|
|
911
|
-
|
|
908
|
+
throw new CommandError('No API key provided.', 'API_KEY_REQUIRED', {
|
|
912
909
|
error: 'API_KEY_REQUIRED',
|
|
913
910
|
message: 'No API key provided.',
|
|
914
911
|
resolution: [
|
|
@@ -916,9 +913,7 @@ export function buildCommands(deps = {}) {
|
|
|
916
913
|
'Or set NANSEN_API_KEY environment variable',
|
|
917
914
|
'Get your API key at: https://app.nansen.ai/auth/agent-setup',
|
|
918
915
|
],
|
|
919
|
-
})
|
|
920
|
-
exit(1);
|
|
921
|
-
return;
|
|
916
|
+
});
|
|
922
917
|
}
|
|
923
918
|
|
|
924
919
|
// Verify API key before saving
|
|
@@ -933,20 +928,17 @@ export function buildCommands(deps = {}) {
|
|
|
933
928
|
accountInfo = await testApi.getAccount();
|
|
934
929
|
} catch (error) {
|
|
935
930
|
if (error.code === ErrorCode.UNAUTHORIZED) {
|
|
936
|
-
|
|
931
|
+
throw new CommandError('The API key is not valid.', 'INVALID_API_KEY', {
|
|
937
932
|
error: 'INVALID_API_KEY',
|
|
938
933
|
message: 'The API key is not valid.',
|
|
939
|
-
resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup']
|
|
940
|
-
})
|
|
941
|
-
} else {
|
|
942
|
-
log(JSON.stringify({
|
|
943
|
-
error: 'VERIFICATION_FAILED',
|
|
944
|
-
message: `Could not verify API key: ${error.message}`,
|
|
945
|
-
resolution: ['Check your internet connection', 'Try again']
|
|
946
|
-
}));
|
|
934
|
+
resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'],
|
|
935
|
+
});
|
|
947
936
|
}
|
|
948
|
-
|
|
949
|
-
|
|
937
|
+
throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', {
|
|
938
|
+
error: 'VERIFICATION_FAILED',
|
|
939
|
+
message: `Could not verify API key: ${error.message}`,
|
|
940
|
+
resolution: ['Check your internet connection', 'Try again'],
|
|
941
|
+
});
|
|
950
942
|
}
|
|
951
943
|
|
|
952
944
|
// Key is valid - now save
|
|
@@ -1110,7 +1102,7 @@ export function buildCommands(deps = {}) {
|
|
|
1110
1102
|
const subcommand = args[0] || 'help';
|
|
1111
1103
|
let address = options.address;
|
|
1112
1104
|
const entityName = options.entity || options['entity-name'];
|
|
1113
|
-
const chain = options.chain || '
|
|
1105
|
+
const chain = options.chain || 'all';
|
|
1114
1106
|
|
|
1115
1107
|
// Resolve ENS names (e.g. vitalik.eth → 0x...)
|
|
1116
1108
|
let ensName;
|
|
@@ -1732,7 +1724,7 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1732
1724
|
};
|
|
1733
1725
|
const formatted = formatOutput(errorData, { pretty, table });
|
|
1734
1726
|
output(formatted.text);
|
|
1735
|
-
trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
|
|
1727
|
+
await trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
|
|
1736
1728
|
exit(1);
|
|
1737
1729
|
return { type: 'error', data: errorData };
|
|
1738
1730
|
}
|
|
@@ -1759,7 +1751,7 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1759
1751
|
|
|
1760
1752
|
// Commands that handle their own output return undefined
|
|
1761
1753
|
if (result === undefined) {
|
|
1762
|
-
trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
1754
|
+
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
1763
1755
|
return { type: 'no-output', command };
|
|
1764
1756
|
}
|
|
1765
1757
|
|
|
@@ -1767,7 +1759,7 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1767
1759
|
if (command === 'schema') {
|
|
1768
1760
|
const formatted = formatOutput(result, { pretty, table: false });
|
|
1769
1761
|
output(formatted.text);
|
|
1770
|
-
trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
1762
|
+
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
1771
1763
|
return { type: 'schema', data: result };
|
|
1772
1764
|
}
|
|
1773
1765
|
|
|
@@ -1780,6 +1772,7 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1780
1772
|
// Alerts list with --table uses custom table format
|
|
1781
1773
|
if (command === 'alerts' && subcommand === 'list' && table) {
|
|
1782
1774
|
output(formatAlertsTable(result));
|
|
1775
|
+
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
|
1783
1776
|
return { type: 'success', data: result };
|
|
1784
1777
|
}
|
|
1785
1778
|
|
|
@@ -1790,20 +1783,26 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1790
1783
|
if (streamOutput) {
|
|
1791
1784
|
output(streamOutput);
|
|
1792
1785
|
}
|
|
1793
|
-
trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
|
|
1786
|
+
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
|
|
1794
1787
|
return { type: 'stream', data: result };
|
|
1795
1788
|
}
|
|
1796
1789
|
|
|
1797
1790
|
const successData = { success: true, data: result };
|
|
1798
1791
|
const formatted = formatOutput(successData, { pretty, table, csv });
|
|
1799
1792
|
output(formatted.text);
|
|
1800
|
-
trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
|
|
1793
|
+
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
|
|
1801
1794
|
return { type: csv ? 'csv' : 'success', data: result };
|
|
1802
1795
|
} catch (error) {
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1796
|
+
let errorData;
|
|
1797
|
+
if (error instanceof CommandError) {
|
|
1798
|
+
output(error.data ? JSON.stringify(error.data) : error.message);
|
|
1799
|
+
errorData = { error: error.message, code: error.code };
|
|
1800
|
+
} else {
|
|
1801
|
+
errorData = formatError(error);
|
|
1802
|
+
const formatted = formatOutput(errorData, { pretty, table, csv });
|
|
1803
|
+
output(formatted.text);
|
|
1804
|
+
}
|
|
1805
|
+
await trackCommandFailed({
|
|
1807
1806
|
command: fullCommand,
|
|
1808
1807
|
duration_ms: Date.now() - startTime,
|
|
1809
1808
|
error_code: error.code || 'UNKNOWN',
|
package/src/schema.json
CHANGED
|
@@ -821,11 +821,11 @@
|
|
|
821
821
|
"amount": {
|
|
822
822
|
"type": "string",
|
|
823
823
|
"required": true,
|
|
824
|
-
"description": "Amount to swap (base units by default, or token units with --amount-unit token, or
|
|
824
|
+
"description": "Amount to swap (base units by default, or token units with --amount-unit token, USD with --amount-unit usd, or percentage of balance with --amount-unit percent)"
|
|
825
825
|
},
|
|
826
826
|
"amount-unit": {
|
|
827
827
|
"type": "string",
|
|
828
|
-
"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
|
|
828
|
+
"description": "\"token\" to specify amount in token units (e.g. 0.5 SOL), \"usd\" to specify amount in USD (e.g. 50), \"percent\" to sell a percentage of your balance (e.g. 100 for all), or \"base\" for base units (default). The CLI resolves the current token price, decimals, and balance locally; the API always receives base units."
|
|
829
829
|
},
|
|
830
830
|
"wallet": {
|
|
831
831
|
"type": "string",
|
package/src/telemetry.js
CHANGED
|
@@ -22,7 +22,7 @@ const { version: cliVersion } = JSON.parse(
|
|
|
22
22
|
const TELEMETRY_URL =
|
|
23
23
|
'https://bi-data-sources.nansen.ai/events-service-68ifmnpsx2uq7cgab8dw/v2/event';
|
|
24
24
|
|
|
25
|
-
const TIMEOUT_MS =
|
|
25
|
+
const TIMEOUT_MS = 1000;
|
|
26
26
|
|
|
27
27
|
// ─── opt-out ──────────────────────────────────────────────
|
|
28
28
|
|
|
@@ -122,15 +122,17 @@ export function getSessionId() {
|
|
|
122
122
|
// ─── send ──────────────────────────────────────────────────
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Send a telemetry event.
|
|
125
|
+
* Send a telemetry event. Returns a promise that resolves when the request
|
|
126
|
+
* completes (or fails/times out). Never rejects — errors are swallowed.
|
|
127
|
+
* Callers that need to ensure delivery before process.exit can await this.
|
|
126
128
|
*/
|
|
127
129
|
function sendEvent(event) {
|
|
128
|
-
if (TELEMETRY_DISABLED) return;
|
|
130
|
+
if (TELEMETRY_DISABLED) return Promise.resolve();
|
|
129
131
|
const controller = new AbortController();
|
|
130
132
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
131
133
|
timer.unref();
|
|
132
134
|
|
|
133
|
-
fetch(TELEMETRY_URL, {
|
|
135
|
+
return fetch(TELEMETRY_URL, {
|
|
134
136
|
method: 'POST',
|
|
135
137
|
headers: { 'Content-Type': 'application/json' },
|
|
136
138
|
body: JSON.stringify(event),
|
|
@@ -178,7 +180,7 @@ export function trackCommandSucceeded({
|
|
|
178
180
|
flags = [],
|
|
179
181
|
chain = null,
|
|
180
182
|
}) {
|
|
181
|
-
sendEvent({
|
|
183
|
+
return sendEvent({
|
|
182
184
|
event: 'cli_command_succeeded',
|
|
183
185
|
event_source: getEventSource(),
|
|
184
186
|
event_id: crypto.randomUUID(),
|
|
@@ -216,7 +218,7 @@ export function trackCommandFailed({
|
|
|
216
218
|
flags = [],
|
|
217
219
|
chain = null,
|
|
218
220
|
}) {
|
|
219
|
-
sendEvent({
|
|
221
|
+
return sendEvent({
|
|
220
222
|
event: 'cli_command_failed',
|
|
221
223
|
event_source: getEventSource(),
|
|
222
224
|
event_id: crypto.randomUUID(),
|
package/src/trade-validation.js
CHANGED
|
@@ -115,6 +115,7 @@ const NATIVE_TOKEN_ADDRESSES = {
|
|
|
115
115
|
// Native token symbols for error messages.
|
|
116
116
|
const NATIVE_SYMBOLS = { solana: 'SOL', base: 'ETH' };
|
|
117
117
|
|
|
118
|
+
const MIN_GAS_AMOUNTS = { solana: 0.01, base: 0.000024 };
|
|
118
119
|
const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
|
|
119
120
|
const HIGH_PERCENTAGE_THRESHOLD = 95;
|
|
120
121
|
const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
|
|
@@ -210,6 +211,91 @@ export async function validateBalance({ chain, from, amount, amountUnit, walletA
|
|
|
210
211
|
return { adjustedAmount: amount };
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a percentage amount to a token-unit amount string.
|
|
216
|
+
* Fetches the wallet's balance of the sell token, calculates the percentage,
|
|
217
|
+
* and applies a native-token fee buffer when selling >=95%.
|
|
218
|
+
*
|
|
219
|
+
* Returns the amount in human-readable token units (e.g. "1.5"),
|
|
220
|
+
* ready for convertToBaseUnits().
|
|
221
|
+
*/
|
|
222
|
+
export async function resolvePercentAmount({ chain, from, walletAddress, percentage, decimals }) {
|
|
223
|
+
if (!Number.isFinite(percentage) || percentage <= 0 || percentage > 100) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
percentage > 100
|
|
226
|
+
? `Cannot sell more than 100% of balance. Got: ${percentage}%`
|
|
227
|
+
: `Percentage must be between 0 and 100. Got: ${percentage}%`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const normalizedChain = chain.toLowerCase();
|
|
232
|
+
const isNative = isNativeAddress(from, normalizedChain);
|
|
233
|
+
|
|
234
|
+
let balance;
|
|
235
|
+
if (isNative) {
|
|
236
|
+
balance = await fetchNativeBalance(normalizedChain, walletAddress);
|
|
237
|
+
} else {
|
|
238
|
+
balance = await fetchTokenBalance(normalizedChain, from, walletAddress, decimals);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (balance === null) {
|
|
242
|
+
throw new Error(`Could not fetch balance for ${from} on ${normalizedChain}. Check your RPC connection.`);
|
|
243
|
+
}
|
|
244
|
+
if (balance === 0) {
|
|
245
|
+
const symbol = isNative ? (NATIVE_SYMBOLS[normalizedChain] || from) : from;
|
|
246
|
+
throw new Error(`No ${symbol} balance in wallet. You cannot trade a token you don't own.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Calculate token amount from percentage.
|
|
250
|
+
// Use exact balance for 100% to avoid floating-point precision loss.
|
|
251
|
+
let tokenAmount = percentage === 100 ? balance : balance * (percentage / 100);
|
|
252
|
+
|
|
253
|
+
// Native token fee buffer: when selling >=95%, cap at balance - reserve.
|
|
254
|
+
if (isNative && percentage >= HIGH_PERCENTAGE_THRESHOLD) {
|
|
255
|
+
const reserve = FEE_BUFFER[normalizedChain] || 0;
|
|
256
|
+
const maxSellable = parseFloat((balance - reserve).toFixed(NATIVE_DECIMALS[normalizedChain]));
|
|
257
|
+
if (maxSellable <= 0) {
|
|
258
|
+
const symbol = NATIVE_SYMBOLS[normalizedChain] || from;
|
|
259
|
+
throw new Error(`Insufficient ${symbol} balance after reserving gas fees.`);
|
|
260
|
+
}
|
|
261
|
+
if (tokenAmount > maxSellable) {
|
|
262
|
+
const symbol = NATIVE_SYMBOLS[normalizedChain] || from;
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`Warning: Reserving ${reserve} ${symbol} for gas. Adjusted sell amount to ${maxSellable} ${symbol}.\n`
|
|
265
|
+
);
|
|
266
|
+
tokenAmount = maxSellable;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return String(parseFloat(tokenAmount.toFixed(decimals)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Validate that the wallet has enough native token for gas fees.
|
|
275
|
+
*
|
|
276
|
+
* Returns { hasSufficientNative } or throws on validation failure.
|
|
277
|
+
* Best-effort: if RPC fails, returns passing result.
|
|
278
|
+
*/
|
|
279
|
+
export async function validateGasBalance({ chain, walletAddress }) {
|
|
280
|
+
const normalizedChain = chain.toLowerCase();
|
|
281
|
+
const minGas = MIN_GAS_AMOUNTS[normalizedChain];
|
|
282
|
+
if (minGas === undefined) return { hasSufficientNative: true };
|
|
283
|
+
|
|
284
|
+
const balance = await fetchNativeBalance(normalizedChain, walletAddress);
|
|
285
|
+
|
|
286
|
+
// RPC failure — proceed without validation.
|
|
287
|
+
if (balance === null) return { hasSufficientNative: true };
|
|
288
|
+
|
|
289
|
+
if (balance >= minGas) {
|
|
290
|
+
return { hasSufficientNative: true };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const symbol = NATIVE_SYMBOLS[normalizedChain] || 'native token';
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Fund the wallet before trading.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
213
299
|
/**
|
|
214
300
|
* Fetch an ERC-20 or SPL token balance for a wallet.
|
|
215
301
|
* Returns balance in human-readable token units, or null on RPC failure.
|
package/src/trading.js
CHANGED
|
@@ -13,12 +13,14 @@ 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
|
+
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance } from './trade-validation.js';
|
|
17
17
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
18
|
+
import { packageVersion, CommandError } from './api.js';
|
|
18
19
|
|
|
19
20
|
// ============= Constants =============
|
|
20
21
|
|
|
21
22
|
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
23
|
+
const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`;
|
|
22
24
|
|
|
23
25
|
const CHAIN_MAP = {
|
|
24
26
|
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
|
|
@@ -115,7 +117,7 @@ export async function getQuote(params) {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
const headers = { 'Accept': 'application/json' };
|
|
120
|
+
const headers = { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT };
|
|
119
121
|
|
|
120
122
|
const res = await fetch(url.toString(), { headers });
|
|
121
123
|
|
|
@@ -153,6 +155,7 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
153
155
|
const headers = {
|
|
154
156
|
'Content-Type': 'application/json',
|
|
155
157
|
'Accept': 'application/json',
|
|
158
|
+
'User-Agent': CLIENT_USER_AGENT,
|
|
156
159
|
};
|
|
157
160
|
let lastError;
|
|
158
161
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -216,7 +219,7 @@ export async function getBridgeStatus(txHash, fromChain, toChain) {
|
|
|
216
219
|
url.searchParams.set('fromChain', fromConfig.lifiChainId || fromConfig.index);
|
|
217
220
|
url.searchParams.set('toChain', toConfig.lifiChainId || toConfig.index);
|
|
218
221
|
|
|
219
|
-
const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
|
|
222
|
+
const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT } });
|
|
220
223
|
const text = await res.text();
|
|
221
224
|
let body;
|
|
222
225
|
try {
|
|
@@ -940,7 +943,7 @@ export function formatQuote(quote, index) {
|
|
|
940
943
|
* Build trading command handlers for CLI integration.
|
|
941
944
|
*/
|
|
942
945
|
export function buildTradingCommands(deps = {}) {
|
|
943
|
-
const { log = console.log
|
|
946
|
+
const { log = console.log } = deps;
|
|
944
947
|
|
|
945
948
|
return {
|
|
946
949
|
'quote': async (args, apiInstance, flags, options) => {
|
|
@@ -961,7 +964,7 @@ export function buildTradingCommands(deps = {}) {
|
|
|
961
964
|
const amountUnit = options['amount-unit'];
|
|
962
965
|
|
|
963
966
|
if (!chain || !from || !to || !amount) {
|
|
964
|
-
|
|
967
|
+
throw new CommandError(`
|
|
965
968
|
Usage: nansen trade quote --chain <chain> --from <token> --to <token> --amount <baseUnits>
|
|
966
969
|
|
|
967
970
|
PREREQUISITE:
|
|
@@ -975,7 +978,7 @@ OPTIONS:
|
|
|
975
978
|
--from <symbol|address> Input token (symbol like SOL, USDC or address)
|
|
976
979
|
--to <symbol|address> Output token (symbol like USDC, ETH or address)
|
|
977
980
|
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
|
|
978
|
-
--amount-unit <unit> "token" for token units
|
|
981
|
+
--amount-unit <unit> "token" for token units, "usd" for USD, "percent" for % of balance
|
|
979
982
|
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
|
|
980
983
|
--to-wallet <address> Destination wallet address (auto-derived for cross-chain if omitted)
|
|
981
984
|
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
|
|
@@ -987,19 +990,21 @@ EXAMPLES:
|
|
|
987
990
|
nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
988
991
|
nansen trade quote --chain solana --from SOL --to USDC --amount 0.5 --amount-unit token
|
|
989
992
|
nansen trade quote --chain solana --from SOL --to USDC --amount 50 --amount-unit usd
|
|
993
|
+
nansen trade quote --chain solana --from SOL --to USDC --amount 100 --amount-unit percent
|
|
990
994
|
nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
991
995
|
nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
|
|
992
996
|
nansen trade quote --chain solana --to-chain base --from SOL --to ETH --amount 1000000000
|
|
993
|
-
|
|
994
|
-
exit(1);
|
|
995
|
-
return;
|
|
997
|
+
`, 'MISSING_ARGS');
|
|
996
998
|
}
|
|
997
999
|
|
|
998
1000
|
// Validate --amount-unit if provided
|
|
999
|
-
if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd') {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1001
|
+
if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd' && amountUnit !== 'percent') {
|
|
1002
|
+
throw new CommandError(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd, percent`, 'INVALID_INPUT');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// --amount-unit percent is only valid for exactIn (sell-side)
|
|
1006
|
+
if (amountUnit === 'percent' && swapMode === 'exactOut') {
|
|
1007
|
+
throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
|
|
1003
1008
|
}
|
|
1004
1009
|
|
|
1005
1010
|
// Static input validation — catches common agent errors (wrong addresses,
|
|
@@ -1007,9 +1012,7 @@ EXAMPLES:
|
|
|
1007
1012
|
try {
|
|
1008
1013
|
validateQuoteInput({ chain, toChain: toChainRaw || null, from, to, amount });
|
|
1009
1014
|
} catch (validationErr) {
|
|
1010
|
-
|
|
1011
|
-
exit(1);
|
|
1012
|
-
return;
|
|
1015
|
+
throw new CommandError(`Error: ${validationErr.message}`, 'INVALID_INPUT');
|
|
1013
1016
|
}
|
|
1014
1017
|
|
|
1015
1018
|
// When --amount-unit token is used, resolve decimals and convert to base units.
|
|
@@ -1028,9 +1031,7 @@ EXAMPLES:
|
|
|
1028
1031
|
usdTokenAmount = tokenAmount.toFixed(resolvedDecimals);
|
|
1029
1032
|
resolvedAmount = convertToBaseUnits(usdTokenAmount, resolvedDecimals);
|
|
1030
1033
|
} catch (err) {
|
|
1031
|
-
|
|
1032
|
-
exit(1);
|
|
1033
|
-
return;
|
|
1034
|
+
throw new CommandError(`Error converting USD amount: ${err.message}`, 'INVALID_INPUT');
|
|
1034
1035
|
}
|
|
1035
1036
|
} else if (amountUnit === 'token') {
|
|
1036
1037
|
try {
|
|
@@ -1038,16 +1039,14 @@ EXAMPLES:
|
|
|
1038
1039
|
resolvedDecimals = await resolveTokenDecimals(tokenForDecimals, chain);
|
|
1039
1040
|
resolvedAmount = convertToBaseUnits(amount, resolvedDecimals);
|
|
1040
1041
|
} catch (err) {
|
|
1041
|
-
|
|
1042
|
-
exit(1);
|
|
1043
|
-
return;
|
|
1042
|
+
throw new CommandError(`Error resolving token decimals: ${err.message}`, 'INVALID_INPUT');
|
|
1044
1043
|
}
|
|
1044
|
+
} else if (amountUnit === 'percent') {
|
|
1045
|
+
// Resolved after wallet address is available — see percent resolution block below.
|
|
1045
1046
|
} else {
|
|
1046
1047
|
const amountError = validateBaseUnitAmount(amount);
|
|
1047
1048
|
if (amountError) {
|
|
1048
|
-
|
|
1049
|
-
exit(1);
|
|
1050
|
-
return;
|
|
1049
|
+
throw new CommandError(`Error: ${amountError}`, 'INVALID_INPUT');
|
|
1051
1050
|
}
|
|
1052
1051
|
}
|
|
1053
1052
|
|
|
@@ -1063,9 +1062,7 @@ EXAMPLES:
|
|
|
1063
1062
|
if (isWalletConnect) {
|
|
1064
1063
|
walletAddress = await getWalletConnectAddress(chainType);
|
|
1065
1064
|
if (!walletAddress) {
|
|
1066
|
-
|
|
1067
|
-
exit(1);
|
|
1068
|
-
return;
|
|
1065
|
+
throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
|
|
1069
1066
|
}
|
|
1070
1067
|
} else if (walletName) {
|
|
1071
1068
|
const wallet = showWallet(walletName);
|
|
@@ -1091,9 +1088,25 @@ EXAMPLES:
|
|
|
1091
1088
|
}
|
|
1092
1089
|
|
|
1093
1090
|
if (!walletAddress) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1091
|
+
throw new CommandError('No wallet found. A wallet address is required for quotes because the trading API builds a transaction specific to the sender.\nCreate one with: nansen wallet create', 'NO_WALLET');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// --amount-unit percent: fetch balance, calculate percentage, convert to base units.
|
|
1095
|
+
// Placed after wallet resolution because we need the wallet address to fetch balance.
|
|
1096
|
+
if (amountUnit === 'percent') {
|
|
1097
|
+
try {
|
|
1098
|
+
resolvedDecimals = await resolveTokenDecimals(from, chain);
|
|
1099
|
+
const tokenAmount = await resolvePercentAmount({
|
|
1100
|
+
chain,
|
|
1101
|
+
from,
|
|
1102
|
+
walletAddress,
|
|
1103
|
+
percentage: parseFloat(amount),
|
|
1104
|
+
decimals: resolvedDecimals,
|
|
1105
|
+
});
|
|
1106
|
+
resolvedAmount = convertToBaseUnits(tokenAmount, resolvedDecimals);
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
throw new CommandError(`Error: ${err.message}`, 'INVALID_INPUT');
|
|
1109
|
+
}
|
|
1097
1110
|
}
|
|
1098
1111
|
|
|
1099
1112
|
// Balance pre-check — catches zero balances and insufficient funds
|
|
@@ -1118,9 +1131,7 @@ EXAMPLES:
|
|
|
1118
1131
|
resolvedAmount = convertToBaseUnits(balanceAdjusted, resolvedDecimals);
|
|
1119
1132
|
}
|
|
1120
1133
|
} catch (balanceErr) {
|
|
1121
|
-
|
|
1122
|
-
exit(1);
|
|
1123
|
-
return;
|
|
1134
|
+
throw new CommandError(`Error: ${balanceErr.message}`, 'INSUFFICIENT_BALANCE');
|
|
1124
1135
|
}
|
|
1125
1136
|
}
|
|
1126
1137
|
|
|
@@ -1167,17 +1178,23 @@ EXAMPLES:
|
|
|
1167
1178
|
const response = await getQuote(params);
|
|
1168
1179
|
|
|
1169
1180
|
if (!response.success || !response.quotes?.length) {
|
|
1170
|
-
|
|
1181
|
+
let msg = 'No quotes available';
|
|
1171
1182
|
if (response.warnings?.length) {
|
|
1172
|
-
response.warnings.
|
|
1183
|
+
msg += '\n' + response.warnings.map(w => ` Warning: ${w}`).join('\n');
|
|
1173
1184
|
}
|
|
1174
|
-
|
|
1175
|
-
return;
|
|
1185
|
+
throw new CommandError(msg, 'NO_QUOTES');
|
|
1176
1186
|
}
|
|
1177
1187
|
|
|
1178
1188
|
log('');
|
|
1179
1189
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
1180
1190
|
|
|
1191
|
+
// Gas balance validation — check that the wallet has enough native token for gas.
|
|
1192
|
+
try {
|
|
1193
|
+
await validateGasBalance({ chain, walletAddress });
|
|
1194
|
+
} catch (gasErr) {
|
|
1195
|
+
throw new CommandError(`Error: ${gasErr.message}`, 'INSUFFICIENT_GAS');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1181
1198
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
1182
1199
|
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
|
|
1183
1200
|
log(`\n Quote ID: ${quoteId}`);
|
|
@@ -1195,13 +1212,14 @@ EXAMPLES:
|
|
|
1195
1212
|
return undefined; // Output already printed above
|
|
1196
1213
|
|
|
1197
1214
|
} catch (err) {
|
|
1215
|
+
if (err instanceof CommandError) throw err;
|
|
1198
1216
|
let message = err.message;
|
|
1199
1217
|
if (err.code === 'INVALID_AMOUNT' || /amount/i.test(err.message)) {
|
|
1200
1218
|
message += '. Amounts must be in base units (e.g., 1000000000 lamports for 1 SOL, 1000000000000000000 wei for 1 ETH)';
|
|
1201
1219
|
}
|
|
1202
|
-
|
|
1203
|
-
if (err.details)
|
|
1204
|
-
|
|
1220
|
+
let msg = `Error: ${message}`;
|
|
1221
|
+
if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
|
|
1222
|
+
throw new CommandError(msg, err.code || 'QUOTE_ERROR');
|
|
1205
1223
|
}
|
|
1206
1224
|
},
|
|
1207
1225
|
|
|
@@ -1211,8 +1229,7 @@ EXAMPLES:
|
|
|
1211
1229
|
const noSimulate = flags['no-simulate'];
|
|
1212
1230
|
|
|
1213
1231
|
if (!quoteId) {
|
|
1214
|
-
|
|
1215
|
-
Usage: nansen trade execute --quote <quoteId> [options]
|
|
1232
|
+
throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
|
|
1216
1233
|
|
|
1217
1234
|
OPTIONS:
|
|
1218
1235
|
--quote <id> Quote ID from 'nansen quote'
|
|
@@ -1220,10 +1237,7 @@ OPTIONS:
|
|
|
1220
1237
|
--no-simulate Skip pre-broadcast simulation
|
|
1221
1238
|
|
|
1222
1239
|
EXAMPLES:
|
|
1223
|
-
nansen trade execute --quote 1708900000000-abc123
|
|
1224
|
-
`);
|
|
1225
|
-
exit(1);
|
|
1226
|
-
return;
|
|
1240
|
+
nansen trade execute --quote 1708900000000-abc123`, 'MISSING_ARGS');
|
|
1227
1241
|
}
|
|
1228
1242
|
|
|
1229
1243
|
try {
|
|
@@ -1234,9 +1248,7 @@ EXAMPLES:
|
|
|
1234
1248
|
|
|
1235
1249
|
const allQuotes = quoteData.response.quotes || [];
|
|
1236
1250
|
if (!allQuotes.length) {
|
|
1237
|
-
|
|
1238
|
-
exit(1);
|
|
1239
|
-
return;
|
|
1251
|
+
throw new CommandError('❌ No quote data found', 'NO_QUOTES');
|
|
1240
1252
|
}
|
|
1241
1253
|
|
|
1242
1254
|
// --quote-index pins a specific quote (no fallback)
|
|
@@ -1247,10 +1259,7 @@ EXAMPLES:
|
|
|
1247
1259
|
// Check if any quote in range has transaction data before prompting for password
|
|
1248
1260
|
const hasAnyTransaction = allQuotes.slice(startIndex, endIndex).some(q => q?.transaction);
|
|
1249
1261
|
if (!hasAnyTransaction) {
|
|
1250
|
-
|
|
1251
|
-
log(' Ensure userWalletAddress was provided when fetching the quote.');
|
|
1252
|
-
exit(1);
|
|
1253
|
-
return;
|
|
1262
|
+
throw new CommandError('❌ No quotes contain transaction data.\n Ensure userWalletAddress was provided when fetching the quote.', 'NO_TRANSACTION');
|
|
1254
1263
|
}
|
|
1255
1264
|
|
|
1256
1265
|
// Determine if this is a WalletConnect or Privy-signed quote
|
|
@@ -1271,16 +1280,14 @@ EXAMPLES:
|
|
|
1271
1280
|
if (walletConfig.passwordHash) {
|
|
1272
1281
|
password = resolveTradePassword();
|
|
1273
1282
|
if (!password) {
|
|
1274
|
-
|
|
1283
|
+
throw new CommandError('Wallet is encrypted and no password was found.', 'PASSWORD_REQUIRED', {
|
|
1275
1284
|
error: 'PASSWORD_REQUIRED',
|
|
1276
1285
|
message: 'Wallet is encrypted and no password was found.',
|
|
1277
1286
|
resolution: [
|
|
1278
1287
|
'Set NANSEN_WALLET_PASSWORD environment variable',
|
|
1279
1288
|
'Or run: nansen wallet create (password is saved to OS keychain automatically)',
|
|
1280
1289
|
],
|
|
1281
|
-
})
|
|
1282
|
-
exit(1);
|
|
1283
|
-
return;
|
|
1290
|
+
});
|
|
1284
1291
|
}
|
|
1285
1292
|
}
|
|
1286
1293
|
|
|
@@ -1290,9 +1297,7 @@ EXAMPLES:
|
|
|
1290
1297
|
effectiveWalletName = list.defaultWallet;
|
|
1291
1298
|
}
|
|
1292
1299
|
if (!effectiveWalletName) {
|
|
1293
|
-
|
|
1294
|
-
exit(1);
|
|
1295
|
-
return;
|
|
1300
|
+
throw new CommandError('No wallet found. Create one with: nansen wallet create', 'NO_WALLET');
|
|
1296
1301
|
}
|
|
1297
1302
|
|
|
1298
1303
|
exported = exportWallet(effectiveWalletName, password);
|
|
@@ -1300,9 +1305,7 @@ EXAMPLES:
|
|
|
1300
1305
|
// Verify WalletConnect session is still active and address matches quote
|
|
1301
1306
|
const wcAddress = await getWalletConnectAddress(chainType);
|
|
1302
1307
|
if (!wcAddress) {
|
|
1303
|
-
|
|
1304
|
-
exit(1);
|
|
1305
|
-
return;
|
|
1308
|
+
throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
|
|
1306
1309
|
}
|
|
1307
1310
|
// Check address matches the one used during quoting
|
|
1308
1311
|
const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from
|
|
@@ -1310,9 +1313,7 @@ EXAMPLES:
|
|
|
1310
1313
|
if (quoteWallet && (chainType === 'solana'
|
|
1311
1314
|
? wcAddress.trim() !== quoteWallet.trim()
|
|
1312
1315
|
: wcAddress.toLowerCase().trim() !== quoteWallet.toLowerCase().trim())) {
|
|
1313
|
-
|
|
1314
|
-
exit(1);
|
|
1315
|
-
return;
|
|
1316
|
+
throw new CommandError(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`, 'WALLET_MISMATCH');
|
|
1316
1317
|
}
|
|
1317
1318
|
}
|
|
1318
1319
|
|
|
@@ -1675,8 +1676,7 @@ EXAMPLES:
|
|
|
1675
1676
|
lastQuoteError = `${quoteName} reverted on-chain`;
|
|
1676
1677
|
continue;
|
|
1677
1678
|
}
|
|
1678
|
-
|
|
1679
|
-
return;
|
|
1679
|
+
throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${wcResult.txHash}\n Explorer: ${chainConfig.explorer}${wcResult.txHash}\n Error: ${receiptErr.message}`, 'TX_REVERTED');
|
|
1680
1680
|
}
|
|
1681
1681
|
|
|
1682
1682
|
log(`\n ✓ Transaction successful!`);
|
|
@@ -1866,10 +1866,7 @@ EXAMPLES:
|
|
|
1866
1866
|
lastQuoteError = `${quoteName} reverted on-chain`;
|
|
1867
1867
|
continue;
|
|
1868
1868
|
}
|
|
1869
|
-
|
|
1870
|
-
log(` This can happen due to: stale quotes, insufficient gas, or liquidity changes.`);
|
|
1871
|
-
exit(1);
|
|
1872
|
-
return;
|
|
1869
|
+
throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
|
|
1873
1870
|
}
|
|
1874
1871
|
}
|
|
1875
1872
|
|
|
@@ -1924,15 +1921,13 @@ EXAMPLES:
|
|
|
1924
1921
|
}
|
|
1925
1922
|
|
|
1926
1923
|
// All quotes exhausted
|
|
1927
|
-
|
|
1928
|
-
log('');
|
|
1929
|
-
exit(1);
|
|
1930
|
-
return undefined;
|
|
1924
|
+
throw new CommandError(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}\n`, 'ALL_QUOTES_FAILED');
|
|
1931
1925
|
|
|
1932
1926
|
} catch (err) {
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1927
|
+
if (err instanceof CommandError) throw err;
|
|
1928
|
+
let msg = `Error: ${err.message}`;
|
|
1929
|
+
if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
|
|
1930
|
+
throw new CommandError(msg, err.code || 'EXECUTE_ERROR');
|
|
1936
1931
|
}
|
|
1937
1932
|
},
|
|
1938
1933
|
|
|
@@ -1942,8 +1937,7 @@ EXAMPLES:
|
|
|
1942
1937
|
const toChain = options['to-chain'] || args[2];
|
|
1943
1938
|
|
|
1944
1939
|
if (!txHash || !fromChain || !toChain) {
|
|
1945
|
-
|
|
1946
|
-
Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1940
|
+
throw new CommandError(`Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
|
|
1947
1941
|
|
|
1948
1942
|
Check the status of a cross-chain bridge transaction.
|
|
1949
1943
|
|
|
@@ -1953,10 +1947,7 @@ OPTIONS:
|
|
|
1953
1947
|
--to-chain <chain> Destination chain (solana or base)
|
|
1954
1948
|
|
|
1955
1949
|
EXAMPLES:
|
|
1956
|
-
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
|
|
1957
|
-
`);
|
|
1958
|
-
exit(1);
|
|
1959
|
-
return;
|
|
1950
|
+
nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana`, 'MISSING_ARGS');
|
|
1960
1951
|
}
|
|
1961
1952
|
|
|
1962
1953
|
try {
|
|
@@ -1980,9 +1971,10 @@ EXAMPLES:
|
|
|
1980
1971
|
if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
|
|
1981
1972
|
log('');
|
|
1982
1973
|
} catch (err) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1974
|
+
if (err instanceof CommandError) throw err;
|
|
1975
|
+
let msg = `Error: ${err.message}`;
|
|
1976
|
+
if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
|
|
1977
|
+
throw new CommandError(msg, err.code || 'BRIDGE_STATUS_ERROR');
|
|
1986
1978
|
}
|
|
1987
1979
|
},
|
|
1988
1980
|
};
|
package/src/transfer.js
CHANGED
|
@@ -559,8 +559,7 @@ async function broadcastTransaction(signedTx, chain) {
|
|
|
559
559
|
|
|
560
560
|
// ============= Public API =============
|
|
561
561
|
|
|
562
|
-
|
|
563
|
-
export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, validateEvmAddress, validateSolanaAddress, bigIntToHex };
|
|
562
|
+
export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, isOnEd25519Curve, validateEvmAddress, validateSolanaAddress, bigIntToHex };
|
|
564
563
|
|
|
565
564
|
/**
|
|
566
565
|
* Send tokens via Privy server wallet. EVM uses Privy's sendTransaction (handles gas/nonce).
|
package/src/wallet.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import * as readline from 'readline';
|
|
10
10
|
import { base58 } from '@scure/base';
|
|
11
11
|
import { storePassword, retrievePassword, deletePassword, deleteCredentialsFile } from './keychain.js';
|
|
12
|
+
import { CommandError } from './api.js';
|
|
12
13
|
|
|
13
14
|
// ============= Constants =============
|
|
14
15
|
|
|
@@ -335,8 +336,8 @@ function resolveWalletPassword() {
|
|
|
335
336
|
*
|
|
336
337
|
* @param {object} config - wallet config (needs config.passwordHash)
|
|
337
338
|
* @param {object} flags - CLI flags
|
|
338
|
-
* @param {object} deps - { promptFn, log
|
|
339
|
-
* @returns {{ password: string|null, error:
|
|
339
|
+
* @param {object} deps - { promptFn, log }
|
|
340
|
+
* @returns {{ password: string|null, error: object|null }}
|
|
340
341
|
*/
|
|
341
342
|
async function resolvePasswordForCommand(config, flags, deps) {
|
|
342
343
|
if (!config.passwordHash) {
|
|
@@ -353,14 +354,14 @@ async function resolvePasswordForCommand(config, flags, deps) {
|
|
|
353
354
|
|
|
354
355
|
return {
|
|
355
356
|
password: null,
|
|
356
|
-
error:
|
|
357
|
+
error: {
|
|
357
358
|
error: 'PASSWORD_REQUIRED',
|
|
358
359
|
message: 'Wallet is encrypted and no password was found.',
|
|
359
360
|
resolution: [
|
|
360
361
|
'Set NANSEN_WALLET_PASSWORD environment variable',
|
|
361
362
|
'Or re-run wallet create with the password (it will be persisted for future use)',
|
|
362
363
|
],
|
|
363
|
-
}
|
|
364
|
+
},
|
|
364
365
|
};
|
|
365
366
|
}
|
|
366
367
|
|
|
@@ -580,7 +581,7 @@ export async function deleteWallet(name, password) {
|
|
|
580
581
|
* Build wallet command handlers for integration into CLI.
|
|
581
582
|
*/
|
|
582
583
|
export function buildWalletCommands(deps = {}) {
|
|
583
|
-
const { log = console.log
|
|
584
|
+
const { log = console.log } = deps;
|
|
584
585
|
|
|
585
586
|
return {
|
|
586
587
|
'wallet': async (args, apiInstance, flags, options) => {
|
|
@@ -599,9 +600,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
599
600
|
log('');
|
|
600
601
|
return;
|
|
601
602
|
} catch (err) {
|
|
602
|
-
|
|
603
|
-
exit(1);
|
|
604
|
-
return;
|
|
603
|
+
throw new CommandError(`❌ ${err.message}`, 'PRIVY_ERROR');
|
|
605
604
|
}
|
|
606
605
|
}
|
|
607
606
|
// All other subcommands fall through to unified handlers below
|
|
@@ -612,8 +611,8 @@ export function buildWalletCommands(deps = {}) {
|
|
|
612
611
|
const handlers = {
|
|
613
612
|
'create': async () => {
|
|
614
613
|
// Privy wallets are handled above — if we reach here with provider=privy,
|
|
615
|
-
// the privy path already
|
|
616
|
-
// where
|
|
614
|
+
// the privy path already threw CommandError. Guard against environments
|
|
615
|
+
// where throw does not propagate (agent frameworks, test harnesses).
|
|
617
616
|
if (isPrivy) return;
|
|
618
617
|
|
|
619
618
|
const name = options.name || args[1] || 'default';
|
|
@@ -628,28 +627,22 @@ export function buildWalletCommands(deps = {}) {
|
|
|
628
627
|
|
|
629
628
|
// Step 2: If --human flag, allow interactive prompt (requires TTY)
|
|
630
629
|
if (!password && flags.human && !process.stdin.isTTY && !deps.promptFn) {
|
|
631
|
-
|
|
630
|
+
throw new CommandError('--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.', 'NOT_A_TTY', {
|
|
632
631
|
error: 'NOT_A_TTY',
|
|
633
632
|
message: '--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.',
|
|
634
|
-
})
|
|
635
|
-
exit(1);
|
|
636
|
-
return;
|
|
633
|
+
});
|
|
637
634
|
}
|
|
638
635
|
if (!password && flags.human && (process.stdin.isTTY || deps.promptFn)) {
|
|
639
636
|
password = await promptPassword('Enter wallet password: ', deps);
|
|
640
637
|
if (password && password.length < 12) {
|
|
641
|
-
|
|
642
|
-
exit(1);
|
|
643
|
-
return;
|
|
638
|
+
throw new CommandError('❌ Password must be at least 12 characters', 'INVALID_INPUT');
|
|
644
639
|
}
|
|
645
640
|
if (password) {
|
|
646
641
|
const config = getWalletConfig();
|
|
647
642
|
if (!config.passwordHash) {
|
|
648
643
|
const confirm = await promptPassword('Confirm password: ', deps);
|
|
649
644
|
if (password !== confirm) {
|
|
650
|
-
|
|
651
|
-
exit(1);
|
|
652
|
-
return;
|
|
645
|
+
throw new CommandError('❌ Passwords do not match', 'INVALID_INPUT');
|
|
653
646
|
}
|
|
654
647
|
}
|
|
655
648
|
}
|
|
@@ -657,20 +650,16 @@ export function buildWalletCommands(deps = {}) {
|
|
|
657
650
|
|
|
658
651
|
// Step 3: No password available — return structured error for agents
|
|
659
652
|
if (!password) {
|
|
660
|
-
|
|
653
|
+
throw new CommandError('A wallet password is required. Ask the user to provide one.', 'PASSWORD_REQUIRED', {
|
|
661
654
|
error: 'PASSWORD_REQUIRED',
|
|
662
655
|
message: 'A wallet password is required. Ask the user to provide one.',
|
|
663
656
|
instructions: 'Re-run with: NANSEN_WALLET_PASSWORD=<password> nansen wallet create',
|
|
664
657
|
note: 'Password must be at least 12 characters. After creation, the password is saved to the OS keychain automatically — future operations will not require it.',
|
|
665
|
-
})
|
|
666
|
-
exit(1);
|
|
667
|
-
return;
|
|
658
|
+
});
|
|
668
659
|
}
|
|
669
660
|
|
|
670
661
|
if (password.length < 12) {
|
|
671
|
-
|
|
672
|
-
exit(1);
|
|
673
|
-
return;
|
|
662
|
+
throw new CommandError('❌ Password must be at least 12 characters', 'INVALID_INPUT');
|
|
674
663
|
}
|
|
675
664
|
}
|
|
676
665
|
|
|
@@ -678,9 +667,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
678
667
|
if (password !== null) {
|
|
679
668
|
const config = getWalletConfig();
|
|
680
669
|
if (config.passwordHash && !verifyPassword(password, config)) {
|
|
681
|
-
|
|
682
|
-
exit(1);
|
|
683
|
-
return;
|
|
670
|
+
throw new CommandError('❌ Incorrect password — does not match existing wallets.', 'INCORRECT_PASSWORD');
|
|
684
671
|
}
|
|
685
672
|
}
|
|
686
673
|
|
|
@@ -725,8 +712,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
725
712
|
log('');
|
|
726
713
|
return;
|
|
727
714
|
} catch (err) {
|
|
728
|
-
|
|
729
|
-
exit(1);
|
|
715
|
+
throw new CommandError(`❌ ${err.message}`, 'CREATE_FAILED');
|
|
730
716
|
}
|
|
731
717
|
},
|
|
732
718
|
|
|
@@ -750,9 +736,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
750
736
|
'show': async () => {
|
|
751
737
|
const name = options.name || args[1];
|
|
752
738
|
if (!name) {
|
|
753
|
-
|
|
754
|
-
exit(1);
|
|
755
|
-
return;
|
|
739
|
+
throw new CommandError('Usage: nansen wallet show <name>', 'MISSING_ARGS');
|
|
756
740
|
}
|
|
757
741
|
try {
|
|
758
742
|
const result = showWallet(name);
|
|
@@ -764,25 +748,20 @@ export function buildWalletCommands(deps = {}) {
|
|
|
764
748
|
log(` Created: ${result.createdAt}\n`);
|
|
765
749
|
return;
|
|
766
750
|
} catch (err) {
|
|
767
|
-
|
|
768
|
-
exit(1);
|
|
751
|
+
throw new CommandError(`❌ ${err.message}`, 'SHOW_FAILED');
|
|
769
752
|
}
|
|
770
753
|
},
|
|
771
754
|
|
|
772
755
|
'export': async () => {
|
|
773
756
|
const name = options.name || args[1];
|
|
774
757
|
if (!name) {
|
|
775
|
-
|
|
776
|
-
exit(1);
|
|
777
|
-
return;
|
|
758
|
+
throw new CommandError('Usage: nansen wallet export <name>', 'MISSING_ARGS');
|
|
778
759
|
}
|
|
779
760
|
|
|
780
761
|
const config = getWalletConfig();
|
|
781
762
|
const { password, error } = await resolvePasswordForCommand(config, flags, deps);
|
|
782
763
|
if (error) {
|
|
783
|
-
|
|
784
|
-
exit(1);
|
|
785
|
-
return;
|
|
764
|
+
throw new CommandError(error.message, 'PASSWORD_REQUIRED', error);
|
|
786
765
|
}
|
|
787
766
|
try {
|
|
788
767
|
const result = exportWallet(name, password);
|
|
@@ -796,34 +775,28 @@ export function buildWalletCommands(deps = {}) {
|
|
|
796
775
|
log('');
|
|
797
776
|
return;
|
|
798
777
|
} catch (err) {
|
|
799
|
-
|
|
800
|
-
exit(1);
|
|
778
|
+
throw new CommandError(`❌ ${err.message}`, 'EXPORT_FAILED');
|
|
801
779
|
}
|
|
802
780
|
},
|
|
803
781
|
|
|
804
782
|
'default': async () => {
|
|
805
783
|
const name = options.name || args[1];
|
|
806
784
|
if (!name) {
|
|
807
|
-
|
|
808
|
-
exit(1);
|
|
809
|
-
return;
|
|
785
|
+
throw new CommandError('Usage: nansen wallet default <name>', 'MISSING_ARGS');
|
|
810
786
|
}
|
|
811
787
|
try {
|
|
812
788
|
const result = setDefaultWallet(name);
|
|
813
789
|
log(`✓ Default wallet set to "${result.defaultWallet}"`);
|
|
814
790
|
return;
|
|
815
791
|
} catch (err) {
|
|
816
|
-
|
|
817
|
-
exit(1);
|
|
792
|
+
throw new CommandError(`❌ ${err.message}`, 'DEFAULT_FAILED');
|
|
818
793
|
}
|
|
819
794
|
},
|
|
820
795
|
|
|
821
796
|
'delete': async () => {
|
|
822
797
|
const name = options.name || args[1];
|
|
823
798
|
if (!name) {
|
|
824
|
-
|
|
825
|
-
exit(1);
|
|
826
|
-
return;
|
|
799
|
+
throw new CommandError('Usage: nansen wallet delete <name>', 'MISSING_ARGS');
|
|
827
800
|
}
|
|
828
801
|
|
|
829
802
|
// Check if this is a Privy wallet (no password needed)
|
|
@@ -839,9 +812,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
839
812
|
const config = getWalletConfig();
|
|
840
813
|
const resolved = await resolvePasswordForCommand(config, flags, deps);
|
|
841
814
|
if (resolved.error) {
|
|
842
|
-
|
|
843
|
-
exit(1);
|
|
844
|
-
return;
|
|
815
|
+
throw new CommandError(resolved.error.message, 'PASSWORD_REQUIRED', resolved.error);
|
|
845
816
|
}
|
|
846
817
|
password = resolved.password;
|
|
847
818
|
}
|
|
@@ -857,8 +828,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
857
828
|
}
|
|
858
829
|
return;
|
|
859
830
|
} catch (err) {
|
|
860
|
-
|
|
861
|
-
exit(1);
|
|
831
|
+
throw new CommandError(`❌ ${err.message}`, 'DELETE_FAILED');
|
|
862
832
|
}
|
|
863
833
|
},
|
|
864
834
|
|
|
@@ -866,28 +836,20 @@ export function buildWalletCommands(deps = {}) {
|
|
|
866
836
|
const { sendTokens } = await import('./transfer.js');
|
|
867
837
|
|
|
868
838
|
if (!options.to) {
|
|
869
|
-
|
|
870
|
-
exit(1);
|
|
871
|
-
return;
|
|
839
|
+
throw new CommandError('--to <address> is required', 'MISSING_ARGS');
|
|
872
840
|
}
|
|
873
841
|
|
|
874
842
|
const isMax = flags.max || options.amount === 'max';
|
|
875
843
|
if (!options.amount && !isMax) {
|
|
876
|
-
|
|
877
|
-
exit(1);
|
|
878
|
-
return;
|
|
844
|
+
throw new CommandError('--amount <number> or --max is required', 'MISSING_ARGS');
|
|
879
845
|
}
|
|
880
846
|
|
|
881
847
|
if (!options.chain) {
|
|
882
|
-
|
|
883
|
-
exit(1);
|
|
884
|
-
return;
|
|
848
|
+
throw new CommandError('--chain <evm|solana> is required', 'MISSING_ARGS');
|
|
885
849
|
}
|
|
886
850
|
|
|
887
851
|
if (!['evm', 'solana', 'ethereum', 'base'].includes(options.chain)) {
|
|
888
|
-
|
|
889
|
-
exit(1);
|
|
890
|
-
return;
|
|
852
|
+
throw new CommandError('--chain must be one of: evm, solana, ethereum, base', 'INVALID_INPUT');
|
|
891
853
|
}
|
|
892
854
|
|
|
893
855
|
const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
|
|
@@ -912,9 +874,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
912
874
|
const sendConfig = getWalletConfig();
|
|
913
875
|
const resolved = await resolvePasswordForCommand(sendConfig, flags, deps);
|
|
914
876
|
if (resolved.error) {
|
|
915
|
-
|
|
916
|
-
exit(1);
|
|
917
|
-
return;
|
|
877
|
+
throw new CommandError(resolved.error.message, 'PASSWORD_REQUIRED', resolved.error);
|
|
918
878
|
}
|
|
919
879
|
password = resolved.password;
|
|
920
880
|
}
|
|
@@ -960,8 +920,7 @@ export function buildWalletCommands(deps = {}) {
|
|
|
960
920
|
log('');
|
|
961
921
|
return;
|
|
962
922
|
} catch (err) {
|
|
963
|
-
|
|
964
|
-
exit(1);
|
|
923
|
+
throw new CommandError(err.message, 'SEND_FAILED');
|
|
965
924
|
}
|
|
966
925
|
},
|
|
967
926
|
|
|
@@ -979,16 +938,14 @@ export function buildWalletCommands(deps = {}) {
|
|
|
979
938
|
'secure': async () => {
|
|
980
939
|
const { password, source } = retrievePassword();
|
|
981
940
|
if (!password) {
|
|
982
|
-
|
|
941
|
+
throw new CommandError('No wallet password found in any store.', 'NO_PASSWORD_FOUND', {
|
|
983
942
|
error: 'NO_PASSWORD_FOUND',
|
|
984
943
|
message: 'No wallet password found in any store.',
|
|
985
944
|
resolution: [
|
|
986
945
|
'Set NANSEN_WALLET_PASSWORD and run: nansen wallet secure',
|
|
987
946
|
'This will store it in the OS keychain.',
|
|
988
947
|
],
|
|
989
|
-
})
|
|
990
|
-
exit(1);
|
|
991
|
-
return;
|
|
948
|
+
});
|
|
992
949
|
}
|
|
993
950
|
|
|
994
951
|
if (source === 'keychain') {
|
|
@@ -999,20 +956,19 @@ export function buildWalletCommands(deps = {}) {
|
|
|
999
956
|
// Verify password actually decrypts wallets before overwriting keychain
|
|
1000
957
|
const walletConfig = getWalletConfig();
|
|
1001
958
|
if (walletConfig.passwordHash && !verifyPassword(password, walletConfig)) {
|
|
1002
|
-
|
|
959
|
+
const resolution = source === 'file'
|
|
960
|
+
? [
|
|
961
|
+
'The password in ~/.nansen/wallets/.credentials is incorrect.',
|
|
962
|
+
'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
|
|
963
|
+
]
|
|
964
|
+
: [
|
|
965
|
+
'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
|
|
966
|
+
];
|
|
967
|
+
throw new CommandError(`Password from '${source}' does not match the wallet's stored hash.`, 'INCORRECT_PASSWORD', {
|
|
1003
968
|
error: 'INCORRECT_PASSWORD',
|
|
1004
969
|
message: `Password from '${source}' does not match the wallet's stored hash.`,
|
|
1005
|
-
resolution
|
|
1006
|
-
|
|
1007
|
-
'The password in ~/.nansen/wallets/.credentials is incorrect.',
|
|
1008
|
-
'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
|
|
1009
|
-
]
|
|
1010
|
-
: [
|
|
1011
|
-
'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
|
|
1012
|
-
],
|
|
1013
|
-
}));
|
|
1014
|
-
exit(1);
|
|
1015
|
-
return;
|
|
970
|
+
resolution,
|
|
971
|
+
});
|
|
1016
972
|
}
|
|
1017
973
|
|
|
1018
974
|
// Try to migrate to keychain
|
|
@@ -1027,18 +983,17 @@ export function buildWalletCommands(deps = {}) {
|
|
|
1027
983
|
log(' Removed ~/.nansen/wallets/.credentials.');
|
|
1028
984
|
}
|
|
1029
985
|
} else {
|
|
1030
|
-
|
|
986
|
+
const msg = source === 'file'
|
|
987
|
+
? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
|
|
988
|
+
: 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).';
|
|
989
|
+
throw new CommandError(msg, 'KEYCHAIN_UNAVAILABLE', {
|
|
1031
990
|
error: 'KEYCHAIN_UNAVAILABLE',
|
|
1032
|
-
message:
|
|
1033
|
-
? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
|
|
1034
|
-
: 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).',
|
|
991
|
+
message: msg,
|
|
1035
992
|
resolution: [
|
|
1036
993
|
'Set NANSEN_WALLET_PASSWORD in a secrets manager or system keyring',
|
|
1037
994
|
'Use a containerized secrets agent (e.g. Vault, 1Password CLI)',
|
|
1038
995
|
],
|
|
1039
|
-
})
|
|
1040
|
-
exit(1);
|
|
1041
|
-
return;
|
|
996
|
+
});
|
|
1042
997
|
}
|
|
1043
998
|
},
|
|
1044
999
|
|
|
@@ -1104,9 +1059,7 @@ EXAMPLES:
|
|
|
1104
1059
|
};
|
|
1105
1060
|
|
|
1106
1061
|
if (!handlers[subcommand]) {
|
|
1107
|
-
|
|
1108
|
-
exit(1);
|
|
1109
|
-
return;
|
|
1062
|
+
throw new CommandError(`Unknown subcommand: ${subcommand}. Available: ${Object.keys(handlers).join(', ')}`, 'UNKNOWN_COMMAND');
|
|
1110
1063
|
}
|
|
1111
1064
|
|
|
1112
1065
|
// --help on any wallet subcommand shows wallet help instead of executing
|
package/src/x402-svm.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { base58Encode, base58DecodePubkey } from './wallet.js';
|
|
8
|
-
import { encodeCompactU16 } from './transfer.js';
|
|
8
|
+
import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js';
|
|
9
9
|
|
|
10
10
|
// ============= Constants =============
|
|
11
11
|
|
|
@@ -13,7 +13,6 @@ const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
|
|
|
13
13
|
const _TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
|
|
14
14
|
const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
|
|
15
15
|
const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
|
|
16
|
-
const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
|
|
17
16
|
const _SYSTEM_PROGRAM = '11111111111111111111111111111111';
|
|
18
17
|
|
|
19
18
|
const DEFAULT_COMPUTE_UNIT_LIMIT = 20000;
|
|
@@ -23,81 +22,10 @@ const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
|
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Derive Associated Token Account (ATA) address.
|
|
26
|
-
*
|
|
25
|
+
* Returns base58-encoded PDA. Delegates algorithm to transfer.js.
|
|
27
26
|
*/
|
|
28
27
|
export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PROGRAM) {
|
|
29
|
-
|
|
30
|
-
const tokenProgram = base58DecodePubkey(tokenProgramBase58);
|
|
31
|
-
const mint = base58DecodePubkey(mintBase58);
|
|
32
|
-
const ataProgramKey = base58DecodePubkey(ATA_PROGRAM);
|
|
33
|
-
|
|
34
|
-
// find_program_address: try nonce 255 down to 0
|
|
35
|
-
// PDA = SHA256(seeds... || programId || "ProgramDerivedAddress")
|
|
36
|
-
// A valid PDA must NOT be on the ed25519 curve.
|
|
37
|
-
// Checking on-curve in pure JS without a full ed25519 implementation is hard.
|
|
38
|
-
// We use the mathematical approach: decode y-coordinate, compute x², check QR.
|
|
39
|
-
for (let nonce = 255; nonce >= 0; nonce--) {
|
|
40
|
-
const hash = crypto.createHash('sha256')
|
|
41
|
-
.update(Buffer.concat([owner, tokenProgram, mint, Buffer.from([nonce]), ataProgramKey, Buffer.from('ProgramDerivedAddress')]))
|
|
42
|
-
.digest();
|
|
43
|
-
|
|
44
|
-
if (!isOnCurve(hash)) {
|
|
45
|
-
return base58Encode(hash);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
throw new Error('Could not derive ATA: no valid PDA found');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Check if a 32-byte buffer represents a valid ed25519 curve point.
|
|
53
|
-
* Ed25519 curve: -x² + y² = 1 + d*x²*y² over GF(p) where p = 2^255 - 19
|
|
54
|
-
*
|
|
55
|
-
* Decode y from the 32 bytes, compute x² = (y² - 1) / (d*y² + 1),
|
|
56
|
-
* then check if x² is a quadratic residue (QR) mod p.
|
|
57
|
-
*/
|
|
58
|
-
function isOnCurve(bytes) {
|
|
59
|
-
const p = (1n << 255n) - 19n;
|
|
60
|
-
const d = -121665n * modInverse(121666n, p) % p;
|
|
61
|
-
|
|
62
|
-
// Read y-coordinate (little-endian, clear top bit which is sign of x)
|
|
63
|
-
let y = 0n;
|
|
64
|
-
for (let i = 0; i < 32; i++) {
|
|
65
|
-
y |= BigInt(bytes[i]) << (BigInt(i) * 8n);
|
|
66
|
-
}
|
|
67
|
-
y &= (1n << 255n) - 1n; // Clear top bit
|
|
68
|
-
|
|
69
|
-
if (y >= p) return false;
|
|
70
|
-
|
|
71
|
-
// y² mod p
|
|
72
|
-
const y2 = modPow(y, 2n, p);
|
|
73
|
-
|
|
74
|
-
// x² = (y² - 1) * inverse(d*y² + 1) mod p
|
|
75
|
-
const num = ((y2 - 1n) % p + p) % p;
|
|
76
|
-
const den = ((d * y2 + 1n) % p + p) % p;
|
|
77
|
-
const denInv = modInverse(den, p);
|
|
78
|
-
if (denInv === null) return false;
|
|
79
|
-
|
|
80
|
-
const x2 = (num * denInv) % p;
|
|
81
|
-
|
|
82
|
-
// Check if x² is a quadratic residue: x^((p-1)/2) == 1 mod p
|
|
83
|
-
if (x2 === 0n) return true;
|
|
84
|
-
const euler = modPow(x2, (p - 1n) / 2n, p);
|
|
85
|
-
return euler === 1n;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function modPow(base, exp, mod) {
|
|
89
|
-
let result = 1n;
|
|
90
|
-
base = ((base % mod) + mod) % mod;
|
|
91
|
-
while (exp > 0n) {
|
|
92
|
-
if (exp & 1n) result = (result * base) % mod;
|
|
93
|
-
exp >>= 1n;
|
|
94
|
-
base = (base * base) % mod;
|
|
95
|
-
}
|
|
96
|
-
return result;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function modInverse(a, mod) {
|
|
100
|
-
return modPow(((a % mod) + mod) % mod, mod - 2n, mod);
|
|
28
|
+
return base58Encode(_deriveATA(ownerBase58, mintBase58, tokenProgramBase58));
|
|
101
29
|
}
|
|
102
30
|
|
|
103
31
|
// ============= MessageV0 Builder =============
|