nansen-cli 1.41.1 → 1.43.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 +49 -0
- package/README.md +33 -7
- package/package.json +1 -1
- package/scripts/postinstall.js +3 -3
- package/skills/nansen-wallet-manager/SKILL.md +5 -5
- package/src/api.js +11 -5
- package/src/bridge.js +791 -55
- package/src/cli.js +68 -41
- package/src/commands/agent.js +4 -0
- package/src/commands/mcp.js +373 -0
- package/src/doctor.js +16 -7
- package/src/limit-order.js +16 -2
- package/src/mcp-verify.js +292 -0
- package/src/privy.js +25 -4
- package/src/schema.json +55 -1
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +32 -5
- package/src/trading.js +92 -41
- package/src/wallet.js +22 -14
- package/src/walletconnect-x402.js +17 -10
- package/src/x402-evm.js +9 -6
- package/src/x402-policy.js +201 -0
- package/src/x402-svm.js +3 -2
- package/src/x402-tokens.js +29 -0
- package/src/x402.js +12 -25
package/src/trading.js
CHANGED
|
@@ -14,7 +14,7 @@ import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js';
|
|
|
14
14
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
15
15
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
16
16
|
import { retrievePassword } from './keychain.js';
|
|
17
|
-
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
|
|
17
|
+
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER, EVM_BRIDGE_NATIVE_FEE_SLACK, isBridgeRequest } from './trade-validation.js';
|
|
18
18
|
import { readCompactU16 } from './solana-tx.js';
|
|
19
19
|
export { readCompactU16 };
|
|
20
20
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
@@ -1008,7 +1008,18 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
1008
1008
|
{ to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
|
|
1009
1009
|
{ from, apiKey },
|
|
1010
1010
|
);
|
|
1011
|
-
|
|
1011
|
+
// A cross-chain bridge may pay a fee in native ETH via msg.value on a
|
|
1012
|
+
// token-input route; that surfaces as a native sibling outflow which the
|
|
1013
|
+
// no-sibling-drain check (assertion 3) would otherwise reject. Tolerate it up
|
|
1014
|
+
// to the smaller of the tx's declared native value and the fixed cap — never
|
|
1015
|
+
// the full value, which a hostile quote could inflate to the whole balance.
|
|
1016
|
+
// assertSwapOutcome applies this only for bridges and only to native.
|
|
1017
|
+
let siblingDustThreshold = 0n;
|
|
1018
|
+
try {
|
|
1019
|
+
const declaredValue = BigInt(tx.value ?? 0);
|
|
1020
|
+
siblingDustThreshold = declaredValue < EVM_BRIDGE_NATIVE_FEE_SLACK ? declaredValue : EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
1021
|
+
} catch { /* non-integer value → leave 0n, assertion 3 stays strict */ }
|
|
1022
|
+
const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders, siblingDustThreshold });
|
|
1012
1023
|
if (outcome.outputAssertionSkipped) {
|
|
1013
1024
|
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
1014
1025
|
}
|
|
@@ -1086,6 +1097,49 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
|
|
|
1086
1097
|
}
|
|
1087
1098
|
}
|
|
1088
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* Parse a gas field from quote/tx data (decimal or 0x-prefixed hex).
|
|
1102
|
+
*/
|
|
1103
|
+
function parseGasField(v) {
|
|
1104
|
+
if (v === undefined || v === null || v === '') return 0;
|
|
1105
|
+
if (typeof v === 'number') return v;
|
|
1106
|
+
if (typeof v === 'string' && v.startsWith('0x')) return parseInt(v, 16);
|
|
1107
|
+
return parseInt(v, 10);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Resolve gas limit for an EVM swap from quote fields. When both quote.gas and
|
|
1112
|
+
* tx.gas/gasLimit are zero/missing, fall back to eth_estimateGas (×1.5) then 210000.
|
|
1113
|
+
*/
|
|
1114
|
+
export async function resolveEvmSwapGasLimit(currentQuote, { chain, from }) {
|
|
1115
|
+
const txData = currentQuote.transaction;
|
|
1116
|
+
const apiGas = parseGasField(currentQuote.gas);
|
|
1117
|
+
const txGas = parseGasField(txData.gas || txData.gasLimit);
|
|
1118
|
+
let finalGas = apiGas > 0 ? apiGas : txGas;
|
|
1119
|
+
if (finalGas === 0) {
|
|
1120
|
+
const estimated = await estimateEvmGas(chain, {
|
|
1121
|
+
from,
|
|
1122
|
+
to: txData.to,
|
|
1123
|
+
data: txData.data || '0x',
|
|
1124
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1125
|
+
});
|
|
1126
|
+
if (estimated) finalGas = Math.ceil(estimated * 1.5);
|
|
1127
|
+
if (finalGas === 0) finalGas = 210000;
|
|
1128
|
+
}
|
|
1129
|
+
return finalGas;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Log when gas was resolved from API vs estimate/fallback (all EVM signing paths). */
|
|
1133
|
+
function logEvmSwapGasResolution(log, currentQuote, txData, finalGas) {
|
|
1134
|
+
const apiGas = parseGasField(currentQuote.gas);
|
|
1135
|
+
const txGas = parseGasField(txData.gas || txData.gasLimit);
|
|
1136
|
+
if (apiGas > 0 && finalGas !== txGas) {
|
|
1137
|
+
log(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
|
|
1138
|
+
} else if (finalGas > 0 && apiGas === 0 && txGas === 0) {
|
|
1139
|
+
log(` ℹ Using estimated gas ${finalGas} (quote had no gas)`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1089
1143
|
/**
|
|
1090
1144
|
* Read the current on-chain ERC-20 allowance, throwing on any RPC failure
|
|
1091
1145
|
* instead of masking it. checkErc20Allowance below wraps this with a
|
|
@@ -2537,7 +2591,16 @@ EXAMPLES:
|
|
|
2537
2591
|
continue;
|
|
2538
2592
|
}
|
|
2539
2593
|
} else {
|
|
2540
|
-
|
|
2594
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
2595
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
2596
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
2597
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
2598
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
2599
|
+
// any bridge fee above the ceiling.
|
|
2600
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
2601
|
+
&& isBridgeRequest(quoteData.request)
|
|
2602
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
2603
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
2541
2604
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
2542
2605
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2543
2606
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
@@ -2713,30 +2776,9 @@ EXAMPLES:
|
|
|
2713
2776
|
}
|
|
2714
2777
|
}
|
|
2715
2778
|
|
|
2716
|
-
// Gas resolution — fall back to eth_estimateGas if quote has no gas
|
|
2717
2779
|
const txData = currentQuote.transaction;
|
|
2718
|
-
const
|
|
2719
|
-
|
|
2720
|
-
let finalGas = apiGas > 0 ? apiGas : txGas;
|
|
2721
|
-
if (finalGas === 0) {
|
|
2722
|
-
try {
|
|
2723
|
-
const rpcUrl = CHAIN_RPCS[chain];
|
|
2724
|
-
const estRes = await fetch(rpcUrl, {
|
|
2725
|
-
method: 'POST',
|
|
2726
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2727
|
-
body: JSON.stringify({
|
|
2728
|
-
jsonrpc: '2.0', id: 1, method: 'eth_estimateGas',
|
|
2729
|
-
params: [{
|
|
2730
|
-
from: walletAddress, to: txData.to, data: txData.data || '0x',
|
|
2731
|
-
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
2732
|
-
}],
|
|
2733
|
-
}),
|
|
2734
|
-
});
|
|
2735
|
-
const estBody = await estRes.json();
|
|
2736
|
-
if (estBody.result) finalGas = Math.ceil(parseInt(estBody.result, 16) * 1.5);
|
|
2737
|
-
} catch { /* ignore */ }
|
|
2738
|
-
if (finalGas === 0) finalGas = 210000;
|
|
2739
|
-
}
|
|
2780
|
+
const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
|
|
2781
|
+
logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
|
|
2740
2782
|
|
|
2741
2783
|
log(' Fetching nonce...');
|
|
2742
2784
|
const nonce = await getEvmNonce(chain, walletAddress);
|
|
@@ -2904,7 +2946,16 @@ EXAMPLES:
|
|
|
2904
2946
|
continue;
|
|
2905
2947
|
}
|
|
2906
2948
|
} else {
|
|
2907
|
-
|
|
2949
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
2950
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
2951
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
2952
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
2953
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
2954
|
+
// any bridge fee above the ceiling.
|
|
2955
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
2956
|
+
&& isBridgeRequest(quoteData.request)
|
|
2957
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
2958
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
2908
2959
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
2909
2960
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2910
2961
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
@@ -3098,11 +3149,9 @@ EXAMPLES:
|
|
|
3098
3149
|
}
|
|
3099
3150
|
}
|
|
3100
3151
|
|
|
3101
|
-
// Resolve gas
|
|
3102
3152
|
const txData = currentQuote.transaction;
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
const finalGas = apiGas > 0 ? apiGas : txGas;
|
|
3153
|
+
const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: wcAddress });
|
|
3154
|
+
logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
|
|
3106
3155
|
|
|
3107
3156
|
// Send transaction via WalletConnect
|
|
3108
3157
|
log(' Sending transaction via WalletConnect...');
|
|
@@ -3232,7 +3281,16 @@ EXAMPLES:
|
|
|
3232
3281
|
continue;
|
|
3233
3282
|
}
|
|
3234
3283
|
} else {
|
|
3235
|
-
|
|
3284
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
3285
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
3286
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
3287
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
3288
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
3289
|
+
// any bridge fee above the ceiling.
|
|
3290
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
3291
|
+
&& isBridgeRequest(quoteData.request)
|
|
3292
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
3293
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
3236
3294
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
3237
3295
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
3238
3296
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
@@ -3405,16 +3463,9 @@ EXAMPLES:
|
|
|
3405
3463
|
}
|
|
3406
3464
|
}
|
|
3407
3465
|
|
|
3408
|
-
// Use the Trading API's gas estimation (quote.gas) directly.
|
|
3409
|
-
// The API already applies a 1.5x buffer over eth_estimateGas.
|
|
3410
|
-
// Skip client-side re-estimation — it adds latency and the API value is reliable.
|
|
3411
3466
|
const txData = currentQuote.transaction;
|
|
3412
|
-
const
|
|
3413
|
-
|
|
3414
|
-
const finalGas = apiGas > 0 ? apiGas : txGas;
|
|
3415
|
-
if (finalGas !== txGas) {
|
|
3416
|
-
log(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
|
|
3417
|
-
}
|
|
3467
|
+
const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
|
|
3468
|
+
logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
|
|
3418
3469
|
if (txData.gasLimit) txData.gasLimit = String(finalGas);
|
|
3419
3470
|
else txData.gas = String(finalGas);
|
|
3420
3471
|
|
package/src/wallet.js
CHANGED
|
@@ -281,27 +281,32 @@ function hashPassword(password) {
|
|
|
281
281
|
|
|
282
282
|
// ============= Prompt Helper =============
|
|
283
283
|
|
|
284
|
-
|
|
284
|
+
// Exported for testing (mirrors the exported `prompt` in cli.js). The streams
|
|
285
|
+
// are injectable so the masking behavior can be exercised without a real TTY.
|
|
286
|
+
export async function promptPassword(question, deps = {}, { input: inStream = process.stdin, output: outStream = process.stderr } = {}) {
|
|
285
287
|
const promptFn = deps.promptFn;
|
|
286
288
|
if (promptFn) {
|
|
287
289
|
return promptFn(question, true);
|
|
288
290
|
}
|
|
289
291
|
// Fallback to readline (only available in --human mode)
|
|
290
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
291
292
|
return new Promise((resolve) => {
|
|
292
|
-
|
|
293
|
-
|
|
293
|
+
// Gate on stdin, not stdout: raw-mode masking disables the terminal's own
|
|
294
|
+
// echo, so a redirected stdout (e.g. `wallet export > backup.json`) can no
|
|
295
|
+
// longer fall through to readline and echo the password in cleartext. Prompt
|
|
296
|
+
// and mask characters go to stderr so they stay on the terminal and never
|
|
297
|
+
// pollute — or leak into — a redirected stdout.
|
|
298
|
+
if (inStream.isTTY) {
|
|
299
|
+
outStream.write(question);
|
|
294
300
|
let input = '';
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
301
|
+
inStream.setRawMode(true);
|
|
302
|
+
inStream.resume();
|
|
303
|
+
inStream.setEncoding('utf8');
|
|
298
304
|
const onData = (char) => {
|
|
299
305
|
if (char === '\n' || char === '\r') {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
rl.close();
|
|
306
|
+
inStream.setRawMode(false);
|
|
307
|
+
inStream.pause();
|
|
308
|
+
inStream.removeListener('data', onData);
|
|
309
|
+
outStream.write('\n');
|
|
305
310
|
resolve(input);
|
|
306
311
|
} else if (char === '\u0003') {
|
|
307
312
|
process.exit();
|
|
@@ -309,11 +314,12 @@ async function promptPassword(question, deps = {}) {
|
|
|
309
314
|
input = input.slice(0, -1);
|
|
310
315
|
} else {
|
|
311
316
|
input += char;
|
|
312
|
-
|
|
317
|
+
outStream.write('*');
|
|
313
318
|
}
|
|
314
319
|
};
|
|
315
|
-
|
|
320
|
+
inStream.on('data', onData);
|
|
316
321
|
} else {
|
|
322
|
+
const rl = readline.createInterface({ input: inStream, output: outStream });
|
|
317
323
|
rl.question(question, (answer) => { rl.close(); resolve(answer); });
|
|
318
324
|
}
|
|
319
325
|
});
|
|
@@ -1039,6 +1045,8 @@ ENVIRONMENT:
|
|
|
1039
1045
|
NANSEN_EVM_RPC Custom Ethereum RPC endpoint (also generic EVM fallback)
|
|
1040
1046
|
NANSEN_BASE_RPC Custom Base RPC endpoint
|
|
1041
1047
|
NANSEN_SOLANA_RPC Custom Solana RPC endpoint
|
|
1048
|
+
NANSEN_X402_MAX_AMOUNT Max USD per x402 auto-payment (default 1.00; "unlimited" to disable)
|
|
1049
|
+
NANSEN_X402_ALLOWED_PAYTO Comma-separated recipient allowlist for x402 auto-payment (optional)
|
|
1042
1050
|
|
|
1043
1051
|
EXAMPLES:
|
|
1044
1052
|
NANSEN_WALLET_PASSWORD=mypass nansen wallet create --name trading
|
|
@@ -9,6 +9,7 @@ import crypto from 'crypto';
|
|
|
9
9
|
import { NansenError, ErrorCode } from './api.js';
|
|
10
10
|
import { wcExec } from './walletconnect-exec.js';
|
|
11
11
|
import { EVM_CHAIN_IDS } from './chain-ids.js';
|
|
12
|
+
import { evaluatePaymentRequirement, resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Check if a WalletConnect wallet session is active.
|
|
@@ -52,9 +53,9 @@ function parseChainId(network) {
|
|
|
52
53
|
* Build EIP-712 typed data for TransferWithAuthorization (EIP-3009).
|
|
53
54
|
*/
|
|
54
55
|
export function buildEIP712TypedData({ fromAddress, requirement }) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const amount = requirement
|
|
56
|
+
const payTo = resolvePayTo(requirement);
|
|
57
|
+
const { asset, extra, maxTimeoutSeconds } = requirement;
|
|
58
|
+
const amount = resolvePaymentAmount(requirement);
|
|
58
59
|
|
|
59
60
|
// Determine chain ID: extra.chainId > parsed from network > fallback map > base
|
|
60
61
|
const chainId = extra.chainId || parseChainId(requirement.network) || EVM_CHAIN_IDS[requirement.chain] || EVM_CHAIN_IDS.base;
|
|
@@ -121,7 +122,7 @@ export function buildPaymentSignatureHeader({ signature, authorization, resource
|
|
|
121
122
|
*/
|
|
122
123
|
function formatPaymentAmount(requirement) {
|
|
123
124
|
const { extra } = requirement;
|
|
124
|
-
const rawAmount = requirement
|
|
125
|
+
const rawAmount = resolvePaymentAmount(requirement);
|
|
125
126
|
const symbol = extra.symbol || extra.name || 'tokens';
|
|
126
127
|
const decimals = extra.decimals || 6;
|
|
127
128
|
const amount = Number(rawAmount) / Math.pow(10, decimals);
|
|
@@ -171,15 +172,21 @@ export async function handleX402Payment(paymentRequirements) {
|
|
|
171
172
|
);
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
// 3.
|
|
175
|
+
// 3. Evaluate payment policy before touching any signing material
|
|
176
|
+
const decision = evaluatePaymentRequirement(requirement);
|
|
177
|
+
if (!decision.ok) {
|
|
178
|
+
throw new Error(decision.reason);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 4. Build EIP-712 typed data
|
|
175
182
|
const typedData = buildEIP712TypedData({ fromAddress, requirement });
|
|
176
183
|
const typedDataJson = JSON.stringify(typedData);
|
|
177
184
|
|
|
178
|
-
//
|
|
185
|
+
// 5. Log payment info to stderr (stdout is for JSON output)
|
|
179
186
|
const amountStr = formatPaymentAmount(requirement);
|
|
180
187
|
process.stderr.write(`x402: Requesting payment approval (${amountStr})...\n`);
|
|
181
188
|
|
|
182
|
-
//
|
|
189
|
+
// 6. Sign via walletconnect CLI (120s timeout for user approval)
|
|
183
190
|
let signResult;
|
|
184
191
|
try {
|
|
185
192
|
const output = await wcExec('walletconnect', ['sign-typed-data', typedDataJson], 120000);
|
|
@@ -195,11 +202,11 @@ export async function handleX402Payment(paymentRequirements) {
|
|
|
195
202
|
);
|
|
196
203
|
}
|
|
197
204
|
|
|
198
|
-
//
|
|
205
|
+
// 7. Build Payment-Signature header (authorization values must be strings per x402 spec)
|
|
199
206
|
const authorization = {
|
|
200
207
|
from: fromAddress,
|
|
201
|
-
to: requirement
|
|
202
|
-
value: (requirement
|
|
208
|
+
to: resolvePayTo(requirement),
|
|
209
|
+
value: resolvePaymentAmount(requirement).toString(),
|
|
203
210
|
validAfter: typedData.message.validAfter.toString(),
|
|
204
211
|
validBefore: typedData.message.validBefore.toString(),
|
|
205
212
|
nonce: typedData.message.nonce,
|
package/src/x402-evm.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import crypto from 'crypto';
|
|
8
8
|
import { keccak256, signSecp256k1 } from './crypto.js';
|
|
9
|
+
import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
|
|
9
10
|
|
|
10
11
|
// ============= EIP-712 Type Hashing =============
|
|
11
12
|
|
|
@@ -229,6 +230,7 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
|
|
|
229
230
|
const now = Math.floor(Date.now() / 1000);
|
|
230
231
|
const validAfter = '0';
|
|
231
232
|
const validBefore = String(now + 3600);
|
|
233
|
+
const amount = resolvePaymentAmount(requirements);
|
|
232
234
|
|
|
233
235
|
// EIP-712 domain
|
|
234
236
|
const domain = {
|
|
@@ -241,8 +243,8 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
|
|
|
241
243
|
// EIP-3009 message
|
|
242
244
|
const message = {
|
|
243
245
|
from: walletAddress,
|
|
244
|
-
to: requirements
|
|
245
|
-
value: BigInt(
|
|
246
|
+
to: resolvePayTo(requirements),
|
|
247
|
+
value: BigInt(amount),
|
|
246
248
|
validAfter: BigInt(validAfter),
|
|
247
249
|
validBefore: BigInt(validBefore),
|
|
248
250
|
nonce: nonce,
|
|
@@ -260,7 +262,7 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
|
|
|
260
262
|
authorization: {
|
|
261
263
|
from: walletAddress,
|
|
262
264
|
to: message.to,
|
|
263
|
-
value: String(
|
|
265
|
+
value: String(amount),
|
|
264
266
|
validAfter: validAfter,
|
|
265
267
|
validBefore: validBefore,
|
|
266
268
|
nonce: nonce,
|
|
@@ -301,15 +303,16 @@ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAdd
|
|
|
301
303
|
throw new Error('spenderAddress missing from requirements.extra (required for permit2-exact)');
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
const payTo = requirements
|
|
306
|
+
const payTo = resolvePayTo(requirements);
|
|
305
307
|
const now = Math.floor(Date.now() / 1000);
|
|
306
308
|
// 256-bit random nonce — Permit2 uses an unordered nonce bitmap.
|
|
307
309
|
const nonce = BigInt('0x' + crypto.randomBytes(32).toString('hex')).toString();
|
|
308
310
|
const deadline = String(now + 3600);
|
|
309
311
|
const validAfter = String(now - 60); // allow clock skew
|
|
312
|
+
const amount = resolvePaymentAmount(requirements);
|
|
310
313
|
|
|
311
314
|
const message = {
|
|
312
|
-
permitted: { token: requirements.asset, amount: BigInt(
|
|
315
|
+
permitted: { token: requirements.asset, amount: BigInt(amount) },
|
|
313
316
|
spender,
|
|
314
317
|
nonce: BigInt(nonce),
|
|
315
318
|
deadline: BigInt(deadline),
|
|
@@ -324,7 +327,7 @@ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAdd
|
|
|
324
327
|
x402Version: 2,
|
|
325
328
|
payload: {
|
|
326
329
|
permit2Authorization: {
|
|
327
|
-
permitted: { token: requirements.asset, amount: String(
|
|
330
|
+
permitted: { token: requirements.asset, amount: String(amount) },
|
|
328
331
|
from: walletAddress,
|
|
329
332
|
spender,
|
|
330
333
|
nonce,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 payment policy guard.
|
|
3
|
+
* Decides whether an auto-payment is safe to sign before any signature is produced.
|
|
4
|
+
* Three layers: known-asset allowlist, optional payTo allowlist, per-payment USD cap.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EVM_X402_TOKENS, SVM_X402_TOKENS } from './x402-tokens.js';
|
|
8
|
+
import { getWalletConfig } from './wallet.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* True for the exact Solana CAIP-2 network prefix ("solana:..."), matching
|
|
12
|
+
* isSvmNetwork() in x402-svm.js. Duplicated here (rather than imported) to
|
|
13
|
+
* avoid a circular dependency: x402-svm.js imports resolvePaymentAmount from
|
|
14
|
+
* this module.
|
|
15
|
+
*/
|
|
16
|
+
function isSvmNetworkString(network) {
|
|
17
|
+
return typeof network === 'string' && network.startsWith('solana:');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Look up the known token entry for a (network, asset) pair.
|
|
22
|
+
* Returns { token, symbol, decimals } or null if the pair is not a known
|
|
23
|
+
* Nansen x402 payment asset.
|
|
24
|
+
*
|
|
25
|
+
* EVM addresses are compared case-insensitively (hex is case-insensitive
|
|
26
|
+
* modulo EIP-55 checksum casing). Solana mint addresses are base58 and
|
|
27
|
+
* MUST be compared case-sensitively — flipping the case of a base58 string
|
|
28
|
+
* decodes to different bytes entirely, not the same address in a different
|
|
29
|
+
* checksum casing.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveKnownToken(network, asset) {
|
|
32
|
+
if (typeof network !== 'string' || typeof asset !== 'string') return null;
|
|
33
|
+
if (network.startsWith('eip155:')) {
|
|
34
|
+
const table = EVM_X402_TOKENS[network];
|
|
35
|
+
if (!table) return null;
|
|
36
|
+
return table.find(t => t.token.toLowerCase() === asset.toLowerCase()) || null;
|
|
37
|
+
}
|
|
38
|
+
if (isSvmNetworkString(network)) {
|
|
39
|
+
const table = SVM_X402_TOKENS['solana'];
|
|
40
|
+
if (!table) return null;
|
|
41
|
+
return table.find(t => t.token === asset) || null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default per-payment ceiling in USD. x402 API calls cost cents; $1.00 is a
|
|
47
|
+
// conservative safety ceiling — raise NANSEN_X402_MAX_AMOUNT if legitimate
|
|
48
|
+
// calls exceed it (they should not for normal API usage).
|
|
49
|
+
export const DEFAULT_X402_MAX_AMOUNT_USD = 1.0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the per-payment USD cap.
|
|
53
|
+
* Precedence: NANSEN_X402_MAX_AMOUNT env var > wallet config x402MaxAmount > default.
|
|
54
|
+
* Returns a positive number, or Infinity when explicitly set to "unlimited".
|
|
55
|
+
*/
|
|
56
|
+
export function resolveMaxAmountUsd() {
|
|
57
|
+
const env = process.env.NANSEN_X402_MAX_AMOUNT;
|
|
58
|
+
// An empty/whitespace-only value (e.g. an exported-but-unset shell var) must
|
|
59
|
+
// be treated as unset, not as Number('') === 0 — a $0.00 cap would refuse
|
|
60
|
+
// every real payment.
|
|
61
|
+
if (env !== undefined && env.trim() !== '') {
|
|
62
|
+
if (env.trim().toLowerCase() === 'unlimited') return Infinity;
|
|
63
|
+
const n = Number(env);
|
|
64
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
65
|
+
// fall through on garbage value
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const cfg = getWalletConfig();
|
|
69
|
+
if (cfg && cfg.x402MaxAmount !== undefined) {
|
|
70
|
+
const n = Number(cfg.x402MaxAmount);
|
|
71
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* no config yet */ }
|
|
74
|
+
return DEFAULT_X402_MAX_AMOUNT_USD;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional payTo allowlist.
|
|
79
|
+
* If NANSEN_X402_ALLOWED_PAYTO is set (comma-separated addresses), only those
|
|
80
|
+
* recipients may be paid. Unset = no recipient restriction.
|
|
81
|
+
*
|
|
82
|
+
* Comparison is case-insensitive for EVM (hex) but case-sensitive for Solana
|
|
83
|
+
* (base58) — see resolveKnownToken for why. `network` is required to know
|
|
84
|
+
* which rule applies; omit it only for EVM-style (case-insensitive) checks.
|
|
85
|
+
*/
|
|
86
|
+
export function isPayToAllowed(payTo, network) {
|
|
87
|
+
const raw = process.env.NANSEN_X402_ALLOWED_PAYTO;
|
|
88
|
+
if (!raw) return true;
|
|
89
|
+
const caseSensitive = isSvmNetworkString(network);
|
|
90
|
+
const normalize = (s) => (caseSensitive ? s.trim() : s.trim().toLowerCase());
|
|
91
|
+
const allow = raw.split(',').map(normalize).filter(Boolean);
|
|
92
|
+
if (allow.length === 0) return true;
|
|
93
|
+
return typeof payTo === 'string' && allow.includes(normalize(payTo));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the payment amount field, preferring `amount` over the legacy
|
|
98
|
+
* `maxAmountRequired` alias (older x402 implementations only send the latter).
|
|
99
|
+
* A present-but-empty `amount` (e.g. "") is treated as missing so it falls
|
|
100
|
+
* through to `maxAmountRequired` here too — the one place this decision is
|
|
101
|
+
* made. Signers must call this instead of re-deriving the fallback themselves;
|
|
102
|
+
* a mismatched `??` vs `||` between here and a signer is exactly the
|
|
103
|
+
* amount-substitution bypass this guard exists to prevent.
|
|
104
|
+
*/
|
|
105
|
+
export function resolvePaymentAmount(requirement) {
|
|
106
|
+
const amount = requirement.amount;
|
|
107
|
+
if (amount !== undefined && amount !== null && amount !== '') return amount;
|
|
108
|
+
return requirement.maxAmountRequired;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the payment recipient field, preferring `payTo` (camelCase) over
|
|
113
|
+
* the legacy `pay_to` (snake_case) alias. A present-but-empty `payTo` (e.g.
|
|
114
|
+
* "") is treated as missing, exactly like resolvePaymentAmount — the same
|
|
115
|
+
* single-source-of-truth rule signers must use instead of re-deriving the
|
|
116
|
+
* fallback themselves.
|
|
117
|
+
*/
|
|
118
|
+
export function resolvePayTo(requirement) {
|
|
119
|
+
const payTo = requirement.payTo;
|
|
120
|
+
if (payTo !== undefined && payTo !== null && payTo !== '') return payTo;
|
|
121
|
+
return requirement.pay_to;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Decide whether an x402 payment requirement is safe to auto-sign.
|
|
126
|
+
* Returns { ok: true, usd, symbol } when allowed, or { ok: false, reason }
|
|
127
|
+
* with a human-readable, actionable reason when refused.
|
|
128
|
+
*
|
|
129
|
+
* Enforces in order: known (network, asset) allowlist, optional payTo
|
|
130
|
+
* allowlist, per-payment USD cap. Never signs; pure decision.
|
|
131
|
+
*/
|
|
132
|
+
export function evaluatePaymentRequirement(requirement) {
|
|
133
|
+
const network = requirement.network;
|
|
134
|
+
const asset = requirement.asset;
|
|
135
|
+
const payTo = resolvePayTo(requirement);
|
|
136
|
+
const amountRaw = resolvePaymentAmount(requirement);
|
|
137
|
+
|
|
138
|
+
const known = resolveKnownToken(network, asset);
|
|
139
|
+
if (!known) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason:
|
|
143
|
+
`Refusing to auto-pay: asset ${asset} on ${network} is not a recognized ` +
|
|
144
|
+
`Nansen payment token. No signature was produced.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (payTo === undefined || payTo === null || payTo === '') {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: 'Refusing to auto-pay: payTo field is missing from the payment requirement.',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!isPayToAllowed(payTo, network)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: `Refusing to auto-pay: recipient ${payTo} is not in NANSEN_X402_ALLOWED_PAYTO.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (amountRaw === undefined || amountRaw === null) {
|
|
163
|
+
return { ok: false, reason: 'Refusing to auto-pay: amount field is missing from the payment requirement.' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let usd;
|
|
167
|
+
try {
|
|
168
|
+
// BigInt base units → USD. Keep integer/fraction split to avoid float loss
|
|
169
|
+
// on large values; final Number() is safe for display-scale amounts.
|
|
170
|
+
const base = BigInt(amountRaw);
|
|
171
|
+
// A transfer authorization must be for a positive amount; a negative value
|
|
172
|
+
// is never legitimate and should never be signed.
|
|
173
|
+
if (base < 0n) {
|
|
174
|
+
return { ok: false, reason: `Refusing to auto-pay: negative amount ${amountRaw}.` };
|
|
175
|
+
}
|
|
176
|
+
const scale = 10n ** BigInt(known.decimals);
|
|
177
|
+
// Number arithmetic is precise enough for stablecoin amounts at 6 or 18
|
|
178
|
+
// decimals against a dollar-scale cap. Would need BigInt-native comparison
|
|
179
|
+
// if supported decimals or cap magnitudes change significantly.
|
|
180
|
+
usd = Number(base / scale) + Number(base % scale) / Number(scale);
|
|
181
|
+
} catch {
|
|
182
|
+
return { ok: false, reason: `Refusing to auto-pay: unparseable amount ${amountRaw}.` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Cap is inclusive: an amount exactly at the cap is allowed (usd > cap, not >=).
|
|
186
|
+
// Note a cap of 0 still permits a zero-value payment (0 > 0 is false); use
|
|
187
|
+
// NANSEN_X402_ALLOWED_PAYTO or an unfunded wallet to block signing entirely.
|
|
188
|
+
const cap = resolveMaxAmountUsd();
|
|
189
|
+
if (usd > cap) {
|
|
190
|
+
const capStr = Number.isFinite(cap) ? `$${cap.toFixed(2)}` : 'unlimited';
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason:
|
|
194
|
+
`Refusing to auto-pay $${usd.toFixed(2)} ${known.symbol}: exceeds the ` +
|
|
195
|
+
`${capStr} per-payment cap. To authorize, raise it with ` +
|
|
196
|
+
`NANSEN_X402_MAX_AMOUNT=<usd> (or NANSEN_X402_MAX_AMOUNT=unlimited to disable).`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { ok: true, usd, symbol: known.symbol };
|
|
201
|
+
}
|
package/src/x402-svm.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { base58Encode, base58DecodePubkey } from './wallet.js';
|
|
8
8
|
import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js';
|
|
9
|
+
import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
|
|
9
10
|
|
|
10
11
|
// ============= Constants =============
|
|
11
12
|
|
|
@@ -180,8 +181,8 @@ export function buildUnsignedSvmTransaction(
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
const mint = requirements.asset;
|
|
183
|
-
const amount = BigInt(requirements
|
|
184
|
-
const payTo = requirements
|
|
184
|
+
const amount = BigInt(resolvePaymentAmount(requirements));
|
|
185
|
+
const payTo = resolvePayTo(requirements);
|
|
185
186
|
|
|
186
187
|
// Derive ATAs
|
|
187
188
|
const sourceATA = deriveATA(walletAddress, mint, tokenProgram);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known x402 payment tokens per network.
|
|
3
|
+
* Leaf module — imported by both x402.js and x402-policy.js to avoid circular deps.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Known payment tokens per EVM network (eip155:<chainId>).
|
|
7
|
+
// decimals is per token — BSC stablecoins are 18-decimal BEP-20 deployments,
|
|
8
|
+
// unlike the 6-decimal tokens on Base and X Layer.
|
|
9
|
+
export const EVM_X402_TOKENS = {
|
|
10
|
+
'eip155:8453': [
|
|
11
|
+
{ token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6 }, // Base USDC
|
|
12
|
+
],
|
|
13
|
+
'eip155:196': [
|
|
14
|
+
{ token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', symbol: 'USDT0', decimals: 6 }, // X Layer USDT0
|
|
15
|
+
],
|
|
16
|
+
'eip155:56': [
|
|
17
|
+
{ token: '0xcE24439F2D9C6a2289F741120FE202248B666666', symbol: 'U', decimals: 18 }, // United Stables
|
|
18
|
+
{ token: '0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d', symbol: 'USD1', decimals: 18 }, // World Liberty Financial USD
|
|
19
|
+
{ token: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }, // Tether USD
|
|
20
|
+
{ token: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', decimals: 18 }, // Binance-Peg USD Coin
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Known payment tokens on Solana.
|
|
25
|
+
export const SVM_X402_TOKENS = {
|
|
26
|
+
solana: [
|
|
27
|
+
{ token: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', symbol: 'USDC', decimals: 6 },
|
|
28
|
+
],
|
|
29
|
+
};
|