Author SHA1 Message Date
sneak 427d99e0a0 fix: a permit on the zero address never reads as ETH (closes #660)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Permit2 has no allowance of ETH, but the swap decoder read a permit's
zero-address token as ETH. Its Allowance token line read ETH. A permit of
1 wei of the zero address before a V4 step of 5 ETH also stood as the
Amount figure, which then read 0.000000000000000001 ETH.

Such a permit now never stands as the Amount figure. Its lines give the
amount in base units and name the zero address, as for a token nothing
knows.

Model: opus-5-5
2026-10-27 02:56:35 +00:00
clawbot 53551c6382 fix: the StateRecovery erase warning names every kind of wallet backup (closes #593)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The StateRecovery erase warning told every user to rely on each wallet's recovery phrase. A wallet imported from a private key or an extended private key has none; its backup is that key. The warning now names all three, in the words the delete-wallet screens use, and README's StateRecovery entry says what a wallet can be added again from. A test reads the warning.

Model: opus-5-5
2026-10-27 03:46:17 +01:00
5 changed files with 241 additions and 91 deletions
+77 -70
View File
@@ -2820,6 +2820,9 @@ view would leave a wallet one click from deletion.
- "Export Saved Data" button, and the read-only text box it fills - "Export Saved Data" button, and the read-only text box it fills
- What erasing does and does not do, in bold: every wallet stored in this - What erasing does and does not do, in bold: every wallet stored in this
browser is deleted; nothing on chain changes and no money is moved browser is deleted; nothing on chain changes and no money is moved
- What a wallet can be added again from: a kept copy of its recovery phrase,
private key or extended private key. Without that copy or the exported
copy, everything the wallet holds is gone forever.
- A text input asking for `ERASE MY WALLET` to be typed back - A text input asking for `ERASE MY WALLET` to be typed back
- Error line - Error line
- "Erase Saved Data" button - "Erase Saved Data" button
@@ -3488,52 +3491,54 @@ Currently supported:
which it does only in the plain shape of Uniswap's own routes: it comes before which it does only in the plain shape of Uniswap's own routes: it comes before
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
the router may not let it fail, no step before it takes its token, a its token is not the zero address, the router may not let it fail, no step
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, before it takes its token, a `PERMIT2_TRANSFER_FROM` of it included, or a
and the decoder reads every step after it in full. Permit2 then lets the token the decoder does not know, and the decoder reads every step after it in
router take no more of the token than that amount, so the permit covers every full. Permit2 then lets the router take no more of the token than that amount,
take of it. A step before the permit takes under whatever allowance the router so the permit covers every take of it. Permit2 has no allowance of ETH, so a
already had, so it may take more than the permit's amount, which would then permit on the zero address covers no take of ETH. A step before the permit
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of takes under whatever allowance the router already had, so it may take more
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 than the permit's amount, which would then read lower than what leaves the
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router, then a
later permit or batch may raise what Permit2 lets the router take, and so may permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid from the
a step the decoder does not read in full: a command the decoder does not read router, read `Amount 2000.0000 USDT`. A later permit or batch may raise what
(listed at the end of this section), a step whose input it cannot read, in Permit2 lets the router take, and so may a step the decoder does not read in
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before full: a command the decoder does not read (listed at the end of this section),
the step that sets the input side or after it, leaves `Token In` and the a step whose input it cannot read, in whole or in part, and a step it fails
`Amount` line as they read with no permit, unless it lets a spender other than on. Every other `PERMIT2_PERMIT`, before the step that sets the input side or
the router take `Token In` (see below): a permit of 5,000 DAI to another after it, leaves `Token In` and the `Amount` line as they read with no permit,
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with unless it lets a spender other than the router take `Token In` (see below): a
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI permit of 5,000 DAI to another address and then a V3 exact-in of 600 USDT read
to the router. When a permit's amount stands and the step that sets the input `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do that exact-in and
side spends a token other than the permit's, or one the decoder does not know, then a permit of 5,000 DAI to the router. When a permit's amount stands and
that step's amount gets lines of its own, unless it states no amount and the the step that sets the input side spends a token other than the permit's, or
user pays it none of its input token. Each take the total does not count gives one the decoder does not know, that step's amount gets lines of its own,
three lines after the `Amount` line (after `Token In` when there is none): one unless it states no amount and the user pays it none of its input token. Each
of a token other than `Token In`; one of `Token In` after an amount that is take the total does not count gives three lines after the `Amount` line (after
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on `Token In` when there is none): one of a token other than `Token In`; one of
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one `Token In` after an amount that is not a quantity (a V4 open delta,
whose amount the decoder does not know. When there is no `Amount` line, each `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
take the total counts gets them too, so that every amount the decoder reads is amount, as of a V4 exact-out step); and one whose amount the decoder does not
shown: the amount the step that sets the input side states, when it is a know. When there is no `Amount` line, each take the total counts gets them
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is too, so that every amount the decoder reads is shown: the amount the step that
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose sets the input side states, when it is a quantity and the user pays that step
ETH comes from the transaction's value), and each later take of `Token In` (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
added to it. A step that sets the input side and is paid from the router takes its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder value), and each later take of `Token In` added to it. A step that sets the
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give input side and is paid from the router takes nothing from the user's wallet
lines for both amounts. When the step that sets the input side states no and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
amount, the user pays it some of its input token, and there is no `Amount` of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
line, that step gets them too whenever another take's lines give a figure, or the step that sets the input side states no amount, the user pays it some of
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into its input token, and there is no `Amount` line, that step gets them too
the router, on its `Paid to` line, so that figure cannot read as all that whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of
leaves. When it states no amount and the user pays it none of its input token, its token gives one, on the lines below or, into the router, on its `Paid to`
as when the router pays it, that step takes none of that token from the user's line, so that figure cannot read as all that leaves. When it states no amount
wallet and never gets them, with a permit or without. They come in step order, and the user pays it none of its input token, as when the router pays it, that
except that the step that sets the input side comes first when it has them, step takes none of that token from the user's wallet and never gets them, with
followed, when there is no `Amount` line, by the other takes the total counts: a permit or without. They come in step order, except that the step that sets
`Moved from the sender`, its amount, worded as the `Amount` line words one, or the input side comes first when it has them, followed, when there is no
`Amount` line, by the other takes the total counts: `Moved from the sender`,
its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. A token the decoder
@@ -3566,30 +3571,32 @@ Currently supported:
amount stands gives none: the `Amount` line gives its amount, `Token In` its amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH` token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH`
grants, one for each token it names, all for its one spender, gives the same grants, one for each token it names, all for its one spender, gives the same
three lines, in step order with those of each `PERMIT2_PERMIT`. A three lines, in step order with those of each `PERMIT2_PERMIT`. For an
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot allowance of the zero address, `Permit2 allowance` gives the amount in base
read, such as one whose token or spender word has a nonzero top byte, may let units and `Allowance token` gives the zero address. A `PERMIT2_PERMIT` or
any spender take any amount of any token: it gives one `Permit2 allowance` `PERMIT2_PERMIT_BATCH` whose input the decoder cannot read, such as one whose
line alone, reading `Unknown (could not be decoded)`. These lines change no token or spender word has a nonzero top byte, may let any spender take any
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such amount of any token: it gives one `Permit2 allowance` line alone, reading
as a V4 pool's hook or a token's own code, can call a spender other than the `Unknown (could not be decoded)`. These lines change no `Paid to` line: a
router, which can then take up to its allowance from the user's wallet before permit or batch pays nobody. But code that a step runs, such as a V4 pool's
the transaction ends. So an allowance to any spender but the router, or a hook or a token's own code, can call a spender other than the router, which
permit or batch the decoder cannot read, makes the minimum read can then take up to its allowance from the user's wallet before the
`Min. paid out`, and when its token is `Token In`, or the decoder does not transaction ends. So an allowance to any spender but the router, or a permit
know its token or `Token In`, it leaves no `Amount` line, whose figure might or batch the decoder cannot read, makes the minimum read `Min. paid out`, and
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in when its token is `Token In`, or the decoder does not know its token or
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its `Token In`, it leaves no `Amount` line, whose figure might be too low: a
own. A step goes unread when its recipient does not decode as an address, when permit of 5,000 USDT to another address and then a V3 exact-in of 600 USDT
it is a V3 swap step whose path is shorter than one token address or has bytes read with no `Amount` line, and the 600 USDT gets lines of its own. A step
after its last hop, or when it is a command the decoder does not read: every goes unread when its recipient does not decode as an address, when it is a V3
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, swap step whose path is shorter than one token address or has bytes after its
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, last hop, or when it is a command the decoder does not read: every command
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
them), or a type its release names no command for. `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`PERMIT2_TRANSFER_FROM_BATCH`,
`EXECUTE_SUB_PLAN` and the NFT commands among them), or a type its release
names no command for.
### Non-Goals Forever ### Non-Goals Forever
+5 -4
View File
@@ -1994,10 +1994,11 @@
>Erasing the saved data deletes every wallet stored in >Erasing the saved data deletes every wallet stored in
this browser.</strong this browser.</strong
> >
Nothing on the blockchain changes and no money is moved, but Nothing on the blockchain changes and no money is moved. If
without the exported copy above, or the recovery phrase for you kept a copy of a wallet's recovery phrase, private key
each wallet written down, everything they hold is gone or extended private key, you can add that wallet again.
forever. Without that copy or the exported copy above, everything the
wallet holds is gone forever.
</p> </p>
<p class="text-xs mb-1"> <p class="text-xs mb-1">
To confirm, type To confirm, type
+41 -17
View File
@@ -2023,14 +2023,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// plain shape of Uniswap's own routes: it comes before the step that // plain shape of Uniswap's own routes: it comes before the step that
// set the input side, it is the only PERMIT2_PERMIT in the // set the input side, it is the only PERMIT2_PERMIT in the
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is // transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took // the router, its token is not the zero address, the router may not
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token // let it fail, no step before it took its token or one not known (a
// included), and this decoder read every step after it in full (see // PERMIT2_TRANSFER_FROM of its token included), and this decoder read
// stepsUnread). Permit2 then lets the router take no more of the token // every step after it in full (see stepsUnread). Permit2 then lets the
// than that amount, so the permit covers every take of it. A step // router take no more of the token than that amount, so the permit
// before it takes under whatever allowance the router already had, so // covers every take of it. Permit2 has no allowance of ETH, so a
// it may take more. A later permit or batch, or a later step this // permit on the zero address covers no take of ETH. A step before it
// decoder did not read, may raise what Permit2 lets the router take. // takes under whatever allowance the router already had, so it may
// take more. A later permit or batch, or a later step this decoder
// did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as // Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step // they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves // that may take from the sender what this decoder did not read leaves
@@ -2047,6 +2049,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") && !stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
permit.token !== "0x0000000000000000000000000000000000000000" &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
@@ -2315,15 +2318,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
details.push({ // Permit2 has no allowance of ETH. An allowance of the zero
label: "Permit2 allowance", // address reads as one of a token nothing knows: its amount in
value: // base units, and the zero address as its token.
p.amount >= MAX_UINT160 if (p.token === "0x0000000000000000000000000000000000000000") {
? "Unlimited" details.push({
: amountText(p.amount, tokenInfo(p.token, sources)) label: "Permit2 allowance",
.display, value:
}); p.amount >= MAX_UINT160
details.push(tokenMovedLine("Allowance token", p.token, sources)); ? "Unlimited"
: unknownDecimalsAmount(p.amount),
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
+26
View File
@@ -23,6 +23,9 @@
// version field, and showing those users a wipe prompt would be a worse defect // version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working. // than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs");
const path = require("path");
const { const {
bootPopup, bootPopup,
cleanupPopup, cleanupPopup,
@@ -148,6 +151,29 @@ describe("the destructive reset on the recovery screen", () => {
}); });
}); });
// A key or xprv wallet has no recovery phrase, so the erase warning names all
// three backups (https://git.eeqj.de/sneak/AutistMask/issues/593). The boot
// harness holds no static text, so this reads the markup itself.
describe("the erase warning on the recovery screen", () => {
test("names the recovery phrase, the private key and the extended private key", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
const start = html.indexOf('id="view-state-recovery"');
const end = html.indexOf('id="btn-state-recovery-reset"', start);
const screenText = html
.slice(start, end)
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ");
expect(screenText).toContain(
"If you kept a copy of a wallet's recovery phrase, private key or " +
"extended private key, you can add that wallet again.",
);
});
});
describe("a popup already open when the stored profile becomes unreadable", () => { describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay // https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen // on the wallet list with the last good balances, and only a reopen
+92
View File
@@ -3310,6 +3310,98 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/660 — Permit2 has no
// allowance of ETH, so a permit on the zero address reads as one on a
// token nothing knows. On next at 53551c6 such a permit's Allowance
// token line read ETH. A permit of 1 wei of the zero address before a
// V4 step of 5 ETH made the Amount line read 0.000000000000000001 ETH.
describe("a permit on the zero address", () => {
const FIVE_ETH = 5000000000000000000n;
// The lines of a permit of `amount` base units of the zero
// address for the router.
const zeroAddressLines = (amount) => [
[
"Permit2 allowance",
amount + " base units (decimals unknown)",
],
["Allowance token", ZERO_ADDR],
["Allowance spender", getAddress(ROUTER_ADDR)],
];
test("a PERMIT2_PERMIT on the zero address names the address", () => {
const result = decodeSteps(
[0x00, 0x0a],
[
V3_600,
encodePermit2(
ZERO_ADDR,
1000000000000000000n,
ROUTER_ADDR,
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...zeroAddressLines("1000000000000000000"),
]);
expect(detail(result, "Allowance token").address).toBe(
ZERO_ADDR,
);
});
test("a PERMIT2_PERMIT_BATCH entry on the zero address names the address", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_600],
[ZERO_ADDR, 1000000000000000000n],
],
ROUTER_ADDR,
),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...USDT_600_LINES,
...zeroAddressLines("1000000000000000000"),
]);
});
test("a PERMIT2_PERMIT of 1 wei of the zero address, then a V4 step of 5 ETH the user settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x0a, 0x10],
[
encodePermit2(ZERO_ADDR, 1n, ROUTER_ADDR),
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
FIVE_ETH,
1n,
),
settle(ZERO_ADDR, FIVE_ETH, true),
encodeV4TakeAll(USDC_ADDR, 1n),
],
),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1"),
]);
});
});
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in