Author SHA1 Message Date
clawbot 426f3e0d9a fix: the approval screen names the address a swap's output is paid to (closes #594)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now reads the recipient of every swap
step, SWEEP, UNWRAP_WETH, V4 TAKE and TAKE_ALL, reads the router's
placeholders as the router does (address(1) is the sender, address(2) the
router, which holds the output until a later SWEEP or UNWRAP_WETH pays it
out), and compares the result with the transaction's sender. When the output
goes anywhere else, a Paid to line names each address it is paid to and the
minimum reads Min. paid out. The approval and transaction detail screens pass
the sender in.

Model: opus-5-5
2026-10-19 06:06:38 +00:00
10 changed files with 828 additions and 69 deletions
+24 -4
View File
@@ -1144,8 +1144,9 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can
read:
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` when
the output is paid to another address, see Transaction Decoding), do not always
read as a number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above.
@@ -2279,7 +2280,9 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
amounts, steps, deadline, and for a swap whose output is paid to an
address other than the From address, each address it is paid to (see
Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -2914,7 +2917,24 @@ Currently supported:
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other
steps state, the summary names no swap direction, `Token In` and `Token Out`
read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line.
`Min. received` line. Each swap step, `SWEEP`, `UNWRAP_WETH` and V4 `TAKE`
names the address it pays, and `TAKE_ALL` pays the transaction's sender. The
router reads two of those addresses as placeholders: `address(1)` is the
transaction's sender, and `address(2)` is the router itself, which holds what
it is paid until a later step pays it out. The output is paid to the address
of the step that sets the output side (for a V4 step, of every `TAKE` and
`TAKE_ALL` of its output currency), or, when no step sets it, of the
`UNWRAP_WETH` that makes `Token Out` ETH. While the router holds it, the next
`SWEEP` of the output token, or `UNWRAP_WETH` of a WETH output, pays it to
that step's address. A `BALANCE_CHECK_ERC20` step pays nothing and leaves the
address as it was. An output nothing pays out is paid to the router. When the
output is paid to any address other than the transaction's sender (the From
address on TxApproval, the sender the explorer reports on TransactionDetail),
a `Paid to` line after `Token Out` names each address it is paid to, the
sender's included, and `Min. received` reads `Min. paid out`. A swap paid to
the sender, directly or through the router, has neither. The decoder does not
read `TRANSFER`, `PAY_PORTION` or V4 `TAKE_PORTION`, each of which pays an
amount or a share to an address of its own.
### Non-Goals Forever
+6 -4
View File
@@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
};
}
// Try to decode calldata sent to `toAddress` on the network with chain id
// `chainId`, using known ABIs.
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId) {
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
@@ -191,6 +191,7 @@ function decodeCalldata(data, toAddress, chainId) {
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
@@ -277,11 +278,12 @@ function showTxApproval(details) {
// If this is an ERC-20 call, try to extract the real recipient and amount.
// Decoded for the network the transaction is for, which the Network line
// names.
// names, and the address the From line names.
const decoded = decodeCalldata(
approvedTx.data,
toAddr || "",
approvedTx.chainId,
details.approvedFrom,
);
if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token
+3 -1
View File
@@ -316,11 +316,13 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
// On the active network, the one whose explorer was just asked.
// On the active network, the one whose explorer was just asked, and
// from the sender it reports.
const decoded = decodeCalldata(
inputData,
txData.to?.hash || "",
currentNetwork().chainId,
txData.from?.hash || "",
);
if (decoded) {
// Render decoded calldata matching approval view style
+146 -14
View File
@@ -8,6 +8,7 @@ const {
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
const { sameAddress } = require("./approvalVerify");
const { NETWORKS } = require("./networks");
const coder = AbiCoder.defaultAbiCoder();
@@ -179,6 +180,33 @@ const WETH_ADDRESSES = [
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
function isWeth(address) {
return present(address) && WETH_ADDRESSES.includes(address.toLowerCase());
}
// Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
// and ADDRESS_THIS pays the router itself, which holds the output until a
// later step pays it out. The swap commands, SWEEP and UNWRAP_WETH map their
// recipient through the dispatcher's map(), and a V4 TAKE through V4Router's
// _mapRecipient(); TAKE_ALL always pays the caller.
// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol
// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol
// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol
// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol
// - TAKE and TAKE_ALL: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// The address a command's `recipient` pays, read as the router reads it, for a
// transaction from `sender` to the router at `router`.
function recipientAddress(recipient, sender, router) {
if (sameAddress(recipient, MSG_SENDER)) return sender;
if (sameAddress(recipient, ADDRESS_THIS)) return router;
return recipient;
}
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -265,6 +293,7 @@ function decodeV2SwapExactIn(input) {
input,
);
return {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2],
tokenIn: d[3][0],
@@ -285,6 +314,7 @@ function decodeV2SwapExactOut(input) {
input,
);
return {
recipient: d[0],
amountOut: d[1],
amountInMax: d[2],
tokenIn: d[3][0],
@@ -316,6 +346,7 @@ function decodeV3SwapExactIn(input) {
const path = decodeV3Path(d[3]);
if (!path) return null;
return {
recipient: d[0],
amountIn: d[1],
amountOutMin: d[2],
tokenIn: path.tokenIn,
@@ -326,6 +357,30 @@ function decodeV3SwapExactIn(input) {
}
}
// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance
// of `token` to the recipient.
// ABI: (address token, address recipient, uint256 amountMin)
function decodeSweep(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { token: d[0], recipient: d[1] };
} catch {
return null;
}
}
// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole
// WETH balance into ETH and pays it to the recipient.
// ABI: (address recipient, uint256 amountMin)
function decodeUnwrapWeth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { recipient: d[0] };
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
@@ -500,6 +555,10 @@ function totalAmount(amounts) {
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
//
// `outputRecipients` is the recipient of every TAKE and TAKE_ALL of Token
// Out, as written in the calldata (see recipientAddress()), TAKE_ALL's as
// MSG_SENDER. It is empty when none takes Token Out.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -508,6 +567,8 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
// Every TAKE and TAKE_ALL, as { currency, recipient }.
const takes = [];
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
@@ -537,10 +598,12 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
takes.push({ currency: t[0], recipient: t[1] });
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -558,6 +621,11 @@ function decodeV4Swap(input) {
}
}
const recipientsOf = (currency) =>
takes
.filter((t) => sameAddress(t.currency, currency))
.map((t) => t.recipient);
// Rule 3.
if (swapUndecoded) {
return {
@@ -565,6 +633,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
outputRecipients: recipientsOf(takeToken),
swapUndecoded,
hasSwapAction: true,
};
@@ -604,6 +673,7 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
outputRecipients: recipientsOf(tokenOut),
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
@@ -612,14 +682,14 @@ function decodeV4Swap(input) {
}
}
// Try to decode a Universal Router execute() call sent to `toAddress` on the
// network with chain id `chainId`.
// Try to decode a Universal Router execute() call sent from `fromAddress`, the
// user, to `toAddress` on the network with chain id `chainId`.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute() or
// `toAddress` is not a Universal Router on that network: the same calldata
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources) {
function decode(data, toAddress, chainId, sources, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
try {
@@ -636,6 +706,9 @@ function decode(data, toAddress, chainId, sources) {
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
// The addresses the output side is paid to, each read as the router
// reads it (see recipientAddress()). Empty when no step read says.
let outputPaidTo = [];
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
@@ -671,12 +744,24 @@ function decode(data, toAddress, chainId, sources) {
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount) => {
// The step that sets the output side also says who it is paid to.
const setOutput = (token, amount, addresses) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputPaidTo = addresses;
outputEstablished = true;
};
const paidTo = (recipients) =>
recipients.map((r) => recipientAddress(r, fromAddress, toAddress));
// SWEEP and UNWRAP_WETH pay out the router's whole balance, so an
// output the router holds goes to their recipient.
const payOutFromRouter = (recipient) => {
const [address] = paidTo([recipient]);
outputPaidTo = outputPaidTo.map((a) =>
sameAddress(a, toAddress) ? address : a,
);
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
@@ -708,8 +793,9 @@ function decode(data, toAddress, chainId, sources) {
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
// It pays nobody, so who the output is paid to stays.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
setOutput(b.token, b.minBalance, outputPaidTo);
}
}
@@ -720,7 +806,11 @@ function decode(data, toAddress, chainId, sources) {
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
setOutput(
s.tokenOut,
s.amountOutMin,
paidTo([s.recipient]),
);
}
}
@@ -728,7 +818,11 @@ function decode(data, toAddress, chainId, sources) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
setOutput(
s.tokenOut,
s.amountOutMin,
paidTo([s.recipient]),
);
}
}
@@ -738,7 +832,18 @@ function decode(data, toAddress, chainId, sources) {
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
setOutput(
s.tokenOut,
s.amountOut,
paidTo([s.recipient]),
);
}
}
if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]);
if (s && sameAddress(s.token, outputToken)) {
payOutFromRouter(s.recipient);
}
}
@@ -775,6 +880,7 @@ function decode(data, toAddress, chainId, sources) {
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
outputPaidTo = paidTo(v4.outputRecipients);
outputEstablished = true;
} else if (v4) {
// A step with no swap action sets only the sides its
@@ -782,12 +888,24 @@ function decode(data, toAddress, chainId, sources) {
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
setOutput(
v4.tokenOut,
v4.amountOutMin,
paidTo(v4.outputRecipients),
);
}
}
if (cmdId === 0x0c) {
hasUnwrapWeth = true;
// It pays out the output when that is WETH, and when no
// step has set the output side: see the ETH output below.
const u = decodeUnwrapWeth(inputs[i]);
if (u && !outputEstablished) {
outputPaidTo = paidTo([u.recipient]);
} else if (u && isWeth(outputToken)) {
payOutFromRouter(u.recipient);
}
}
} catch {
// Skip commands we can't decode
@@ -804,6 +922,7 @@ function decode(data, toAddress, chainId, sources) {
inputAmount = null;
outputToken = null;
minOutput = null;
outputPaidTo = [];
outputEstablished = true;
}
@@ -816,12 +935,9 @@ function decode(data, toAddress, chainId, sources) {
// even when it set it unknown. 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());
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
hasUnwrapWeth && (isWeth(outputToken) || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
@@ -936,6 +1052,22 @@ function decode(data, toAddress, chainId, sources) {
});
}
// When any address the output is paid to is not the user's, each one
// is named, the user's own included, and the output is not said to be
// received. An output the router still holds is paid to the router.
const paidElsewhere = outputPaidTo.some(
(a) => !sameAddress(a, fromAddress),
);
if (paidElsewhere) {
const named = outputPaidTo.filter(
(a, i) =>
outputPaidTo.findIndex((b) => sameAddress(a, b)) === i,
);
for (const address of named) {
details.push({ label: "Paid to", value: address, address });
}
}
if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same
@@ -944,7 +1076,7 @@ function decode(data, toAddress, chainId, sources) {
// return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown.
details.push({
label: "Min. received",
label: paidElsewhere ? "Min. paid out" : "Min. received",
value:
minOutput === 0n
? NO_MINIMUM
+3
View File
@@ -68,10 +68,13 @@ function swapData(amountIn, amountOutMin) {
}
function swapDetail(amountIn, amountOutMin, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
swapData(amountIn, amountOutMin),
ROUTER,
"0x1",
{},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
+113 -43
View File
@@ -332,7 +332,13 @@ describe("uniswap decoder", () => {
});
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 ETH");
@@ -373,6 +379,8 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, deployed),
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
},
@@ -389,6 +397,8 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, newer),
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
@@ -419,7 +429,13 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
@@ -440,7 +456,13 @@ describe("uniswap decoder", () => {
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
@@ -470,7 +492,13 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
@@ -498,7 +526,13 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
@@ -520,7 +554,7 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
@@ -540,7 +574,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
@@ -567,7 +601,7 @@ describe("uniswap decoder", () => {
1767225600n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
@@ -599,7 +633,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount");
@@ -628,7 +662,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDC → ETH");
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
expect(detail(result, "Token Out").value).toBe("ETH");
@@ -671,7 +705,13 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Token Out").address).toBe(tokenOut);
expect(detail(result, "Min. received").value).toBe(minReceived);
},
@@ -692,7 +732,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
@@ -725,7 +765,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
});
@@ -744,7 +784,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
});
@@ -769,7 +809,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
@@ -792,7 +832,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
});
@@ -804,7 +844,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
const tokenIn = result.details.find((d) => d.label === "Token In");
@@ -827,7 +867,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
// UNWRAP_WETH means output is native ETH
expect(result.name).toBe("Swap USDT \u2192 ETH");
@@ -843,7 +883,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received");
@@ -863,7 +903,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to
@@ -943,7 +983,13 @@ describe("uniswap decoder", () => {
[subPlan],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
@@ -1031,7 +1077,13 @@ describe("uniswap decoder", () => {
"$beside an EXECUTE_SUB_PLAN step does not name either side",
({ commands, inputs, steps }) => {
const data = buildExecute(commands, inputs, 9999999999n);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(steps);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
@@ -1046,7 +1098,13 @@ describe("uniswap decoder", () => {
);
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
});
@@ -1064,7 +1122,7 @@ describe("uniswap decoder", () => {
2n ** 256n - 1n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe(
@@ -1079,7 +1137,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
const amount = result.details.find((d) => d.label === "Amount");
@@ -1154,7 +1212,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions
@@ -1188,7 +1246,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop.
@@ -1228,7 +1286,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe(
@@ -1273,7 +1331,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1302,7 +1360,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1339,7 +1397,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH");
@@ -1532,7 +1590,13 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
@@ -1584,7 +1648,13 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
@@ -1643,7 +1713,7 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
@@ -1699,7 +1769,7 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
@@ -1722,7 +1792,7 @@ describe("uniswap decoder", () => {
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
@@ -1756,7 +1826,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
@@ -1780,7 +1850,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT");
@@ -1811,7 +1881,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH");
@@ -1832,7 +1902,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1851,7 +1921,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
@@ -1871,7 +1941,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
@@ -1891,7 +1961,7 @@ describe("uniswap decoder", () => {
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
+522
View File
@@ -0,0 +1,522 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594).
//
// Every swap command, SWEEP, UNWRAP_WETH and V4 TAKE names the address it
// pays, and the decoder read none of them: a swap whose output went to
// another address still read "Min. received", as if the user received it. The
// router reads address(1) as the caller and address(2) as the router itself,
// which holds the output until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const uniswap = require("../src/shared/uniswap");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
// UniversalRouterV2 on Ethereum Mainnet.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// execute() of `steps`, each [command, input].
function execute(...steps) {
const commands = steps.map(([command]) => command);
return routerIface.encodeFunctionData("execute", [
solidityPacked(
commands.map(() => "uint8"),
commands,
),
steps.map(([, input]) => input),
9999999999n,
]);
}
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
function v2Swap(command, recipient, amount, limit, path) {
return [
command,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amount, limit, path, true],
),
];
}
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
return [
0x00,
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
recipient,
amountIn,
amountOutMin,
solidityPacked(
["address", "uint24", "address"],
[tokenIn, 3000, tokenOut],
),
true,
],
),
];
}
function sweep(token, recipient) {
return [
0x04,
coder.encode(["address", "address", "uint256"], [token, recipient, 0n]),
];
}
function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
}
function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
// The V4 actions a step below is built from.
const SWAP_USDT_FOR_USDC = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
const SETTLE_USDT = [
0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
];
function take(currency, recipient) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, 0n],
),
];
}
function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
}
// A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) {
return [
0x10,
coder.encode(
["bytes", "bytes[]"],
[
solidityPacked(
actions.map(() => "uint8"),
actions.map(([action]) => action),
),
actions.map(([, params]) => params),
],
),
];
}
function decode(data, sender = USER) {
return uniswap.decode(data, ROUTER, "0x1", {}, sender);
}
function labels(result) {
return result.details.map((d) => d.label);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
function paidTo(result) {
return result.details
.filter((d) => d.label === "Paid to")
.map((d) => d.address.toLowerCase());
}
// A swap paid to the user, the lines it has always had.
const PAID_TO_USER = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Min. received",
"Steps",
"Deadline",
];
const PAID_ELSEWHERE = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Min. paid out",
"Steps",
"Deadline",
];
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("each swap command's recipient", () => {
test.each([
{
kind: "V2_SWAP_EXACT_IN",
data: (r) =>
execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V2_SWAP_EXACT_OUT",
data: (r) =>
execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V3_SWAP_EXACT_IN",
data: (r) =>
execute(v3SwapExactIn(r, USDT_2000, ONE_WETH, USDT, WETH)),
min: "1.0000 WETH",
},
{
kind: "V4_SWAP with TAKE",
data: (r) =>
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it reads as before; paid to another address it names that address",
({ data, min }) => {
const viaPlaceholder = decode(data(MSG_SENDER));
expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER);
expect(detail(viaPlaceholder, "Min. received").value).toBe(min);
// The sender's own address, in any case, reads the same.
expect(decode(data(USER), USER.toLowerCase()).details).toEqual(
viaPlaceholder.details,
);
const toOther = decode(data(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
});
describe("an output the router holds", () => {
const toRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
test("SWEEP of the output token pays it to the SWEEP's recipient", () => {
const direct = decode(
execute(
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
);
const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(toUser)).toEqual(withoutSteps(direct));
const toOther = decode(execute(toRouter, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("is paid to the router when nothing pays it out", () => {
// A SWEEP of another token leaves it there.
for (const data of [
execute(toRouter),
execute(toRouter, sweep(USDT, MSG_SENDER)),
]) {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([ROUTER]);
}
});
test("the router's own address as a recipient is read as address(2) is", () => {
const toRouterAddress = v3SwapExactIn(
ROUTER,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([ROUTER]);
expect(
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
).toEqual(PAID_TO_USER);
});
test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => {
const toWeth = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
ONE_WETH,
USDT,
WETH,
);
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH");
const toOther = decode(execute(toWeth, unwrapWeth(OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH");
});
test("with no step setting the output, UNWRAP_WETH's recipient is paid the ETH", () => {
const wrap = wrapEth(ADDRESS_THIS, ONE_WETH);
const toUser = decode(execute(wrap, unwrapWeth(MSG_SENDER)));
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(paidTo(toUser)).toEqual([]);
const toOther = decode(execute(wrap, unwrapWeth(OTHER)));
expect(detail(toOther, "Token Out").value).toBe("ETH");
expect(paidTo(toOther)).toEqual([OTHER]);
});
test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => {
const step = v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
);
const toUser = decode(execute(step, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const toOther = decode(execute(step, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
});
});
describe("several V4 takes of the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it names both", () => {
const toUser = decode(
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
);
expect(labels(toUser)).toEqual(PAID_TO_USER);
// TAKE with an amount of zero takes the whole open credit, so
// TAKE_ALL after it pays the user nothing.
const split = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDC),
),
),
);
expect(paidTo(split)).toEqual([OTHER, USER.toLowerCase()]);
expect(detail(split, "Min. received")).toBeUndefined();
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
});
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapPaidTo = (recipient) =>
execute(v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH));
// Open the approval screen for a transaction from USER sending `data` to the
// router.
async function showApproval(data) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (_msg, reply) =>
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: USER,
approvedTx: {
type: 2,
from: USER,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: ROUTER,
value: "0x0",
data,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
return node("approve-tx-decoded-details").innerHTML;
}
// The transaction the explorer reports: from USER, sending `mockSentData` to
// the router.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentData = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
from: { hash: USER },
to: { hash: ROUTER },
raw_input: mockSentData,
method: "execute",
};
}
return {};
}
// Open the transaction detail screen for that transaction, sending `data`.
async function showDetail(data) {
mockSentData = data;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: USER,
to: ROUTER,
value: "",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: "0x1",
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
return node("tx-detail-calldata-details").innerHTML;
}
describe("the screens judge the output against the transaction's sender", () => {
test("the approval screen, against its From address", async () => {
const toUser = await showApproval(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showApproval(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).toContain(OTHER);
expect(toOther).not.toContain("Min. received");
});
test("the transaction detail screen, against the sender the explorer reports", async () => {
const toUser = await showDetail(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showDetail(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).not.toContain("Min. received");
});
});
+8 -1
View File
@@ -53,7 +53,14 @@ function unwrapData() {
}
function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {});
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
data,
ROUTER,
"0x1",
sources || {},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
+1 -1
View File
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER);
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
+2 -1
View File
@@ -82,7 +82,8 @@ function walletsHolding(token, decimals) {
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER, "0x1");
// Sent by the address the swap pays, so the output is the user's.
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
return decoded.details.find((d) => d.label === label);
}