Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 598de3ff1a fix: re-enable Confirm Delete after a delete, so a second one needs no reopen (closes #335)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The password route disabled its Confirm Delete button before the decrypt
and never re-enabled it on success, so a second delete in the same popup
session found a dead button until the popup was closed and reopened. The
lost-password route re-enabled its own button in its leave hook, so the
two screens on the one screen behaved differently.

Both routes now reset the button through the shared finishDelete(), the
one path they both take, and the lost-password leave hook no longer
handles it separately. Tests drive a password-route delete and a second
delete in the same session; they fail against the prior head, where the
button stays disabled after the first delete.

Model: opus-4-8
2026-09-22 01:28:02 +02:00
4 changed files with 93 additions and 70 deletions
+3 -44
View File
@@ -882,18 +882,9 @@ On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000` — truncated string, integer part included, so `1.00005` still shows as `1.0000` —
the exception only fires where the entire displayed figure would read as zero. the exception only fires where the entire displayed figure would read as zero. A
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. A genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
genuine zero reaching this rule renders `0.0000`. The ERC-20 shows as `0.9999`, never rounded up.
`approve`/`transfer` amount does exactly that, and so does the swap's `Amount`
line for a literal-zero `amountIn` on a V2 or V3 exact-in swap, or a zero
`WRAP_ETH` (`0.0000 ETH`). Only two zeros are stated in words before the floor:
the swap's `Min. received` line reads `None (no minimum guaranteed)` for any
zero minimum, and its `Amount` line reads `All available (V4 open delta)` for a
V4 exact-in `amountIn` of zero, which V4 treats as the whole open credit rather
than a quantity. So the guarantee that a zero is never shown as `0.0000` covers
the `Min. received` line and the V4 exact-in `Amount`; a V2/V3 or `WRAP_ETH`
`Amount` still renders it (see the list of amount-slot strings below).
The rule and its exception live in `src/shared/amountDisplay.js` as The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -945,38 +936,6 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make. refusal the wallet has any reason to make.
**Every string an amount slot can show:** taken together, the exceptions above
mean an amount line on the dApp approval screen (and the wait/success/error
screens that carry a figure forward) shows one of a fixed set of strings, not
always a number:
- A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known and the
figure is at or above the floor, or below it and extended to its first
significant digit. This is `truncateAmountNeverZero()`
(`src/shared/amountDisplay.js`).
- `Unlimited`: an unbounded allowance or permit, which needs no scale to
describe — a `uint256`-max ERC-20 `approve` (`src/popup/views/approval.js`) or
a Permit2 amount at the `uint160` max on a swap's `Amount`
(`src/shared/uniswap.js`).
- `All available (V4 open delta)`: a V4 exact-in swap whose `amountIn` is zero.
V4 reads that zero as "use the whole open delta", not as a literal zero, so
the calldata states no quantity at all. Swap `Amount` line only
(`src/shared/uniswap.js`).
- `None (no minimum guaranteed)`: a zero minimum — the swap guarantees nothing
back. It is a literal zero slippage floor on a V2/V3/V4 swap, and it also
reaches a `BALANCE_CHECK_ERC20` step: a zero `minBalance`, which once rendered
`0.0000` beside the token symbol, now reads this. Swap `Min. received` line
(`src/shared/uniswap.js`).
- `<amount> base units (decimals unknown)`: the token's scale could not be
resolved, so the base-unit integer is shown with that caveat rather than
formatted (see Unknown token scale above). Reaches both the ERC-20 amount line
and the swap's `Amount` and `Min. received` (`unknownDecimalsAmount()` in
`src/shared/approvalAmount.js`).
- `Unknown (not named in the calldata)`: not an amount but the currency itself —
the `Token In` or `Token Out` line when nothing in the calldata established
which token, shown beside the amount and, like the strings above, a sentence
rather than a value (`src/shared/uniswap.js`).
#### Partial USD totals #### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the Prices are fetched for the top 25 tokens only, so an address can hold assets the
+11 -14
View File
@@ -132,20 +132,17 @@ but the review is broader than any of them.
constant rather than `isDebug()`, so it survives only in a debug build; a constant rather than `isDebug()`, so it survives only in a debug build; a
testnet or the runtime debug toggle still raises the banner but without the testnet or the runtime debug toggle still raises the banner but without the
view id. view id.
- 2026-09-21: `README.md` now documents the approval screen's amount-slot
vocabulary and no longer contradicts itself - 2026-09-21: The Confirm Delete button on the delete-wallet screen no longer
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim stays dead after a successful delete
that a genuine zero still renders `0.0000` is corrected: it holds for the ([#335](https://git.eeqj.de/sneak/AutistMask/issues/335)). The password route
ERC-20 amount, and also for the swap's `Amount` line on a literal-zero disabled the button before the decrypt and never re-enabled it, so a second
`amountIn` in a V2/V3 exact-in swap or a zero `WRAP_ETH`. Only two zeros are delete in the same popup session needed a reopen; the lost-password route
stated in words upstream — the swap's `Min. received` (any zero minimum) and re-enabled its own button in its leave hook, so the two screens behaved
its V4 exact-in `Amount` (an `amountIn` of zero, V4's open delta). The differently. Both now reset through the shared `finishDelete()`, the one path
amount-display section now names every string a slot can show — a formatted both routes take, so they behave the same and the button is live for the next
quantity, `Unlimited`, `All available (V4 open delta)`, delete.
`None (no minimum guaranteed)`, base units with decimals unknown, and
`Unknown (not named in the calldata)` — and records that a zero `minBalance`
on a `BALANCE_CHECK_ERC20` step now reads `None (no minimum guaranteed)` where
it once read `0.0000`. Docs only; each claim checked against the tree.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the - 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the wallet list was the reported case: the address shared one row with the
+14 -7
View File
@@ -51,16 +51,12 @@ function clear() {
// The lost-password screen holds no secret — a wallet name is not one — // The lost-password screen holds no secret — a wallet name is not one —
// but it is wiped on leave for the neighbouring reason: a typed // but it is wiped on leave for the neighbouring reason: a typed
// confirmation left standing in a hidden view is one click away from // confirmation left standing in a hidden view is one click away from
// destroying a wallet the user has since navigated off. The button is // destroying a wallet the user has since navigated off.
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
function clearLostPassword() { function clearLostPassword() {
lostPasswordIndex = null; lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = ""; $("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = ""; $("delete-wallet-lost-flash").textContent = "";
$("delete-wallet-lost-flash").style.visibility = "hidden"; $("delete-wallet-lost-flash").style.visibility = "hidden";
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = false;
btn.classList.remove("text-muted");
} }
function show(walletIdx) { function show(walletIdx) {
@@ -98,6 +94,17 @@ function showLostPassword() {
// cleanup and the accountsChanged broadcast cannot drift apart between // cleanup and the accountsChanged broadcast cannot drift apart between
// them. // them.
async function finishDelete(walletIdx) { async function finishDelete(walletIdx) {
// Each route's confirm button was disabled by its own click handler
// before the delete ran. Re-enable both here, on the one path they
// share, so the two routes reset the same way and a second delete in
// the same popup session finds a live button instead of a dead one.
const passwordBtn = $("btn-delete-wallet-confirm");
passwordBtn.disabled = false;
passwordBtn.classList.remove("text-muted");
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
lostPasswordBtn.disabled = false;
lostPasswordBtn.classList.remove("text-muted");
const { activeAddressChanged } = removeWalletFromState(state, walletIdx); const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
deleteWalletIndex = null; deleteWalletIndex = null;
@@ -187,8 +194,8 @@ function init(_ctx) {
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
// finishDelete() navigates, and the leave hook re-enables the // finishDelete() re-enables the button; navigating away then runs
// button and wipes the typed name on the way out. // the leave hook that wipes the typed name.
await finishDelete(lostPasswordIndex); await finishDelete(lostPasswordIndex);
}); });
+65 -5
View File
@@ -172,6 +172,15 @@ async function openLostPassword(deleteWallet, walletIdx) {
await click("btn-delete-wallet-lost-password"); await click("btn-delete-wallet-lost-password");
} }
// Delete a wallet through the password route: open its confirm screen,
// enter the password, and confirm. The vault is mocked, so the password
// text itself is irrelevant — decryptWithPassword decides pass or fail.
async function deleteWithPassword(deleteWallet, walletIdx) {
deleteWallet.show(walletIdx);
node("delete-wallet-password").value = "any password";
await click("btn-delete-wallet-confirm");
}
// ------------------------------------------------------------ tests // ------------------------------------------------------------ tests
// The stub is what every persistence assertion below rests on, so its one // The stub is what every persistence assertion below rests on, so its one
@@ -456,15 +465,21 @@ describe("what the screen leaves behind", () => {
); );
}); });
// Left mid-delete, the screen has to come back usable. // Both routes now re-enable through finishDelete(), not their leave
test("the confirm button is re-enabled on the way out", async () => { // hooks, so the button comes back live once a delete completes.
const { helpers, deleteWallet } = load(); test("the confirm button is re-enabled after a delete", async () => {
const { deleteWallet } = load();
await openLostPassword(deleteWallet, 1); await openLostPassword(deleteWallet, 1);
node("btn-delete-wallet-lost-confirm").disabled = true; node("delete-wallet-lost-name-input").value = "Wallet 2";
helpers.showView("settings"); await click("btn-delete-wallet-lost-confirm");
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false); expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
expect(
node("btn-delete-wallet-lost-confirm").classList.contains(
"text-muted",
),
).toBe(false);
}); });
// A wallet name is not a secret, so the screen is excluded for the // A wallet name is not a secret, so the screen is excluded for the
@@ -475,3 +490,48 @@ describe("what the screen leaves behind", () => {
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false); expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
}); });
}); });
// The password route is the pre-existing bug this file's fix addresses:
// its Confirm Delete button was disabled before the decrypt and never
// re-enabled on success, so a second delete in the same popup session
// found a dead button. Now both routes re-enable through finishDelete().
//
// Against head these tests fail: with the re-enable absent, the button
// stays disabled after the first delete, so the disabled assertions read
// true where they expect false.
describe("the password route's confirm button", () => {
test("is re-enabled after a successful delete", async () => {
const { deleteWallet, vault } = load();
vault.decryptWithPassword.mockResolvedValue();
await deleteWithPassword(deleteWallet, 1);
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
expect(
node("btn-delete-wallet-confirm").classList.contains("text-muted"),
).toBe(false);
});
// The reported symptom: delete one wallet, then open Delete Wallet for
// a second one without reopening the popup. The button must be live on
// that second visit, and the second delete must actually persist.
test("a second delete works in the same popup session", async () => {
const { deleteWallet, vault, storage } = load();
vault.decryptWithPassword.mockResolvedValue();
await deleteWithPassword(deleteWallet, 1);
// Wallet 2 is gone; the list is now [Wallet 1, Wallet 3]. Opening
// the confirm screen for the wallet now at index 1 (Wallet 3) must
// find its button live, not the dead one the first delete left.
deleteWallet.show(1);
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
node("delete-wallet-password").value = "any password";
await click("btn-delete-wallet-confirm");
expect((await persistedWallets(storage)).map((w) => w.name)).toEqual([
"Wallet 1",
]);
});
});