nansen-cli 1.41.0 → 1.41.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 +10 -0
- package/package.json +1 -1
- package/src/schema.json +1 -0
- package/src/trade-validation.js +154 -33
- package/src/trading.js +166 -27
- package/src/x402-svm.js +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.41.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#527](https://github.com/nansen-ai/nansen-cli/pull/527) [`02efb6d`](https://github.com/nansen-ai/nansen-cli/commit/02efb6d1f40453c03135eb68cf493486a5b6133a) Thanks [@kome12](https://github.com/kome12)! - Cross-chain (bridge) swaps now run swap-outcome verification instead of skipping it entirely. The output-arrival check is still skipped (the output settles on the destination chain), but the input-outflow cap and no-sibling-drain checks now run on the source-chain leg, closing a gap where a compromised quote's bridge instructions could move more than the declared input. Bridges also now enforce an intent-relative lower bound on the source-chain input outflow (an exactIn bridge must spend ~the requested input, so a large fee-only or partial no-op no longer verifies) and still validate the quote's output-amount integrity, and the native-SOL bridge log no longer contradicts itself about whether the output check ran. Note the lower bound relaxes by a native-SOL fee/rent allowance (~0.013 SOL), so on a small native-SOL leg at or below that allowance the floor effectively collapses to a bare "outflow > 0" — the tightest bound possible for a native leg whose fees are indistinguishable from the transfer. `--swap-mode` is now validated against `exactIn`/`exactOut` at the CLI, and both the swap-outcome verifier and the pre-signing request-intent completeness checks fail closed on an unrecognized mode in a persisted quote so a garbage value cannot bypass the exactIn input floor — even when outcome verification is skipped or degraded.
|
|
8
|
+
|
|
9
|
+
Because bridges now go through the simulation, a bridge quote that **reverts in simulation** returns `proceed: false` and is dropped (the signing loop falls through to the next quote); only a simulation that cannot run at all (`NO_SIM_RPC` / `SIM_RPC_ERROR` / `NOT_SIM_CAPABLE`) degrades to proceed-without-verification, matching same-chain swaps. This is a new, fail-closed outcome for bridges specifically.
|
|
10
|
+
|
|
11
|
+
- [#513](https://github.com/nansen-ai/nansen-cli/pull/513) [`55eb953`](https://github.com/nansen-ai/nansen-cli/commit/55eb953cc15fe21aa441d1700e05ef053c643a58) Thanks [@kome12](https://github.com/kome12)! - Fix `trade execute` crashing on Solana-source bridge quotes from the Relay aggregator, which return raw uncompiled instructions instead of a ready-to-sign transaction. These are now compiled client-side before signing.
|
|
12
|
+
|
|
3
13
|
## 1.41.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/package.json
CHANGED
package/src/schema.json
CHANGED
|
@@ -1570,6 +1570,7 @@
|
|
|
1570
1570
|
"swap-mode": {
|
|
1571
1571
|
"type": "string",
|
|
1572
1572
|
"default": "exactIn",
|
|
1573
|
+
"enum": ["exactIn", "exactOut"],
|
|
1573
1574
|
"description": "\"exactIn\" (default) to spend exactly --amount of the sell token, or \"exactOut\" to receive exactly --amount of the buy token. Not supported together with --amount-unit percent."
|
|
1574
1575
|
},
|
|
1575
1576
|
"slippage": {
|
package/src/trade-validation.js
CHANGED
|
@@ -912,6 +912,18 @@ export function assertSwapCalldataNotBareTransfer(data) {
|
|
|
912
912
|
|
|
913
913
|
// ============= Swap-outcome verification (balance-delta simulation) =============
|
|
914
914
|
|
|
915
|
+
/**
|
|
916
|
+
* A cross-chain bridge's output settles on the destination chain, invisible to
|
|
917
|
+
* a source-chain simulation, so the output-arrival assertion is meaningless
|
|
918
|
+
* for one and both assert...SwapOutcome functions skip it via this check.
|
|
919
|
+
* Derived from the immutable persisted request intent (not the loose
|
|
920
|
+
* quote/quoteData) so it can't drift between calls or across chains.
|
|
921
|
+
*/
|
|
922
|
+
function isBridgeRequest(request) {
|
|
923
|
+
return request.toChain != null
|
|
924
|
+
&& String(request.toChain).toLowerCase() !== String(request.chain).toLowerCase();
|
|
925
|
+
}
|
|
926
|
+
|
|
915
927
|
/**
|
|
916
928
|
* Assert that a SIMULATED swap's asset changes match the user's intent, failing
|
|
917
929
|
* closed on any mismatch. This is a defence-in-depth outcome check that
|
|
@@ -929,6 +941,8 @@ export function assertSwapCalldataNotBareTransfer(data) {
|
|
|
929
941
|
* log) is never counted.
|
|
930
942
|
* 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
|
|
931
943
|
* output; exactIn: the quoted output reduced by the slippage in effect.
|
|
944
|
+
* SKIPPED for a cross-chain bridge: the output settles on the destination
|
|
945
|
+
* chain and can never appear in a source-chain simulation.
|
|
932
946
|
* 3. NO token other than the input leaves the wallet.
|
|
933
947
|
* 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
|
|
934
948
|
*
|
|
@@ -944,6 +958,9 @@ export function assertSwapCalldataNotBareTransfer(data) {
|
|
|
944
958
|
* router); anything else fails assertion 4. Compared case-insensitively.
|
|
945
959
|
* @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
|
|
946
960
|
* before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
|
|
961
|
+
* @returns {{verified: true, outputAssertionSkipped: boolean}} outputAssertionSkipped
|
|
962
|
+
* is true for a cross-chain bridge, meaning assertion 2 did not run — the
|
|
963
|
+
* caller should surface this.
|
|
947
964
|
* @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
|
|
948
965
|
*/
|
|
949
966
|
export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
|
|
@@ -983,6 +1000,9 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
|
|
|
983
1000
|
throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
|
|
984
1001
|
}
|
|
985
1002
|
|
|
1003
|
+
// Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
|
|
1004
|
+
const isBridge = isBridgeRequest(request);
|
|
1005
|
+
|
|
986
1006
|
// --- Assertion 1: input outflow within the spend ceiling ---
|
|
987
1007
|
// This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
|
|
988
1008
|
// NOT the exact expected input: for exactOut the aggregator may legitimately
|
|
@@ -1005,9 +1025,50 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
|
|
|
1005
1025
|
throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
|
|
1006
1026
|
}
|
|
1007
1027
|
|
|
1008
|
-
//
|
|
1028
|
+
// Fail closed on an unrecognized swap mode. `?? 'exactIn'` only defaults a
|
|
1029
|
+
// missing mode; a persisted request with a garbage value (an edited/older
|
|
1030
|
+
// quote record) must not silently fall into the exactOut branch below, which
|
|
1031
|
+
// would drop the intent-relative input floor. The CLI validates --swap-mode
|
|
1032
|
+
// against this same enum, so a well-formed quote never reaches here invalid.
|
|
1009
1033
|
const swapMode = request.swapMode ?? 'exactIn';
|
|
1010
|
-
|
|
1034
|
+
if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
|
|
1035
|
+
throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// A bridge drops assertion 2 (its output settles on the destination chain),
|
|
1039
|
+
// and for a normal swap that positive-output check is what implicitly proves
|
|
1040
|
+
// the input was actually consumed. Restore that with an intent-relative LOWER
|
|
1041
|
+
// bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
|
|
1042
|
+
// fee-only or 1-unit no-op would still verify a bridge that never funded its
|
|
1043
|
+
// input. For exactIn the outflow must be ~the requested input (assertion 1
|
|
1044
|
+
// already caps it above); for exactOut the input is variable up to the cap, so
|
|
1045
|
+
// only a positive-outflow floor is meaningful. EVM native input delta is the
|
|
1046
|
+
// transferred value with gas excluded, so the outflow is exact — no fee slack.
|
|
1047
|
+
if (isBridge) {
|
|
1048
|
+
if (swapMode === 'exactIn') {
|
|
1049
|
+
if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
|
|
1050
|
+
let requested;
|
|
1051
|
+
try {
|
|
1052
|
+
requested = BigInt(request.amount);
|
|
1053
|
+
} catch {
|
|
1054
|
+
throw fail(`requested input amount (${request.amount}) is not an integer.`);
|
|
1055
|
+
}
|
|
1056
|
+
if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
|
|
1057
|
+
if (outflow < requested) {
|
|
1058
|
+
throw fail(`the bridge moved only ${outflow} of the input token (${inputToken}) out of the wallet, below the requested input (${requested}); a bridge must spend its full input on the source chain.`);
|
|
1059
|
+
}
|
|
1060
|
+
} else if (outflow <= 0n) {
|
|
1061
|
+
throw fail(`the bridge moved no input token (${inputToken}) out of the wallet; a bridge must spend its input on the source chain.`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// --- Assertion 2: output arrives at or above the minimum acceptable ---
|
|
1066
|
+
// The minimum-output computation below — and its quote-integrity checks (the
|
|
1067
|
+
// output amount must be present, an integer, and positive) — runs for EVERY
|
|
1068
|
+
// swap, bridges included: a bridge quote with a missing or zero output is
|
|
1069
|
+
// still malformed. Only the final delta comparison is bridge-skipped, because
|
|
1070
|
+
// the output settles on the destination chain and can never appear in this
|
|
1071
|
+
// source-chain simulation.
|
|
1011
1072
|
let minOut;
|
|
1012
1073
|
if (swapMode === 'exactOut') {
|
|
1013
1074
|
if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
|
|
@@ -1058,8 +1119,11 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
|
|
|
1058
1119
|
const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
|
|
1059
1120
|
minOut = (quoted * (10000n - bps)) / 10000n;
|
|
1060
1121
|
}
|
|
1061
|
-
if (
|
|
1062
|
-
|
|
1122
|
+
if (!isBridge) {
|
|
1123
|
+
const outputDelta = deltas[outputToken] || 0n;
|
|
1124
|
+
if (outputDelta < minOut) {
|
|
1125
|
+
throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
|
|
1126
|
+
}
|
|
1063
1127
|
}
|
|
1064
1128
|
|
|
1065
1129
|
// --- Assertion 3: no token other than the input leaves the wallet ---
|
|
@@ -1117,7 +1181,7 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
|
|
|
1117
1181
|
}
|
|
1118
1182
|
}
|
|
1119
1183
|
|
|
1120
|
-
return { verified: true };
|
|
1184
|
+
return { verified: true, outputAssertionSkipped: isBridge };
|
|
1121
1185
|
}
|
|
1122
1186
|
|
|
1123
1187
|
// Native-SOL dust tolerated on a non-input sibling in assertSolanaSwapOutcome —
|
|
@@ -1170,6 +1234,8 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
|
|
|
1170
1234
|
* Native-SOL output relaxes this floor by NATIVE_FEE_RENT_SLACK_LAMPORTS
|
|
1171
1235
|
* because its lamport delta also nets out the base fee, priority fee, and
|
|
1172
1236
|
* ATA rent (same noise as native input); SPL output keeps the exact floor.
|
|
1237
|
+
* SKIPPED for a cross-chain bridge: the output settles on the destination
|
|
1238
|
+
* chain and can never appear in a source-chain simulation.
|
|
1173
1239
|
* 3. no OTHER tracked asset leaves the wallet. SPL-token siblings get zero
|
|
1174
1240
|
* tolerance; native SOL, when it's a sibling (not the input), tolerates
|
|
1175
1241
|
* NATIVE_FEE_RENT_SLACK_LAMPORTS of fee/rent dust. All three assertions
|
|
@@ -1186,10 +1252,12 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
|
|
|
1186
1252
|
* @param {object} [ctx]
|
|
1187
1253
|
* @param {number} [ctx.slippage] - slippage fraction in effect; defaults to 3%
|
|
1188
1254
|
* @param {bigint} [ctx.siblingDustThreshold] - overrides NATIVE_FEE_RENT_SLACK_LAMPORTS
|
|
1189
|
-
* @returns {{verified: true, inputAssertionSkipped: boolean}}
|
|
1190
|
-
* is true when the input was native SOL, meaning
|
|
1191
|
-
* fee/rent slack applied instead of an exact bound
|
|
1192
|
-
* rationale above)
|
|
1255
|
+
* @returns {{verified: true, inputAssertionSkipped: boolean, outputAssertionSkipped: boolean}}
|
|
1256
|
+
* inputAssertionSkipped is true when the input was native SOL, meaning
|
|
1257
|
+
* assertion 1 ran with the fee/rent slack applied instead of an exact bound
|
|
1258
|
+
* (see assertion 1's rationale above). outputAssertionSkipped is true for a
|
|
1259
|
+
* cross-chain bridge, meaning assertion 2 did not run. The caller should
|
|
1260
|
+
* surface both.
|
|
1193
1261
|
* @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
|
|
1194
1262
|
*/
|
|
1195
1263
|
export function assertSolanaSwapOutcome(request, quote, sim, { slippage, siblingDustThreshold } = {}) {
|
|
@@ -1227,6 +1295,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
|
|
|
1227
1295
|
|
|
1228
1296
|
const inputIsNative = inputAsset === SOL_SENTINEL;
|
|
1229
1297
|
|
|
1298
|
+
// Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
|
|
1299
|
+
const isBridge = isBridgeRequest(request);
|
|
1300
|
+
|
|
1230
1301
|
// --- Assertion 1: input outflow within the spend ceiling ---
|
|
1231
1302
|
if (request.maxInputAmount == null) {
|
|
1232
1303
|
throw fail('request has no maximum input to bound the outflow against.');
|
|
@@ -1252,11 +1323,55 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
|
|
|
1252
1323
|
if (outflow > effectiveCap) {
|
|
1253
1324
|
throw fail(`the input token (${inputAsset}) left the wallet by ${outflow}, exceeding your maximum input (${cap}${inputIsNative ? ` plus fee/rent slack` : ''}).`);
|
|
1254
1325
|
}
|
|
1326
|
+
// Fail closed on an unrecognized swap mode (mirrors assertSwapOutcome). A
|
|
1327
|
+
// persisted request with a garbage value must not fall into the exactOut
|
|
1328
|
+
// branch below and drop the intent-relative input floor.
|
|
1329
|
+
const swapMode = request.swapMode ?? 'exactIn';
|
|
1330
|
+
if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
|
|
1331
|
+
throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// A bridge drops assertion 2 (its output settles on the destination chain),
|
|
1335
|
+
// and for a normal swap that positive-output check is what implicitly proves
|
|
1336
|
+
// the input was actually consumed. Restore that with an intent-relative LOWER
|
|
1337
|
+
// bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
|
|
1338
|
+
// native-SOL leg always burns a fee, so a fee-only no-op (and any partial SPL
|
|
1339
|
+
// outflow) would otherwise verify a bridge that never funded its input. For
|
|
1340
|
+
// exactIn the outflow must be ~the requested input (assertion 1 caps it
|
|
1341
|
+
// above); for exactOut the input is variable up to the cap, so only a
|
|
1342
|
+
// positive-outflow floor is meaningful. Native-SOL input carries fee/rent
|
|
1343
|
+
// noise (bridged amount + base/priority fee − reclaimed ATA rent), so relax
|
|
1344
|
+
// the floor by the same slack assertion 1 adds to the ceiling; SPL is exact.
|
|
1345
|
+
if (isBridge) {
|
|
1346
|
+
if (swapMode === 'exactIn') {
|
|
1347
|
+
if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
|
|
1348
|
+
let requested;
|
|
1349
|
+
try {
|
|
1350
|
+
requested = BigInt(request.amount);
|
|
1351
|
+
} catch {
|
|
1352
|
+
throw fail(`requested input amount (${request.amount}) is not an integer.`);
|
|
1353
|
+
}
|
|
1354
|
+
if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
|
|
1355
|
+
const floorSlack = inputIsNative ? NATIVE_FEE_RENT_SLACK_LAMPORTS : 0n;
|
|
1356
|
+
// Clamp the floor to a positive minimum: for a native bridge smaller than
|
|
1357
|
+
// the fee/rent slack a real leg is indistinguishable from a fee-only no-op,
|
|
1358
|
+
// so the tightest we can still require is a non-zero outflow.
|
|
1359
|
+
const minOutflow = requested > floorSlack ? requested - floorSlack : 1n;
|
|
1360
|
+
if (outflow < minOutflow) {
|
|
1361
|
+
throw fail(`the bridge moved only ${outflow} of the input token (${inputAsset}) out of the wallet, below the requested input (${requested}${inputIsNative ? ` minus fee/rent slack` : ''}); a bridge must spend its full input on the source chain.`);
|
|
1362
|
+
}
|
|
1363
|
+
} else if (outflow <= 0n) {
|
|
1364
|
+
throw fail(`the bridge moved no input token (${inputAsset}) out of the wallet; a bridge must spend its input on the source chain.`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1255
1367
|
|
|
1256
1368
|
// --- Assertion 2: output arrives at or above the minimum acceptable ---
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1369
|
+
// The minimum-output computation below — and its quote-integrity checks (the
|
|
1370
|
+
// output amount must be present, an integer, and positive) — runs for EVERY
|
|
1371
|
+
// swap, bridges included: a bridge quote with a missing or zero output is
|
|
1372
|
+
// still malformed. Only the final delta comparison is bridge-skipped, because
|
|
1373
|
+
// the output settles on the destination chain and can never appear in this
|
|
1374
|
+
// source-chain simulation.
|
|
1260
1375
|
let minOut;
|
|
1261
1376
|
if (swapMode === 'exactOut') {
|
|
1262
1377
|
if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
|
|
@@ -1290,26 +1405,30 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
|
|
|
1290
1405
|
const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
|
|
1291
1406
|
minOut = (quoted * (10000n - bps)) / 10000n;
|
|
1292
1407
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1408
|
+
if (!isBridge) {
|
|
1409
|
+
const outputIsNative = outputAsset === SOL_SENTINEL;
|
|
1410
|
+
const outputDelta = deltas[outputAsset] || 0n;
|
|
1411
|
+
// Native-SOL output carries the same fee/rent noise as native input: the
|
|
1412
|
+
// lamport delta is (SOL received − base/priority fee − net ATA rent), so a
|
|
1413
|
+
// legitimate trade can land a few million lamports under the quoted amount at
|
|
1414
|
+
// tight slippage or on a congested-network priority fee. Relax the floor by
|
|
1415
|
+
// the same combined fee/rent slack used for native siblings (assertion 3) and
|
|
1416
|
+
// native input (assertion 1) so fee noise never false-blocks; the slippage
|
|
1417
|
+
// floor still bounds any real shortfall. SPL output has no such noise and
|
|
1418
|
+
// keeps the exact floor.
|
|
1419
|
+
const outputFloorSlack = outputIsNative
|
|
1420
|
+
? (siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS)
|
|
1421
|
+
: 0n;
|
|
1422
|
+
// minOut can be smaller than the dust tolerance for a dust-quoted swap; clamp
|
|
1423
|
+
// the floor at 0 so the subtraction never goes negative and silently admits
|
|
1424
|
+
// any non-negative outputDelta (including zero). The explicit outputDelta <= 0n
|
|
1425
|
+
// check below then restores the invariant assertSwapOutcome (the EVM sibling)
|
|
1426
|
+
// gets for free because its minOut can never collapse to <= 0: a swap must
|
|
1427
|
+
// deliver SOME positive output, even when the dust-adjusted floor is 0.
|
|
1428
|
+
const adjustedFloor = minOut > outputFloorSlack ? minOut - outputFloorSlack : 0n;
|
|
1429
|
+
if (outputDelta <= 0n || outputDelta < adjustedFloor) {
|
|
1430
|
+
throw fail(`the output token (${outputAsset}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
|
|
1431
|
+
}
|
|
1313
1432
|
}
|
|
1314
1433
|
|
|
1315
1434
|
// --- Assertion 3: no other tracked asset leaves the wallet ---
|
|
@@ -1326,7 +1445,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
|
|
|
1326
1445
|
// inputAssertionSkipped tells the caller assertion 1 ran with the fee/rent
|
|
1327
1446
|
// slack applied (native-SOL input, per the JSDoc above), so it can surface
|
|
1328
1447
|
// that instead of implying the input spend was tightly delta-verified.
|
|
1329
|
-
|
|
1448
|
+
// outputAssertionSkipped tells the caller assertion 2 did not run at all
|
|
1449
|
+
// (cross-chain bridge, per the JSDoc above).
|
|
1450
|
+
return { verified: true, inputAssertionSkipped: inputIsNative, outputAssertionSkipped: isBridge };
|
|
1330
1451
|
}
|
|
1331
1452
|
|
|
1332
1453
|
/**
|
package/src/trading.js
CHANGED
|
@@ -9,7 +9,8 @@ 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';
|
|
@@ -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,10 @@ 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
|
-
assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
|
|
1011
|
+
const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
|
|
1012
|
+
if (outcome.outputAssertionSkipped) {
|
|
1013
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
1014
|
+
}
|
|
905
1015
|
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
906
1016
|
return { proceed: true };
|
|
907
1017
|
} catch (e) {
|
|
@@ -925,9 +1035,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
925
1035
|
*/
|
|
926
1036
|
export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
|
|
927
1037
|
if (chain !== 'solana') return { proceed: true };
|
|
928
|
-
// Cross-chain: the output settles on the destination chain
|
|
929
|
-
//
|
|
930
|
-
|
|
1038
|
+
// Cross-chain (bridge): the output settles on the destination chain, so
|
|
1039
|
+
// the source-chain simulation still runs but assertSolanaSwapOutcome skips
|
|
1040
|
+
// only the output-arrival assertion internally (mirrors the EVM path above).
|
|
1041
|
+
|
|
931
1042
|
if (!quoteData?.request) {
|
|
932
1043
|
log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
|
|
933
1044
|
return { proceed: true };
|
|
@@ -940,7 +1051,14 @@ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64,
|
|
|
940
1051
|
const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
|
|
941
1052
|
const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
|
|
942
1053
|
if (outcome.inputAssertionSkipped) {
|
|
943
|
-
|
|
1054
|
+
// On a native-SOL bridge the output assertion did NOT run (it settles on
|
|
1055
|
+
// the destination chain), so don't claim "output ... checks still ran" —
|
|
1056
|
+
// that would contradict the bridge line logged just below.
|
|
1057
|
+
const alsoRan = outcome.outputAssertionSkipped ? 'sibling checks still ran' : 'output and sibling checks still ran';
|
|
1058
|
+
log(` ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; ${alsoRan}.`);
|
|
1059
|
+
}
|
|
1060
|
+
if (outcome.outputAssertionSkipped) {
|
|
1061
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
944
1062
|
}
|
|
945
1063
|
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
946
1064
|
return { proceed: true };
|
|
@@ -1148,6 +1266,14 @@ export function assertCompleteEvmRequestIntent(request) {
|
|
|
1148
1266
|
if (missing.length) {
|
|
1149
1267
|
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
1150
1268
|
}
|
|
1269
|
+
// swapMode must be a recognized mode, not merely present. This runs
|
|
1270
|
+
// unconditionally before signing — unlike the swap-outcome verifier, which is
|
|
1271
|
+
// skipped by --no-verify-outcome or when the sim RPC degrades — so a corrupted
|
|
1272
|
+
// or edited quote record with a garbage swapMode fails closed regardless of
|
|
1273
|
+
// the outcome-verification path.
|
|
1274
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1275
|
+
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.`);
|
|
1276
|
+
}
|
|
1151
1277
|
}
|
|
1152
1278
|
|
|
1153
1279
|
/**
|
|
@@ -1170,6 +1296,12 @@ export function assertCompleteSolanaRequestIntent(request) {
|
|
|
1170
1296
|
if (missing.length) {
|
|
1171
1297
|
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1172
1298
|
}
|
|
1299
|
+
// swapMode must be a recognized mode, not merely present — see the EVM sibling.
|
|
1300
|
+
// Runs unconditionally before signing, so a garbage swapMode fails closed even
|
|
1301
|
+
// when the swap-outcome verifier is skipped or degraded.
|
|
1302
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1303
|
+
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.`);
|
|
1304
|
+
}
|
|
1173
1305
|
}
|
|
1174
1306
|
|
|
1175
1307
|
/**
|
|
@@ -1718,6 +1850,12 @@ export function buildTradingCommands(deps = {}) {
|
|
|
1718
1850
|
const autoSlippage = flags['auto-slippage'];
|
|
1719
1851
|
const maxAutoSlippage = options['max-auto-slippage'];
|
|
1720
1852
|
const swapMode = options['swap-mode'] || 'exactIn';
|
|
1853
|
+
if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
|
|
1854
|
+
throw new CommandError(
|
|
1855
|
+
`Invalid --swap-mode: "${swapMode}". Use one of: exactIn, exactOut.`,
|
|
1856
|
+
'INVALID_INPUT',
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1721
1859
|
const amountUnit = options['amount-unit'];
|
|
1722
1860
|
const aggregatorFilter = options.aggregator;
|
|
1723
1861
|
if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
|
|
@@ -2311,10 +2449,6 @@ EXAMPLES:
|
|
|
2311
2449
|
|
|
2312
2450
|
if (chainType === 'solana' && isPrivy) {
|
|
2313
2451
|
// 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
2452
|
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
2319
2453
|
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
2320
2454
|
const walletResult = await privyClient.getWallet(solWalletId);
|
|
@@ -2328,6 +2462,11 @@ EXAMPLES:
|
|
|
2328
2462
|
throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
|
|
2329
2463
|
}
|
|
2330
2464
|
|
|
2465
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2466
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2467
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2468
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress);
|
|
2469
|
+
|
|
2331
2470
|
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2332
2471
|
// signer) before signing the aggregator's serialized transaction.
|
|
2333
2472
|
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
@@ -2626,15 +2765,13 @@ EXAMPLES:
|
|
|
2626
2765
|
// below binds the metadata (token pair, amounts, signer), and
|
|
2627
2766
|
// assertSolanaInstructionsSafe statically inspects the tx's own
|
|
2628
2767
|
// instructions before signing.
|
|
2629
|
-
// Solana: transaction is
|
|
2630
|
-
//
|
|
2631
|
-
|
|
2632
|
-
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
2633
|
-
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2634
|
-
}
|
|
2768
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2769
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2770
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2635
2771
|
|
|
2636
|
-
// Resolve the signer
|
|
2637
|
-
//
|
|
2772
|
+
// Resolve the signer first — both the Relay-shape compiler (which needs
|
|
2773
|
+
// an expected signer for its fee-payer check) and the intent-binding
|
|
2774
|
+
// check below use this exact same address.
|
|
2638
2775
|
let solanaWalletAddress;
|
|
2639
2776
|
if (isWalletConnect) {
|
|
2640
2777
|
solanaWalletAddress = await getWalletConnectAddress(chainType);
|
|
@@ -2651,6 +2788,8 @@ EXAMPLES:
|
|
|
2651
2788
|
}
|
|
2652
2789
|
}
|
|
2653
2790
|
|
|
2791
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
|
|
2792
|
+
|
|
2654
2793
|
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2655
2794
|
// signer) before signing the opaque Solana transaction.
|
|
2656
2795
|
assertCompleteSolanaRequestIntent(quoteData.request);
|
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,
|