fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358)
The four V4 swap actions are read only as the parameter structs deployed on mainnet and Sepolia. ethers decoded the newer v4-periphery structs, which add minHopPriceX36, as the deployed ones without an error, and the newer multi-hop exact-in struct showed the offset of its hop-price array as the swap amount. A decode now counts only when encoding it again gives back the same bytes. When a swap action fails that, its step is listed as "V4 Swap (could not be decoded)", and a token line left without a currency reads "Unknown (could not be decoded)" rather than "not named in the calldata". The README records both. Model: opus-5-5
This commit is contained in:
+58
-16
@@ -53,6 +53,11 @@ function formatAmount(raw, decimals) {
|
||||
// on a scale — not as a token name, and not as a quantity.
|
||||
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
||||
|
||||
// The same refusal when a V4 swap action could not be read (see
|
||||
// decodeExactly()). That action names both of its currencies, so saying the
|
||||
// calldata did not name one would be false.
|
||||
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
|
||||
|
||||
// Explicit presence, never truthiness. Every gate in this file that guards a
|
||||
// decoded value goes through here: an address is never falsy once set, but an
|
||||
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
|
||||
@@ -295,6 +300,24 @@ function v4ExactInAmount(raw) {
|
||||
return raw === 0n ? OPEN_DELTA : raw;
|
||||
}
|
||||
|
||||
// The V4 swap actions are read only in the parameter structs V4Router has
|
||||
// deployed on mainnet and Sepolia. v4-periphery `main` has since added a
|
||||
// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and
|
||||
// reading a shape nobody has deployed would be guessing. ethers alone would
|
||||
// read the newer structs anyway: it takes whatever word sits where a
|
||||
// deployed field would be, ignores bytes left over, and holds back an error
|
||||
// in a field until that field is used. The newer multi-hop exact-in struct
|
||||
// then yields the offset of its hop-price array as `amountIn`. So a decode
|
||||
// counts only when encoding the result again gives back the same bytes;
|
||||
// otherwise this throws, and decodeV4Swap() marks the step as not decoded.
|
||||
function decodeExactly(types, data) {
|
||||
const decoded = coder.decode(types, data);
|
||||
if (coder.encode(types, decoded) !== data) {
|
||||
throw new Error("Not in the deployed V4 parameter shape.");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// Decode V4_SWAP (command 0x10) input bytes.
|
||||
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
||||
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
||||
@@ -309,6 +332,10 @@ function decodeV4Swap(input) {
|
||||
let takeToken = null;
|
||||
let amountIn = null;
|
||||
let amountOutMin = null;
|
||||
// Set when a swap action's parameters are not in the deployed shape
|
||||
// (see decodeExactly()), so nothing was read from that action. SETTLE
|
||||
// and TAKE may still name both currencies.
|
||||
let swapUndecoded = false;
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const actionId = actions[i];
|
||||
@@ -337,7 +364,7 @@ function decodeV4Swap(input) {
|
||||
// tuple(address,uint24,int24,address,bytes)[] path,
|
||||
// uint128 amountIn, uint128 amountOutMin)
|
||||
try {
|
||||
const s = coder.decode(
|
||||
const s = decodeExactly(
|
||||
[
|
||||
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
||||
],
|
||||
@@ -355,13 +382,13 @@ function decodeV4Swap(input) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
} catch {
|
||||
// Fall through — SETTLE/TAKE will provide tokens
|
||||
swapUndecoded = true;
|
||||
}
|
||||
} else {
|
||||
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
|
||||
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
||||
try {
|
||||
const s = coder.decode(
|
||||
const s = decodeExactly(
|
||||
[
|
||||
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
||||
],
|
||||
@@ -384,7 +411,7 @@ function decodeV4Swap(input) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
swapUndecoded = true;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
@@ -393,7 +420,7 @@ function decodeV4Swap(input) {
|
||||
) {
|
||||
if (actionId === V4_SWAP_EXACT_OUT) {
|
||||
try {
|
||||
const s = coder.decode(
|
||||
const s = decodeExactly(
|
||||
[
|
||||
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
||||
],
|
||||
@@ -405,11 +432,11 @@ function decodeV4Swap(input) {
|
||||
settleToken = path[path.length - 1][0];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
swapUndecoded = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const s = coder.decode(
|
||||
const s = decodeExactly(
|
||||
[
|
||||
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
||||
],
|
||||
@@ -426,7 +453,7 @@ function decodeV4Swap(input) {
|
||||
? poolKey[1]
|
||||
: poolKey[0];
|
||||
} catch {
|
||||
// Fall through
|
||||
swapUndecoded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +467,7 @@ function decodeV4Swap(input) {
|
||||
tokenOut: takeToken,
|
||||
amountIn,
|
||||
amountOutMin,
|
||||
swapUndecoded,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -465,6 +493,7 @@ function decode(data, toAddress, sources) {
|
||||
let minOutput = null;
|
||||
let hasUnwrapWeth = false;
|
||||
let hasV2ExactOut = false;
|
||||
let swapUndecoded = false;
|
||||
const commandNames = [];
|
||||
|
||||
// THE INVARIANT: an amount and the token it is counted in always come
|
||||
@@ -568,6 +597,12 @@ function decode(data, toAddress, sources) {
|
||||
if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
if (v4) {
|
||||
// Without this, a swap action that went unread would
|
||||
// look like one that had nothing to show.
|
||||
if (v4.swapUndecoded) {
|
||||
swapUndecoded = true;
|
||||
commandNames[i] += " (could not be decoded)";
|
||||
}
|
||||
setInputOnce(v4.tokenIn, v4.amountIn);
|
||||
// Always update output: last swap step wins. A step
|
||||
// that carries the Min. received figure but decoded no
|
||||
@@ -591,12 +626,13 @@ function decode(data, toAddress, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve token info. A null token on either side means the calldata
|
||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
|
||||
// when the output side is WETH, or when no step set the output side.
|
||||
// Any other output keeps its own token and figure: a swap that buys
|
||||
// USDC and then unwraps the WETH it did not spend receives USDC.
|
||||
// Resolve token info. A null token on either side means no step that
|
||||
// was read named a currency for it; tokenInfo() refuses rather than
|
||||
// calling it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the
|
||||
// output ETH when the output side is WETH, or when no step set the
|
||||
// output side. Any other output keeps its own token and figure: a swap
|
||||
// that buys USDC and then unwraps the WETH it did not spend receives
|
||||
// USDC.
|
||||
const outputIsWeth =
|
||||
present(outputToken) &&
|
||||
WETH_ADDRESSES.includes(outputToken.toLowerCase());
|
||||
@@ -640,7 +676,10 @@ function decode(data, toAddress, sources) {
|
||||
// rather than going missing or naming a token by default. Same
|
||||
// wording as the Token Out refusal below: the two sides of this
|
||||
// screen must not describe the same condition in two ways.
|
||||
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
|
||||
details.push({
|
||||
label: "Token In",
|
||||
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
|
||||
});
|
||||
}
|
||||
|
||||
if (present(inputAmount)) {
|
||||
@@ -703,7 +742,10 @@ function decode(data, toAddress, sources) {
|
||||
// rather than going missing or naming a token by default, and a
|
||||
// Min. received figure below it is never attached to a token the
|
||||
// calldata did not state.
|
||||
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
|
||||
details.push({
|
||||
label: "Token Out",
|
||||
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
|
||||
});
|
||||
}
|
||||
|
||||
if (present(minOutput)) {
|
||||
|
||||
Reference in New Issue
Block a user