2 Commits
Author SHA1 Message Date
sneak e9dc1fdccd fix: the wallet's own send shows its amount to four decimal places (closes #522)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ConfirmTx and the wait, success and error screens printed the text from
Send's amount field as typed or as Max filled it in: `0.5 ETH`, `.5 ETH`, or
`1.234147890123456789 ETH` after Max, where TxApproval shows a site's send
as `0.5000 ETH`. ConfirmTx now writes the amount as TxApproval does: parsed
at the scale it is encoded with, written back as plain decimal text, and cut
by truncateAmountNeverZero(). It hands the status screens a copy carrying
that text. The transaction is still signed for the exact amount, which stays
on the pending transaction, and the USD value is priced from it. A saved
confirmation screen whose amount is not text is no longer redrawn.

Model: opus-5-5
2026-10-19 13:51:04 +00:00
clawbot c99772a520 fix: a step the router lets fail is marked and sets no minimum (closes #602)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each Universal Router release now carries the command types its dispatch()
can return a failure for instead of reverting, read from its source; only a
step of one of those with the allow-revert flag (0x80) set can fail while the
transaction goes on. Such a step is listed with "(allowed to fail)". A minimum
such a step states guarantees nothing, so it is read as zero: a
BALANCE_CHECK_ERC20 then keeps an earlier step's minimum, and 0x10 on
UniversalRouterV1 and UniversalRouterV1_2_V2Support, an NFT call there still
read as a V4 swap, states no minimum. Every swap and every payment of ETH or an
ERC-20 token succeeds or reverts the whole transaction, so the flag on them has
no effect.

Model: opus-5-5
2026-10-19 15:24:13 +02:00
6 changed files with 569 additions and 69 deletions
+41 -16
View File
@@ -1200,7 +1200,11 @@ number. They can read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
`minBalance` sets both lines, as a swap step does. A `BALANCE_CHECK_ERC20`
step that the router lets fail without reverting the transaction (see
Transaction Decoding) guarantees nothing either, whatever its `minBalance`,
and is read as one whose `minBalance` is zero. A minimum stated by a V4 swap
step the router lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1761,14 +1765,15 @@ view would leave a wallet one click from deletion.
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses), the value to four decimal
places with the nonzero floor, as on TxApproval (see Display Consistency),
however many places Send's amount field held. An ETH amount Send's "Max"
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance could not be read
or no longer covers the fee, the amount is left as it was and one of the
errors below blocks the send
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
An ETH amount Send's "Max" filled in is worked out again from this
screen's own fee estimate when it arrives, as the balance minus the
reserve, and the transaction is signed with that estimate's fee fields, so
a fee fetched again at signing cannot exceed what the amount leaves
behind. The address keeps whatever part of the reserve the transaction
does not use. If the balance could not be read or no longer covers the
fee, the amount is left as it was and one of the errors below blocks the
send
- Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that
@@ -2974,13 +2979,33 @@ Currently supported:
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. Only the routers with the
`0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
`Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
`Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
there as `Command 0x07`. The decoder does not read the commands inside an
`EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
the user's tokens or pay the user. That step is listed as
(`0x80`) therefore does not change the command a step is read as. The router
reverts the whole transaction when a step fails, unless the step's command
byte has that flag set and its command is one the router lets fail, as each
release's `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
`BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
checks. Such a step may then fail while the transaction goes on to the next
step, and it is listed with ` (allowed to fail)` after its name, for example
`Balance Check (allowed to fail)`. On every other command, among them each V2,
V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
effect: the step succeeds or the whole transaction reverts, so the step reads
as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
guarantees nothing, so it is read as one whose `minBalance` is zero. On
`UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` is an NFT
market call the router lets fail, which the decoder still reads as a V4 swap
([#592](https://git.eeqj.de/sneak/AutistMask/issues/592)); with the flag set,
a minimum it states is read as zero: `None (no minimum guaranteed)`. Only the
routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
is listed there as `Command 0x07`. The decoder does not read the commands
inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
may spend the user's tokens or pay the user. That step is 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`
+27 -9
View File
@@ -100,10 +100,31 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate. Only the text on screen
// is cut to four decimals: the USD value is priced from the exact amount, and
// txInfo.amount, which is what gets signed, is left as it is.
// The amount as this screen and the wait, success and error screens show it,
// written as the approval screen writes a site's amount: the amount being
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does. txInfo.amount, which is what gets
// signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
@@ -112,7 +133,7 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
truncateAmountNeverZero(txInfo.amount) + " " + displaySymbol(rawSymbol),
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
@@ -703,10 +724,7 @@ function init(_ctx) {
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = {
...pendingTx,
amount: truncateAmountNeverZero(pendingTx.amount),
};
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
+105 -36
View File
@@ -24,55 +24,104 @@ const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there.
// The files also list `UnsupportedProtocol`, which is not a router.
// The Universal Router releases deployed at the addresses below, each with two
// things read from its source at the commit linked for it:
//
// Beside each address is the mask that router reads a command's type with:
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source verified
// for that address:
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
// - `commandTypeMask`, the mask it reads a command's type with: its dispatcher
// runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the
// mask are the allow-revert flag (0x80) or ignored. The mask grew between
// releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source
// verified for its addresses.
// - `allowRevert`, the command types it lets fail without reverting the
// transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each
// step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts
// the whole transaction when a step fails, unless the step's command byte
// has the allow-revert flag set: then it goes on to the next step. Only for
// these command types does `dispatch()` return a failure. Every other
// command, among them each V2, V3 and V4 swap and each payment of ETH or an
// ERC-20 token, succeeds or reverts the whole transaction itself, so the
// flag has no effect on it.
//
// UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts
const ROUTER_V1 = {
commandTypeMask: 0x1f,
// Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks
// (0x15, 0x16).
allowRevert: [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
],
};
// UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts
const ROUTER_V1_2 = {
commandTypeMask: 0x3f,
// BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to
// 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [
0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1e, 0x20, 0x21,
],
};
// UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts
const ROUTER_V2 = {
commandTypeMask: 0x3f,
// PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20
// (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL,
// V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21],
};
// UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts
// UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts
// The three files are the same in both releases.
const ROUTER_V2_1 = {
commandTypeMask: 0x7f,
// The same as UniversalRouterV2's.
allowRevert: ROUTER_V2.allowRevert,
};
// The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there
// and given with the release it runs. The files also list
// `UnsupportedProtocol`, which is not a router.
const ROUTER_ADDRESSES = new Map([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
]);
// The mask the Universal Router deployment at `address` on the network with
// this chain id reads a command's type with, or null when `address` is not one
// of them. Address case is ignored.
function routerCommandTypeMask(address, chainId) {
// The release that the Universal Router deployment at `address` on the
// network with this chain id runs, or null when `address` is not one of them.
// Address case is ignored.
function routerRelease(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase();
const router = routers.find(([a]) => a.toLowerCase() === wanted);
return router ? router[1] : null;
}
// The allow-revert flag on a command byte (see ROUTER_V1).
const ALLOW_REVERT_FLAG = 0x80;
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
@@ -123,7 +172,7 @@ function v2PairFor(chainId, tokenA, tokenB) {
}
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
// router's mask (see ROUTER_V1). Only the routers with the 0x7f mask
// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers
// revert on 0x07, so decode() names it there as it names a command not
// listed here.
@@ -856,8 +905,9 @@ function decodeV4Swap(input) {
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, allowRevert } = release;
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -986,6 +1036,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
// The router goes on to the next step if this one fails (see
// ROUTER_V1). The flag on any other command has no effect.
const allowedToFail =
(commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 &&
allowRevert.includes(cmdId);
// Only the routers with the 0x7f mask have command 0x07 (see
// COMMAND_NAMES).
const name =
@@ -1034,11 +1089,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
// does not replace a minimum an earlier step stated. A
// check the router lets fail guarantees nothing either, so
// it is read as a zero one. Any other minBalance sets the
// output side as a swap does. It pays nobody.
const minBalance = b && !allowedToFail ? b.minBalance : 0n;
if (b && !(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance);
}
} else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
@@ -1161,7 +1218,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
payeeUnread = true;
}
} else if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
let v4 = decodeV4Swap(inputs[i]);
// A step the router lets fail guarantees nothing, so a
// minimum it states is read as zero, as a
// BALANCE_CHECK_ERC20's is. Only UniversalRouterV1 and
// UniversalRouterV1_2_V2Support let this command fail; on
// them it is an NFT market call, still read here as a V4
// swap (https://git.eeqj.de/sneak/AutistMask/issues/592).
if (v4 && allowedToFail && present(v4.amountOutMin)) {
v4 = { ...v4, amountOutMin: 0n };
}
if (v4) {
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
@@ -1225,6 +1291,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Skip commands we can't decode. One may pay any address.
payeeUnread = true;
}
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
+28
View File
@@ -317,6 +317,34 @@ describe("the wallet's own send shows its amount to four decimal places", () =>
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
// Send accepts both. The approval screen writes the same value `0.5000`.
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
await openSend("ETH");
el("send-amount").value = typed;
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
test("for a token amount typed as .5", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = ".5";
await review();
expect(text("confirm-amount")).toBe("0.5000 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
const [, amount] = transferIface.decodeFunctionData(
"transfer",
broadcastTx().data,
);
expect(amount).toBe(500000n);
});
test("for a Max amount with many decimals", async () => {
await openSend("ETH");
await el("btn-send-max").handlers.get("click")();
+15 -7
View File
@@ -987,13 +987,23 @@ describe("uniswap decoder", () => {
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
{
flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
({ command, steps }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
@@ -1013,9 +1023,7 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
expect(detail(result, "Steps").value).toBe(steps);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
+353 -1
View File
@@ -1,6 +1,8 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599).
// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -149,6 +151,20 @@ function transfer(command, token, recipient, amount) {
];
}
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
function permit2Permit(token, amount) {
return [
0x0a,
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
@@ -1362,6 +1378,342 @@ describe("paths whose recipients were untested", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command
// byte (0x80) is the allow-revert flag. The router lets a step with it fail
// and goes on to the next step only for some commands, none of them a swap or
// a payment; on every other command the flag has no effect.
describe("the allow-revert flag", () => {
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// UniversalRouterV2_1_1 on Ethereum Mainnet.
const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA";
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// `step` with the allow-revert flag set on its command byte.
const flagged = ([command, input]) => [command | 0x80, input];
// `steps` with the flag set on the one at `index`.
const flagStep = (steps, index) =>
steps.map((s, i) => (i === index ? flagged(s) : s));
const usdcToRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const pairSpends = v2Swap(
0x08,
MSG_SENDER,
0n,
USDC_1990,
[WETH, USDC],
false,
);
// The flag is set on the step at `index`: a swap, a payment, or the V2
// swap that a payment to its pair hops into.
test.each([
{
kind: "V2_SWAP_EXACT_IN",
steps: [
v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]),
],
index: 0,
},
{
kind: "V2_SWAP_EXACT_OUT",
steps: [
v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]),
],
index: 0,
},
{
kind: "V3_SWAP_EXACT_IN",
steps: [swapToWeth(MSG_SENDER)],
index: 0,
},
{
kind: "V4_SWAP",
steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))],
index: 0,
},
{
kind: "SWEEP",
steps: [usdcToRouter, sweep(USDC, MSG_SENDER)],
index: 1,
},
{
kind: "TRANSFER",
steps: [
usdcToRouter,
transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PAY_PORTION",
steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)],
index: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)],
index: 1,
},
{
kind: "UNWRAP_WETH",
steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)],
index: 1,
},
{
kind: "WRAP_ETH",
steps: [
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
],
index: 0,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
steps: [
usdcToRouter,
acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends",
steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends],
index: 0,
},
{
kind: "a swap paid to the pair the next V2 swap spends",
steps: [wethToPair, pairSpends],
index: 0,
},
{
kind: "the V2 swap after a swap paid to its pair",
steps: [wethToPair, pairSpends],
index: 1,
},
])(
"$kind reads the same with the flag as without it",
({ steps, index }) => {
const plain = decode(execute(...steps), USER, ROUTER_V2_1_2);
const withFlag = decode(
execute(...flagStep(steps, index)),
USER,
ROUTER_V2_1_2,
);
expect(withFlag).toEqual(plain);
expect(detail(withFlag, "Steps").value).not.toContain(
"allowed to fail",
);
},
);
test.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
])(
"on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum",
({ address }) => {
const check = balanceCheck(WETH, 2n * ONE_WETH);
const plain = decode(
execute(swapToWeth(MSG_SENDER), check),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check",
);
expect(detail(plain, "Min. received").value).toBe("2.0000 WETH");
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(check)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
// On these two routers 0x10 is an NFT market call the router lets fail,
// still read as a V4 swap
// (https://git.eeqj.de/sneak/AutistMask/issues/592).
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1 },
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
])(
"on $router, a V4_SWAP with the flag is allowed to fail and states no minimum",
({ address }) => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC));
const plain = decode(execute(step), USER, address);
expect(detail(plain, "Steps").value).toBe("V4 Swap");
expect(detail(plain, "Min. received").value).toBe("1990.0000 USDC");
const withFlag = decode(execute(flagged(step)), USER, address);
expect(detail(withFlag, "Steps").value).toBe(
"V4 Swap (allowed to fail)",
);
expect(detail(withFlag, "Token Out").address).toBe(USDC);
expect(detail(withFlag, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
},
);
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
];
const plain = decode(execute(...steps), USER, address);
const withFlag = decode(
execute(...flagStep(steps, 0)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a step the decoder does not read is marked too", () => {
const positionManagerCall = [0x12, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), positionManagerCall),
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call",
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)),
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)",
);
expect(paidTo(withFlag)).toEqual([UNDECODED]);
});
// A command from each router's list, by its name in that release's
// Commands.sol.
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1,
command: "FOUNDATION",
id: 0x1c,
name: "Command 0x1c",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
command: "ELEMENT_MARKET",
id: 0x1e,
name: "Command 0x1e",
},
{
router: "UniversalRouterV2",
address: ROUTER,
command: "PERMIT2_PERMIT_BATCH",
id: 0x03,
name: "Permit2 Permit Batch",
},
{
router: "UniversalRouterV2_1_1",
address: ROUTER_V2_1_1,
command: "V4_INITIALIZE_POOL",
id: 0x13,
name: "V4 Initialize Pool",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
command: "V4_POSITION_MANAGER_CALL",
id: 0x14,
name: "V4 Position Mgr Call",
},
])(
"on $router, $command with the flag is listed as '$name (allowed to fail)'",
({ address, id, name }) => {
const step = [id, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name,
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(step)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name + " (allowed to fail)",
);
},
);
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();