2 Commits
Author SHA1 Message Date
clawbot ab86707a77 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 collects the address that every
swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays, across the
whole transaction, leaving out payments to the router itself. The minimum
reads Min. received only when some step pays the sender, none pays another
address, and no step that may pay an address went unread: a recipient that
does not decode, or a command or V4 action the decoder does not read.
Otherwise it reads Min. paid out, with a Paid to line for each other address
and one saying when who is paid is unknown. The approval and transaction
detail screens pass the sender in.

Model: opus-5-5
2026-10-19 06:58:59 +00:00
clawbot e6fe9b2593 test: the popup boot in the jest suite sends no request to the internet (closes #559)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
bootPopup() in tests/support/popupBoot.js mocked three of the modules
that reach the network, but a boot onto the transaction screen still
asked Blockscout for its transaction, and a boot onto the confirmation
screen had src/shared/etherscanLabels.js look the recipient up on
Etherscan, both through the real fetch().
tests/persistedFieldContract.test.js boots onto both, so every run sent
those requests and logged their answers after its tests had finished.
bootPopup() now replaces fetch() with the throwing stub other test files
use, and cleanupPopup() puts it back. tests/popupBoot.test.js checks
that both requests reach the stub.

Model: opus-5-5
2026-10-19 08:46:18 +02:00
8 changed files with 482 additions and 212 deletions
+25 -24
View File
@@ -1144,9 +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; `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:
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid, 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.
@@ -2280,9 +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, 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)
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying who it pays is
unknown (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
@@ -2917,24 +2917,25 @@ 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. 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.
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's
sender. The router reads `address(1)` as the transaction's sender, and
`address(2)` as the router itself, which holds what it is paid until a later
step pays it out; a payment to the router, as `address(2)` or by its own
address, is not counted as paid out. Every payment counts, in whichever step
and of whatever token. The minimum reads `Min. received` only when some step
pays the transaction's sender (the From address on TxApproval, the sender the
explorer reports on TransactionDetail), no step pays any other address, and no
step that may pay an address went unread. Otherwise it reads `Min. paid out`,
a `Paid to` line after `Token Out` names each other address paid, and one more
`Paid to` line reads `Unknown (could not be decoded)` when such a step went
unread, or `Unknown (not named in the calldata)` when no step pays anyone. A
step goes unread when its recipient does not decode as an address, or when it
is a command or V4 action the decoder does not read: every command other than
`PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and `EXECUTE_SUB_PLAN` among
them), and V4 `TAKE_PORTION`.
### Non-Goals Forever
+134 -137
View File
@@ -8,7 +8,6 @@ const {
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
const { sameAddress } = require("./approvalVerify");
const { NETWORKS } = require("./networks");
const coder = AbiCoder.defaultAbiCoder();
@@ -102,14 +101,16 @@ function formatAmount(raw, decimals) {
// One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity.
// on a scale — not as a token name, and not as a quantity. The Paid to line
// uses it too, when no step names anyone paid.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false.
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// Explicit presence, never truthiness. Every gate in this file that guards a
@@ -180,33 +181,21 @@ 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.
// and ADDRESS_THIS pays the router itself, which holds what it is paid until a
// later step pays it out. The swap commands, SWEEP, WRAP_ETH 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
// - TAKE, TAKE_ALL and TAKE_PORTION: 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
@@ -363,7 +352,19 @@ function decodeV3SwapExactIn(input) {
function decodeSweep(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { token: d[0], recipient: d[1] };
return { recipient: d[1] };
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
// recipient.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { recipient: d[0], amount: d[1] };
} catch {
return null;
}
@@ -381,17 +382,6 @@ function decodeUnwrapWeth(input) {
}
}
// Decode WRAP_ETH (command 0x0b) input bytes.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { amount: d[1] };
} catch {
return null;
}
}
// V4 inner action IDs
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
@@ -401,6 +391,7 @@ const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
const V4_TAKE_PORTION = 0x10;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -556,9 +547,9 @@ function totalAmount(amounts) {
// 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.
// `recipients` is the recipient of every TAKE as written, and MSG_SENDER for
// every TAKE_ALL, whatever currency each takes. `recipientUnread` is set when
// the step has a payout this decoder did not read.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -567,8 +558,8 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
// Every TAKE and TAKE_ALL, as { currency, recipient }.
const takes = [];
const recipients = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
@@ -598,12 +589,12 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
takes.push({ currency: t[0], recipient: t[1] });
recipients.push(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 });
recipients.push(MSG_SENDER);
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -615,17 +606,22 @@ function decodeV4Swap(input) {
} catch {
swapUndecoded = true;
}
} else if (actionId === V4_TAKE_PORTION) {
// It pays a share of a currency to an address this decoder
// does not read
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
// V4Router reverts on any action not named here.
recipientUnread = true;
}
} catch {
// Skip sub-actions we can't decode
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among
// them is a payout this decoder did not read.
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) {
recipientUnread = true;
}
}
}
const recipientsOf = (currency) =>
takes
.filter((t) => sameAddress(t.currency, currency))
.map((t) => t.recipient);
// Rule 3.
if (swapUndecoded) {
return {
@@ -633,7 +629,8 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
outputRecipients: recipientsOf(takeToken),
recipients,
recipientUnread,
swapUndecoded,
hasSwapAction: true,
};
@@ -673,7 +670,8 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
outputRecipients: recipientsOf(tokenOut),
recipients,
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
@@ -706,9 +704,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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;
@@ -744,23 +739,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
// The step that sets the output side also says who it is paid to.
const setOutput = (token, amount, addresses) => {
const setOutput = (token, amount) => {
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,
);
// Every address any step pays, whatever it pays, with MSG_SENDER read
// as the sender. The router itself, as ADDRESS_THIS or by its own
// address, is left out: what the router holds is not yet paid out.
const payees = [];
// Set when a step may pay an address this decoder did not read.
let payeeUnread = false;
const addPayee = (recipient) => {
const r = recipient.toLowerCase();
if (r === ADDRESS_THIS || r === toAddress.toLowerCase()) return;
payees.push(r === MSG_SENDER ? fromAddress || "" : recipient);
};
for (let i = 0; i < commandsBytes.length; i++) {
@@ -775,90 +770,84 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step
// says so, and after this loop neither side is named.
// Those commands may pay any address.
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
}
if (cmdId === 0x0a) {
payeeUnread = true;
} else if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
}
if (cmdId === 0x0e) {
} else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at
// 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.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance, outputPaidTo);
setOutput(b.token, b.minBalance);
}
}
if (cmdId === 0x00) {
} else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
// 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,
paidTo([s.recipient]),
);
setOutput(s.tokenOut, s.amountOutMin);
addPayee(s.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x08) {
} else if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(
s.tokenOut,
s.amountOutMin,
paidTo([s.recipient]),
);
setOutput(s.tokenOut, s.amountOutMin);
addPayee(s.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x09) {
} else if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(
s.tokenOut,
s.amountOut,
paidTo([s.recipient]),
);
setOutput(s.tokenOut, s.amountOut);
addPayee(s.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x04) {
} else if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]);
if (s && sameAddress(s.token, outputToken)) {
payOutFromRouter(s.recipient);
if (s) {
addPayee(s.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x0b) {
} else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
addPayee(w.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x10) {
} else if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
v4.recipients.forEach(addPayee);
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
}
if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step
// is marked.
@@ -880,7 +869,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
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
@@ -888,27 +876,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(
v4.tokenOut,
v4.amountOutMin,
paidTo(v4.outputRecipients),
);
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
if (cmdId === 0x0c) {
} else 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);
if (u) {
addPayee(u.recipient);
} else {
payeeUnread = true;
}
} else {
// Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER,
// PAY_PORTION and the others
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
payeeUnread = true;
}
} catch {
// Skip commands we can't decode
// Skip commands we can't decode. One may pay any address.
payeeUnread = true;
}
}
@@ -922,7 +909,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
inputAmount = null;
outputToken = null;
minOutput = null;
outputPaidTo = [];
outputEstablished = true;
}
@@ -935,9 +921,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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 && (isWeth(outputToken) || !outputEstablished)
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
@@ -1052,20 +1041,28 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// 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),
// The output is received by the sender only when some step pays the
// sender, none pays another address, and none may pay an address
// this decoder did not read. Otherwise each other address paid is
// named, once, and a further line says when a payout could not be
// read or no step names anyone paid.
const sender = (fromAddress || "").toLowerCase();
const paidElsewhere = payees.filter(
(a, i) =>
a.toLowerCase() !== sender &&
payees.findIndex((b) => b.toLowerCase() === a.toLowerCase()) ===
i,
);
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 });
}
const received =
payees.length > 0 && paidElsewhere.length === 0 && !payeeUnread;
for (const address of paidElsewhere) {
details.push({ label: "Paid to", value: address, address });
}
if (payeeUnread || payees.length === 0) {
details.push({
label: "Paid to",
value: payeeUnread ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
}
if (present(minOutput)) {
@@ -1076,7 +1073,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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: paidElsewhere ? "Min. paid out" : "Min. received",
label: received ? "Min. received" : "Min. paid out",
value:
minOutput === 0n
? NO_MINIMUM
+45
View File
@@ -9,6 +9,7 @@ const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const FLASH_MS = 10;
@@ -40,3 +41,47 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
await wait(FLASH_MS * 5);
});
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
function restoringOnto(view, viewData) {
return unversionedValidProfile({
currentView: view,
selectedWallet: 0,
selectedAddress: 0,
viewStack: ["main"],
viewData,
});
}
test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup(
restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
}),
);
expect(page.visibleViews()).toEqual(["transaction"]);
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
"https://eth.blockscout.com/api/v2/transactions/0x1",
]);
});
test("the confirmation screen's Etherscan lookup reaches the stub", async () => {
const page = await bootPopup(
restoringOnto("confirm-tx", {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
},
}),
);
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
"https://etherscan.io/address/" + ADDRESS,
]);
});
+16 -2
View File
@@ -45,6 +45,7 @@ const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
const realFetch = globalThis.fetch;
// Every timeout set since the last cleanupPopup() that has neither fired nor
// been cleared. cleanupPopup() clears them all.
@@ -260,8 +261,14 @@ async function settle() {
async function bootPopup(stored, options) {
jest.resetModules();
// The three modules that reach the network. None is on the path under
// test; all would make the suite hit the internet.
// The RPC requests made through src/shared/balances.js go through Node's
// own http and https modules, not fetch(), so the fetch() stub below would
// not stop them, which is why that module stays mocked.
// src/shared/prices.js and src/shared/transactions.js send their requests
// through fetch(), and their mocks answer with no prices and no
// transactions. Every request the page still sends, such as the
// transaction screen's Blockscout lookup and the Etherscan lookup in
// src/shared/etherscanLabels.js, meets the fetch() stub.
jest.doMock("../../src/shared/prices", () => ({
prices: {},
refreshPrices: jest.fn(async () => {}),
@@ -334,6 +341,12 @@ async function bootPopup(stored, options) {
windowListeners[name].push(fn);
},
};
// Every fetch() the page makes fails, as it does elsewhere in the suite,
// instead of reaching the internet. A new stub per boot, so the calls it
// records start with that boot. Put back by cleanupPopup().
globalThis.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than
// started, clearInterval() removes it as a browser would, and tick() below
@@ -435,6 +448,7 @@ function cleanupPopup() {
pendingTimeouts.clear();
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
globalThis.fetch = realFetch;
}
module.exports = {
+15 -6
View File
@@ -362,8 +362,10 @@ describe("uniswap decoder", () => {
expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited",
);
// This fixture's TAKE parameters are one byte long, so who it pays
// cannot be read, and the minimum is not said to be received.
expect(
result.details.find((d) => d.label === "Min. received").value,
result.details.find((d) => d.label === "Min. paid out").value,
).toBe("0.0002 ETH");
// Real mainnet calldata is in the deployed shape, so its V4 step is
@@ -873,6 +875,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap USDT \u2192 ETH");
});
// No step pays anyone, so the minimum is not said to be received.
test("decodes BALANCE_CHECK_ERC20 for min output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -886,7 +889,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received");
const minOut = result.details.find((d) => d.label === "Min. paid out");
expect(minOut).toBeDefined();
expect(minOut.value).toContain("2.0000");
expect(minOut.value).toContain("USDT");
@@ -1414,9 +1417,12 @@ describe("uniswap decoder", () => {
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
// pays the user, so it is not said to be received.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
minLabel: "Min. received",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
@@ -1425,11 +1431,13 @@ describe("uniswap decoder", () => {
},
{
ending: "SETTLE and TAKE",
minLabel: "Min. received",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
minLabel: "Min. paid out",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
@@ -1578,7 +1586,7 @@ describe("uniswap decoder", () => {
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
({ swaps, ids, params, amount, min, minLabel }) => {
const data = buildExecute(
"0x10",
[
@@ -1601,7 +1609,7 @@ describe("uniswap decoder", () => {
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
expect(detail(result, minLabel)?.value).toBe(min);
},
);
@@ -1910,7 +1918,8 @@ describe("uniswap decoder", () => {
});
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing.
// output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -1925,7 +1934,7 @@ describe("uniswap decoder", () => {
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. received").value).toBe(
expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
+241 -40
View File
@@ -1,11 +1,12 @@
// 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.
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the
// address it pays, and TAKE_ALL pays the sender. 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
// sender and address(2) as the router itself, which holds what it is paid
// until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
@@ -38,6 +39,8 @@ const transactionDetail = require("../src/popup/views/transactionDetail");
// UniversalRouterV2 on Ethereum Mainnet.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
@@ -51,6 +54,10 @@ const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
@@ -114,6 +121,23 @@ function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
function balanceCheck(token, minBalance) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
),
];
}
// `step`, a [command or action, input] pair, with the top byte of the input's
// word `index` set. The decoder cannot read that word as an address.
function withDirtyWord([id, input], index) {
const at = 2 + index * 64;
return [id, input.slice(0, at) + "ff" + input.slice(at + 2)];
}
// The V4 actions a step below is built from.
const SWAP_USDT_FOR_USDC = [
0x06, // SWAP_EXACT_IN_SINGLE
@@ -124,6 +148,8 @@ const SWAP_USDT_FOR_USDC = [
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [
0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
@@ -158,8 +184,8 @@ function v4Swap(...actions) {
];
}
function decode(data, sender = USER) {
return uniswap.decode(data, ROUTER, "0x1", {}, sender);
function decode(data, sender = USER, router = ROUTER) {
return uniswap.decode(data, router, "0x1", {}, sender);
}
function labels(result) {
@@ -173,7 +199,7 @@ function detail(result, label) {
function paidTo(result) {
return result.details
.filter((d) => d.label === "Paid to")
.map((d) => d.address.toLowerCase());
.map((d) => d.value);
}
// A swap paid to the user, the lines it has always had.
@@ -197,6 +223,10 @@ const PAID_ELSEWHERE = [
"Deadline",
];
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
@@ -218,8 +248,7 @@ describe("each swap command's recipient", () => {
},
{
kind: "V3_SWAP_EXACT_IN",
data: (r) =>
execute(v3SwapExactIn(r, USDT_2000, ONE_WETH, USDT, WETH)),
data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH",
},
{
@@ -256,7 +285,7 @@ describe("an output the router holds", () => {
USDC,
);
test("SWEEP of the output token pays it to the SWEEP's recipient", () => {
test("SWEEP pays it to the SWEEP's recipient", () => {
const direct = decode(
execute(
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
@@ -274,16 +303,10 @@ describe("an output the router holds", () => {
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("with nothing paying it out, no one is named as paid", () => {
const result = decode(execute(toRouter));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([UNNAMED]);
});
test("the router's own address as a recipient is read as address(2) is", () => {
@@ -294,20 +317,14 @@ describe("an output the router holds", () => {
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([ROUTER]);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([UNNAMED]);
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 toWeth = swapToWeth(ADDRESS_THIS);
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
@@ -320,15 +337,16 @@ describe("an output the router holds", () => {
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)));
test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => {
const toUser = decode(
execute(wrapEth(ADDRESS_THIS, ONE_WETH), 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");
const toOther = decode(
execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(paidTo(toOther)).toEqual([OTHER]);
});
@@ -348,8 +366,8 @@ describe("an output the router holds", () => {
});
});
describe("several V4 takes of the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it names both", () => {
describe("every step's payee counts, whichever step sets the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
const toUser = decode(
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
);
@@ -367,10 +385,194 @@ describe("several V4 takes of the output", () => {
),
),
);
expect(paidTo(split)).toEqual([OTHER, USER.toLowerCase()]);
expect(paidTo(split)).toEqual([OTHER]);
expect(detail(split, "Min. received")).toBeUndefined();
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a swap paid to another address, then a V4 step that only takes the same token", () => {
const emptyStep = v4Swap(takeAll(WETH));
const result = decode(execute(swapToWeth(OTHER), emptyStep));
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
const checked = decode(
execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)),
);
expect(paidTo(checked)).toEqual([OTHER]);
expect(detail(checked, "Min. received")).toBeUndefined();
expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => {
const result = decode(
execute(
v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC),
v4Swap(takeAll(USDC)),
sweep(USDC, OTHER),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
test("a swap paid to another address, then a small swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(OTHER),
v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
});
test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDT),
),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("a payout that cannot be read is never said to be received", () => {
test("a V4 TAKE whose recipient word has its top byte set", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
withDirtyWord(take(USDC, OTHER), 1),
),
),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test.each([
{
kind: "V2_SWAP_EXACT_IN",
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
},
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
])(
"a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
test("an output set only by BALANCE_CHECK_ERC20", () => {
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
expect(paidTo(result)).toEqual([UNNAMED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
test.each([
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
{ kind: "TRANSFER", step: transferShape(0x05) },
{ kind: "PAY_PORTION", step: transferShape(0x06) },
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
{
kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [
0x0d,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER, OTHER, 1n, WETH]]],
),
],
},
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{
kind: "EXECUTE_SUB_PLAN",
step: [
0x21,
coder.encode(
["bytes", "bytes[]"],
["0x05", [transferShape(0x05)[1]]],
),
],
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])],
},
{
kind: "V4 TAKE_PORTION",
step: v4Swap([
0x10,
coder.encode(
["address", "address", "uint256"],
[WETH, OTHER, 10000n],
),
]),
},
])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("paths whose recipients were untested", () => {
test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => {
const check = balanceCheck(WETH, ONE_WETH);
const toUser = decode(execute(swapToWeth(MSG_SENDER), check));
expect(paidTo(toUser)).toEqual([]);
expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH");
const toOther = decode(execute(swapToWeth(OTHER), check));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a V4 step whose swap action cannot be read still has its TAKE read", () => {
const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r));
const toUser = decode(execute(step(MSG_SENDER)));
expect(detail(toUser, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(paidTo(toUser)).toEqual([]);
expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]);
});
});
// The DOM stub both screens are drawn into.
@@ -417,8 +619,7 @@ function node(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));
const swapPaidTo = (recipient) => execute(swapToWeth(recipient));
// Open the approval screen for a transaction from USER sending `data` to the
// router.
+3 -2
View File
@@ -87,7 +87,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a Min. received
// BALANCE_CHECK still names the output, so the screen has a minimum
// figure and a Token Out, and previously claimed the user was paying ETH
// for them.
const data = () =>
@@ -132,7 +132,8 @@ describe("an execute() that names only an output token", () => {
test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
// No step pays anyone, so the minimum is not said to be received.
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
});
});
+3 -1
View File
@@ -134,7 +134,9 @@ describe("a V4 step that states a Min. received but no output currency", () => {
});
test("refuses to scale Min. received rather than assuming 18", () => {
expect(detail(data(), "Min. received").value).toBe(
// With no TAKE, no step pays the user either, so the line is not
// labelled received.
expect(detail(data(), "Min. paid out").value).toBe(
unknownDecimalsAmount(MIN_OUT),
);
});