nansen-cli 1.41.0 → 1.42.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 +47 -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 +12 -2
- package/src/schema.json +55 -0
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +185 -37
- package/src/trading.js +208 -31
- package/src/wallet.js +20 -14
- package/src/x402-svm.js +9 -5
package/src/trading.js
CHANGED
|
@@ -9,11 +9,12 @@ import crypto from 'crypto';
|
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import { base58Encode, exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js';
|
|
12
|
-
import { base58Decode } from './transfer.js';
|
|
12
|
+
import { base58Decode, encodeCompactU16 } from './transfer.js';
|
|
13
|
+
import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js';
|
|
13
14
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
15
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
16
|
import { retrievePassword } from './keychain.js';
|
|
16
|
-
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';
|
|
17
18
|
import { readCompactU16 } from './solana-tx.js';
|
|
18
19
|
export { readCompactU16 };
|
|
19
20
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
@@ -25,6 +26,8 @@ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './ap
|
|
|
25
26
|
|
|
26
27
|
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
27
28
|
const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`;
|
|
29
|
+
// Solana's max transaction wire size (IPv6 MTU minus headers).
|
|
30
|
+
const SOLANA_MAX_TX_SIZE = 1232;
|
|
28
31
|
|
|
29
32
|
const CHAIN_MAP = {
|
|
30
33
|
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
|
|
@@ -509,6 +512,107 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
|
|
|
509
512
|
return signedTx.toString('base64');
|
|
510
513
|
}
|
|
511
514
|
|
|
515
|
+
// Any valid base58 32-byte value works here — recentBlockhash is fixed-size
|
|
516
|
+
// regardless of its actual value, so this is exact for a size-only preflight
|
|
517
|
+
// and lets the signer/signature-count checks below run before the real
|
|
518
|
+
// blockhash fetch (no wasted RPC round trip on a request we're going to reject).
|
|
519
|
+
const SIZE_CHECK_BLOCKHASH = '11111111111111111111111111111111';
|
|
520
|
+
|
|
521
|
+
function decodeInstructionData(hex) {
|
|
522
|
+
if (hex == null || hex === '') return Buffer.alloc(0); // some instructions legitimately carry no data
|
|
523
|
+
const body = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
524
|
+
// Buffer.from(str, 'hex') silently drops a trailing odd nibble and stops at
|
|
525
|
+
// the first non-hex character, so it would decode malformed data into a
|
|
526
|
+
// plausible-but-wrong instruction that then gets signed. Reject instead.
|
|
527
|
+
if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
|
|
528
|
+
throw new Error(`Cannot compile Solana transaction: instruction data is not valid hex ("${hex}")`);
|
|
529
|
+
}
|
|
530
|
+
return Buffer.from(body, 'hex');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Compile a raw, uncompiled Solana transaction — {instructions, addressLookupTableAddresses}
|
|
535
|
+
* — into a signable base64 VersionedTransaction. Some aggregators (Relay's Solana-source
|
|
536
|
+
* bridge quotes) return this shape instead of a ready-to-sign serialized transaction.
|
|
537
|
+
*
|
|
538
|
+
* Every account is kept static; the address-lookup-table hint is a size optimization,
|
|
539
|
+
* not a correctness requirement, so skipping it is valid as long as the compiled
|
|
540
|
+
* transaction still fits Solana's packet limit. Full lookup-table compilation is
|
|
541
|
+
* unimplemented — throws instead of silently building an oversized/invalid transaction.
|
|
542
|
+
*
|
|
543
|
+
* getExpectedSigner is an async thunk resolving to the address of the wallet that is
|
|
544
|
+
* about to sign. The transaction only ever gets a single signature written into slot 0
|
|
545
|
+
* (see signSolanaTransaction / the WalletConnect injection path), so the instructions'
|
|
546
|
+
* own declared signer must both be unambiguous (exactly one signer) and match that
|
|
547
|
+
* wallet — otherwise the transaction would silently sign the wrong account or leave a
|
|
548
|
+
* required signature slot empty, failing on-chain with an opaque error.
|
|
549
|
+
*/
|
|
550
|
+
export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
|
|
551
|
+
const instructions = transaction.instructions.map(ix => {
|
|
552
|
+
if (!Array.isArray(ix.keys)) {
|
|
553
|
+
throw new Error('Cannot compile Solana transaction: instruction is missing its "keys" accounts list');
|
|
554
|
+
}
|
|
555
|
+
return { programId: ix.programId, accounts: ix.keys, data: decodeInstructionData(ix.data) };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const feePayer = instructions.flatMap(ix => ix.accounts).find(a => a.isSigner)?.pubkey;
|
|
559
|
+
if (!feePayer) {
|
|
560
|
+
throw new Error('Cannot compile Solana transaction: no signer account found in instructions');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const expectedSigner = await getExpectedSigner();
|
|
564
|
+
if (!expectedSigner) {
|
|
565
|
+
throw new Error('Cannot compile Solana transaction: wallet address unavailable to verify the signer');
|
|
566
|
+
}
|
|
567
|
+
if (feePayer !== expectedSigner) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`Solana transaction signer (${feePayer}) doesn't match the wallet executing this trade ` +
|
|
570
|
+
`(${expectedSigner}). Refusing to sign — get a new quote.`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH });
|
|
575
|
+
if (preflight.numRequiredSignatures !== 1) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Cannot compile Solana transaction: requires ${preflight.numRequiredSignatures} signatures, ` +
|
|
578
|
+
`but only the wallet's own signature can be provided.`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const unsignedSize = 1 + 64 + preflight.messageBytes.length; // compact-u16(1) + 1 signature slot
|
|
582
|
+
if (unsignedSize > SOLANA_MAX_TX_SIZE) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Solana transaction too large to compile without address-lookup-table support ` +
|
|
585
|
+
`(${unsignedSize} bytes > ${SOLANA_MAX_TX_SIZE} limit). This route needs its ` +
|
|
586
|
+
`address lookup tables resolved, which isn't supported yet.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
|
|
591
|
+
const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash });
|
|
592
|
+
const unsignedTx = Buffer.concat([encodeCompactU16(1), Buffer.alloc(64), messageBytes]);
|
|
593
|
+
return unsignedTx.toString('base64');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Normalize a Solana quote's `transaction` field to a base64-encoded, ready-to-sign
|
|
598
|
+
* VersionedTransaction. Three shapes seen across aggregators: Jupiter (already base64),
|
|
599
|
+
* OKX ({data: base58}), and Relay bridge quotes (raw uncompiled
|
|
600
|
+
* {instructions, addressLookupTableAddresses} — compiled client-side).
|
|
601
|
+
*
|
|
602
|
+
* getExpectedSigner (only consulted for the Relay shape) is an async thunk resolving to
|
|
603
|
+
* the signing wallet's address — see compileRawSolanaTransaction.
|
|
604
|
+
*/
|
|
605
|
+
export async function normalizeSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
|
|
606
|
+
if (typeof transaction === 'string') return transaction; // Jupiter: already base64
|
|
607
|
+
// Dispatch most-specific shape first. Only Relay carries `instructions` and
|
|
608
|
+
// only OKX carries `data`; checking `instructions` ahead of the bare
|
|
609
|
+
// `data` truthiness test keeps a future Relay shape that also had a `data`
|
|
610
|
+
// field from being mis-routed into the OKX base58 decode.
|
|
611
|
+
if (Array.isArray(transaction.instructions)) return compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner);
|
|
612
|
+
if (transaction.data) return base58Decode(transaction.data).toString('base64'); // OKX: base58 serialized tx
|
|
613
|
+
throw new Error('Unrecognized Solana transaction format in quote');
|
|
614
|
+
}
|
|
615
|
+
|
|
512
616
|
/**
|
|
513
617
|
* Sign an EVM transaction from quote data.
|
|
514
618
|
*
|
|
@@ -853,10 +957,12 @@ function toRpcHexValue(value) {
|
|
|
853
957
|
* guards: the cheap eth_call sim answers "will it revert", this answers "does the
|
|
854
958
|
* outcome match intent" (see assertSwapOutcome in trade-validation.js).
|
|
855
959
|
*
|
|
856
|
-
* EVM-only, and on its own gate independent of --no-simulate/gasless.
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
960
|
+
* EVM-only, and on its own gate independent of --no-simulate/gasless. Runs for
|
|
961
|
+
* cross-chain bridges too — assertSwapOutcome skips only the output-arrival
|
|
962
|
+
* assertion internally, since the output lands on the destination chain and a
|
|
963
|
+
* source-chain simulation can't observe it; the input-outflow and no-sibling-
|
|
964
|
+
* drain assertions still bound the source-chain leg. When no simulation-capable
|
|
965
|
+
* endpoint is configured it DEGRADES — logs a warning, then proceeds — so a simulation
|
|
860
966
|
* outage never blocks trading. --no-verify-outcome skips it entirely.
|
|
861
967
|
*
|
|
862
968
|
* Returns { proceed, reason }. proceed=false means this quote failed
|
|
@@ -874,11 +980,12 @@ function toRpcHexValue(value) {
|
|
|
874
980
|
*/
|
|
875
981
|
export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
|
|
876
982
|
if (CHAIN_MAP[chain?.toLowerCase()]?.type !== 'evm') return { proceed: true }; // EVM-only
|
|
877
|
-
// Cross-chain: the output token settles on the destination chain,
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
983
|
+
// Cross-chain (bridge): the output token settles on the destination chain,
|
|
984
|
+
// so the source-chain simulation still runs but assertSwapOutcome skips
|
|
985
|
+
// only the output-arrival assertion internally (isBridge, derived from
|
|
986
|
+
// quoteData.request). The input-outflow cap and no-sibling-drain checks
|
|
987
|
+
// still bound the source-chain leg.
|
|
988
|
+
|
|
882
989
|
// No request intent recorded (a pre-intent quote): assertSwapOutcome has
|
|
883
990
|
// nothing to compare the simulated deltas against and would raise a misleading
|
|
884
991
|
// SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
|
|
@@ -901,7 +1008,21 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
901
1008
|
{ to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
|
|
902
1009
|
{ from, apiKey },
|
|
903
1010
|
);
|
|
904
|
-
|
|
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 });
|
|
1023
|
+
if (outcome.outputAssertionSkipped) {
|
|
1024
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
1025
|
+
}
|
|
905
1026
|
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
906
1027
|
return { proceed: true };
|
|
907
1028
|
} catch (e) {
|
|
@@ -925,9 +1046,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
925
1046
|
*/
|
|
926
1047
|
export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
|
|
927
1048
|
if (chain !== 'solana') return { proceed: true };
|
|
928
|
-
// Cross-chain: the output settles on the destination chain
|
|
929
|
-
//
|
|
930
|
-
|
|
1049
|
+
// Cross-chain (bridge): the output settles on the destination chain, so
|
|
1050
|
+
// the source-chain simulation still runs but assertSolanaSwapOutcome skips
|
|
1051
|
+
// only the output-arrival assertion internally (mirrors the EVM path above).
|
|
1052
|
+
|
|
931
1053
|
if (!quoteData?.request) {
|
|
932
1054
|
log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
|
|
933
1055
|
return { proceed: true };
|
|
@@ -940,7 +1062,14 @@ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64,
|
|
|
940
1062
|
const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
|
|
941
1063
|
const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
|
|
942
1064
|
if (outcome.inputAssertionSkipped) {
|
|
943
|
-
|
|
1065
|
+
// On a native-SOL bridge the output assertion did NOT run (it settles on
|
|
1066
|
+
// the destination chain), so don't claim "output ... checks still ran" —
|
|
1067
|
+
// that would contradict the bridge line logged just below.
|
|
1068
|
+
const alsoRan = outcome.outputAssertionSkipped ? 'sibling checks still ran' : 'output and sibling checks still ran';
|
|
1069
|
+
log(` ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; ${alsoRan}.`);
|
|
1070
|
+
}
|
|
1071
|
+
if (outcome.outputAssertionSkipped) {
|
|
1072
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
944
1073
|
}
|
|
945
1074
|
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
946
1075
|
return { proceed: true };
|
|
@@ -1148,6 +1277,14 @@ export function assertCompleteEvmRequestIntent(request) {
|
|
|
1148
1277
|
if (missing.length) {
|
|
1149
1278
|
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
1150
1279
|
}
|
|
1280
|
+
// swapMode must be a recognized mode, not merely present. This runs
|
|
1281
|
+
// unconditionally before signing — unlike the swap-outcome verifier, which is
|
|
1282
|
+
// skipped by --no-verify-outcome or when the sim RPC degrades — so a corrupted
|
|
1283
|
+
// or edited quote record with a garbage swapMode fails closed regardless of
|
|
1284
|
+
// the outcome-verification path.
|
|
1285
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1286
|
+
throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
1287
|
+
}
|
|
1151
1288
|
}
|
|
1152
1289
|
|
|
1153
1290
|
/**
|
|
@@ -1170,6 +1307,12 @@ export function assertCompleteSolanaRequestIntent(request) {
|
|
|
1170
1307
|
if (missing.length) {
|
|
1171
1308
|
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1172
1309
|
}
|
|
1310
|
+
// swapMode must be a recognized mode, not merely present — see the EVM sibling.
|
|
1311
|
+
// Runs unconditionally before signing, so a garbage swapMode fails closed even
|
|
1312
|
+
// when the swap-outcome verifier is skipped or degraded.
|
|
1313
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1314
|
+
throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1315
|
+
}
|
|
1173
1316
|
}
|
|
1174
1317
|
|
|
1175
1318
|
/**
|
|
@@ -1718,6 +1861,12 @@ export function buildTradingCommands(deps = {}) {
|
|
|
1718
1861
|
const autoSlippage = flags['auto-slippage'];
|
|
1719
1862
|
const maxAutoSlippage = options['max-auto-slippage'];
|
|
1720
1863
|
const swapMode = options['swap-mode'] || 'exactIn';
|
|
1864
|
+
if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
|
|
1865
|
+
throw new CommandError(
|
|
1866
|
+
`Invalid --swap-mode: "${swapMode}". Use one of: exactIn, exactOut.`,
|
|
1867
|
+
'INVALID_INPUT',
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1721
1870
|
const amountUnit = options['amount-unit'];
|
|
1722
1871
|
const aggregatorFilter = options.aggregator;
|
|
1723
1872
|
if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
|
|
@@ -2311,10 +2460,6 @@ EXAMPLES:
|
|
|
2311
2460
|
|
|
2312
2461
|
if (chainType === 'solana' && isPrivy) {
|
|
2313
2462
|
// Solana via Privy: sign the serialized transaction
|
|
2314
|
-
let txBase64 = currentQuote.transaction;
|
|
2315
|
-
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
2316
|
-
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2317
|
-
}
|
|
2318
2463
|
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
2319
2464
|
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
2320
2465
|
const walletResult = await privyClient.getWallet(solWalletId);
|
|
@@ -2328,6 +2473,11 @@ EXAMPLES:
|
|
|
2328
2473
|
throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
|
|
2329
2474
|
}
|
|
2330
2475
|
|
|
2476
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2477
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2478
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2479
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress);
|
|
2480
|
+
|
|
2331
2481
|
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2332
2482
|
// signer) before signing the aggregator's serialized transaction.
|
|
2333
2483
|
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
@@ -2398,7 +2548,16 @@ EXAMPLES:
|
|
|
2398
2548
|
continue;
|
|
2399
2549
|
}
|
|
2400
2550
|
} else {
|
|
2401
|
-
|
|
2551
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
2552
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
2553
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
2554
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
2555
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
2556
|
+
// any bridge fee above the ceiling.
|
|
2557
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
2558
|
+
&& isBridgeRequest(quoteData.request)
|
|
2559
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
2560
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
2402
2561
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
2403
2562
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2404
2563
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
@@ -2626,15 +2785,13 @@ EXAMPLES:
|
|
|
2626
2785
|
// below binds the metadata (token pair, amounts, signer), and
|
|
2627
2786
|
// assertSolanaInstructionsSafe statically inspects the tx's own
|
|
2628
2787
|
// instructions before signing.
|
|
2629
|
-
// Solana: transaction is
|
|
2630
|
-
//
|
|
2631
|
-
|
|
2632
|
-
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
2633
|
-
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2634
|
-
}
|
|
2788
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2789
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2790
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2635
2791
|
|
|
2636
|
-
// Resolve the signer
|
|
2637
|
-
//
|
|
2792
|
+
// Resolve the signer first — both the Relay-shape compiler (which needs
|
|
2793
|
+
// an expected signer for its fee-payer check) and the intent-binding
|
|
2794
|
+
// check below use this exact same address.
|
|
2638
2795
|
let solanaWalletAddress;
|
|
2639
2796
|
if (isWalletConnect) {
|
|
2640
2797
|
solanaWalletAddress = await getWalletConnectAddress(chainType);
|
|
@@ -2651,6 +2808,8 @@ EXAMPLES:
|
|
|
2651
2808
|
}
|
|
2652
2809
|
}
|
|
2653
2810
|
|
|
2811
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
|
|
2812
|
+
|
|
2654
2813
|
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2655
2814
|
// signer) before signing the opaque Solana transaction.
|
|
2656
2815
|
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
@@ -2765,7 +2924,16 @@ EXAMPLES:
|
|
|
2765
2924
|
continue;
|
|
2766
2925
|
}
|
|
2767
2926
|
} else {
|
|
2768
|
-
|
|
2927
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
2928
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
2929
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
2930
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
2931
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
2932
|
+
// any bridge fee above the ceiling.
|
|
2933
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
2934
|
+
&& isBridgeRequest(quoteData.request)
|
|
2935
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
2936
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
2769
2937
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
2770
2938
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2771
2939
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
@@ -3093,7 +3261,16 @@ EXAMPLES:
|
|
|
3093
3261
|
continue;
|
|
3094
3262
|
}
|
|
3095
3263
|
} else {
|
|
3096
|
-
|
|
3264
|
+
// A token-input swap sends no native value — except a cross-chain
|
|
3265
|
+
// bridge may carry a bounded native fee via msg.value. Allow that up
|
|
3266
|
+
// to the same ceiling assertSwapOutcome tolerates as a native sibling
|
|
3267
|
+
// (verifySwapOutcome runs below and re-bounds the actual simulated
|
|
3268
|
+
// outflow to min(tx.value, cap)); reject any other non-zero value, and
|
|
3269
|
+
// any bridge fee above the ceiling.
|
|
3270
|
+
const bridgeFeeAllowed = quoteData?.request
|
|
3271
|
+
&& isBridgeRequest(quoteData.request)
|
|
3272
|
+
&& txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
|
|
3273
|
+
if (txValue > 0n && !bridgeFeeAllowed) {
|
|
3097
3274
|
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
3098
3275
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
3099
3276
|
lastQuoteError = `${quoteName} unexpected tx.value`;
|
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
|
});
|
package/src/x402-svm.js
CHANGED
|
@@ -32,9 +32,13 @@ export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PR
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Build a Solana MessageV0 from accounts and instructions.
|
|
35
|
-
*
|
|
35
|
+
* feePayer is always placed at account index 0, forced signer+writable,
|
|
36
|
+
* regardless of whether an instruction references it directly.
|
|
37
|
+
* Returns numRequiredSignatures alongside the bytes since it's read back out
|
|
38
|
+
* of the header to size the signature-placeholder slots of the wrapping
|
|
39
|
+
* unsigned transaction (see callers).
|
|
36
40
|
*/
|
|
37
|
-
function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) {
|
|
41
|
+
export function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) {
|
|
38
42
|
// All unique accounts in order: feePayer first, then signers, then rest
|
|
39
43
|
const accountMap = new Map();
|
|
40
44
|
const feePayerKey = feePayer;
|
|
@@ -129,10 +133,10 @@ function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _ac
|
|
|
129
133
|
parts.push(ix.data);
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
// Address table lookups (empty
|
|
136
|
+
// Address table lookups (empty — all accounts referenced statically above)
|
|
133
137
|
parts.push(encodeCompactU16(0));
|
|
134
138
|
|
|
135
|
-
return Buffer.concat(parts);
|
|
139
|
+
return { messageBytes: Buffer.concat(parts), numRequiredSignatures };
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
// ============= Ed25519 Signing =============
|
|
@@ -231,7 +235,7 @@ export function buildUnsignedSvmTransaction(
|
|
|
231
235
|
},
|
|
232
236
|
];
|
|
233
237
|
|
|
234
|
-
const messageBytes = buildMessageV0({
|
|
238
|
+
const { messageBytes } = buildMessageV0({
|
|
235
239
|
feePayer: feePayerStr,
|
|
236
240
|
instructions,
|
|
237
241
|
recentBlockhash,
|