feat: remove an address from an HD wallet, behind a confirmation (closes #162)
All checks were successful
check / check (push) Successful in 24s

Address rows on Home now carry an [x] control on wallets that derive their
addresses from an extended key and hold more than one; it opens a confirmation
screen before anything is removed.

Removing an address destroys nothing: it stays derivable from key material the
wallet still holds, and any funds at it stay where they are. That is also why
the screen is not password-gated, unlike delete-wallet — a password gates the
disclosure or destruction of a secret, and this does neither.

Getting the address back into the list is another matter, and the copy states
it exactly rather than promising a route the app refuses. "+" derives the next
unused index, because the derivation counter is a high-water mark and is not
rewound, and re-importing the wallet's key material is rejected as a duplicate
for as long as the wallet is present — which it always is here, since a wallet
never gives up its last address. What works is deleting the whole wallet in
Settings, which asks for the password and destroys the stored secret, then
importing again: the scan that follows rediscovers the address only if it has
on-chain activity, and an address that was never used does not come back at
all. The text is built by recoveryPathText() rather than sitting in index.html
so it can name the wallet's own kind of key material, an xprv wallet having no
recovery phrase to re-import.

A balance is surfaced as a warning, never a refusal, and holding something
means any ERC-20 as well as ETH, at any size: an address with no ETH and a
stablecoin position must not get a blank line on the screen whose job is to
warn. The warning names no figure of its own, because the balance lines round
to four decimals and a sentence built from a rounded number would report
0.0000 ETH for an address holding real money; the amounts come from the same
balanceLinesForAddress() and getAddressValueUsd() every other screen uses.

The state transition lives next to the wallet one in
src/shared/walletDelete.js and shares its address comparison, site-permission
cleanup and broadcast, so the rules match one level down: the last address of
a wallet is never removable, the selection moves only when it was the address
removed, an index after the splice is decremented, a selection in another
wallet is untouched, and AUTISTMASK_ACTIVE_CHANGED is broadcast when the
active address moves so a connected site stops being told about an address the
user removed.
This commit is contained in:
2026-08-11 13:37:36 +00:00
parent bd4bdcafc7
commit 852798d93a
11 changed files with 905 additions and 19 deletions

View File

@@ -398,6 +398,101 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
assertWiped(st, env.phrase, "after reopening the popup");
});
// -------------------------------------------- address removal (#162)
// Number of address rows across every wallet in the list, counted in the DOM
// whether or not Home is the screen on top.
function addressRowCount(page) {
return page.locator("#wallet-list .btn-addr-info").count();
}
function waitForAddressRows(page, n) {
return page.waitForFunction(
(want) =>
document.querySelectorAll("#wallet-list .btn-addr-info").length ===
want,
n,
{ timeout: 60000 },
);
}
// The suite arrives here with two wallets, an HD one and a key one, holding
// one address each.
test("only a wallet that can spare an address offers to remove one (#162)", async (env) => {
await visible(env.page, "#view-main");
const rows = await addressRowCount(env.page);
assert(rows === 2, "expected two address rows, got " + rows);
const offered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
offered === 0,
"a wallet holding its last address offered to remove it",
);
await env.page.click("#wallet-list .btn-add-address");
await waitForAddressRows(env.page, 3);
// Only the HD wallet's two rows; the key wallet still holds one address.
const nowOffered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
nowOffered === 2,
"expected the HD wallet's two rows to offer removal, got " + nowOffered,
);
});
// The gate itself: the control opens a confirmation, and leaving that
// confirmation by "Back" removes nothing.
test("leaving the removal confirmation removes nothing (#162)", async (env) => {
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
await visible(env.page, "#view-delete-address-confirm");
const label = await env.page.locator("#delete-address-label").innerText();
assert(
label === "Address 2",
"the confirmation names the wrong address: " + JSON.stringify(label),
);
// The route back is written by the view, not by index.html, so an empty
// paragraph here means the user is confirming with no idea what it
// takes to undo. This wallet is an HD one, so it is told about its
// recovery phrase.
const recovery = await env.page
.locator("#delete-address-recovery")
.innerText();
assert(
recovery.includes("delete the whole wallet in Settings") &&
recovery.includes("recovery phrase"),
"the confirmation does not state the route back: " +
JSON.stringify(recovery),
);
// "Back" re-renders Home, so a count taken after it is a real
// measurement of the wallet rather than a stale screen.
await env.page.click("#btn-delete-address-back");
await visible(env.page, "#view-main");
const rows = await addressRowCount(env.page);
assert(rows === 3, "the address was removed without a confirmation");
});
test("confirming removes the address and returns Home (#162)", async (env) => {
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
await visible(env.page, "#view-delete-address-confirm");
await env.page.click("#btn-delete-address-confirm");
await visible(env.page, "#view-main");
await waitForAddressRows(env.page, 2);
const offered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
offered === 0,
"the HD wallet still offers to remove its last address",
);
});
// ---------------------------------------------------------------- runner
async function main() {