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

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, and the copy says so: the address stays
derivable from key material the wallet still holds, any funds at it stay
where they are, and importing the recovery phrase brings it back. 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. A
balance is surfaced as a warning line, never as a refusal.

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.

The wallet's derivation counter is a high-water mark and is not rewound, so
"+" derives a fresh index rather than handing back the address just removed.
This commit is contained in:
clawbot
2026-08-11 13:37:36 +00:00
parent 6f6bc2e7b5
commit 125566d256
10 changed files with 632 additions and 18 deletions

View File

@@ -128,7 +128,9 @@ transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
— which wallet types are offered it, that it holds nothing before the password — which wallet types are offered it, that it holds nothing before the password
is accepted, that a wrong password reveals nothing, that leaving it by either is accepted, that a wrong password reveals nothing, that leaving it by either
route wipes it — including a leave taken while the decrypt is still running — route wipes it — including a leave taken while the decrypt is still running —
and that reopening the popup does not land on it. All outbound network is and that reopening the popup does not land on it. It also covers address
removal: which wallets offer the control at all, that leaving the confirmation
removes nothing, and that confirming it does. All outbound network is
intercepted at the browser level and served from fixtures in intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline; `tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently unrecognised outbound requests are reported as failures rather than silently
@@ -508,8 +510,9 @@ of it.
- Wallet list: each wallet shows its name (tap to rename inline) and a "+" - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address with "Address button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, the address USD total, and a balance line for ETH and for `[info]` button, an `[x]` button (only on HD and xprv wallets holding more
each token shown for that address than one address), the address USD total, and a balance line for ETH and
for each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address - "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
@@ -519,6 +522,7 @@ of it.
- Tap wallet name → inline rename field (no screen change) - Tap wallet name → inline rename field (no screen change)
- "+" on wallet → derives the next address inline (no screen change) - "+" on wallet → derives the next address inline (no screen change)
- `[info]` on address → **AddressDetail** - `[info]` on address → **AddressDetail**
- `[x]` on address → **DeleteAddress**
- "Send" → **Send** (refuses with a flash message on a zero balance) - "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** (shows active address QR) - "Receive" → **Receive** (shows active address QR)
- Tap home tx row → **TransactionDetail** - Tap home tx row → **TransactionDetail**
@@ -869,6 +873,40 @@ of it.
nothing deleted nothing deleted
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
#### DeleteAddress (`delete-address-confirm`)
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
and xprv wallets holding more than one address: the last address of a wallet
is never removable, and a key wallet has exactly one.
- **Elements**:
- "Back" button, "Remove Address" heading
- The address's own label ("Address N") and its wallet's name
- The full address (color dot, etherscan link, tap to copy), with the ENS
name above it if resolved
- Explanation that this only stops the wallet tracking the address: nothing
is destroyed, no key is deleted, funds stay where they are, and the
address comes back by importing the recovery phrase again
- A line naming the address's ETH balance when it holds one, stating that
removal moves and spends nothing. A balance is a warning, never a refusal.
- The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings
- Error line
- "Remove Address" button
- **Transitions**:
- "Remove Address" → removes the address and its site permissions, then →
previous screen (Home) with an "Address removed." flash message
- "Back" → previous screen (Home), nothing removed
- **Deliberately not password-gated**, unlike DeleteWallet: a password gates the
disclosure or destruction of a secret, and this does neither. The address
stays derivable from key material the wallet still holds.
- The active address moves only if it was the address removed, and then to the
wallet's first remaining address, with `AUTISTMASK_ACTIVE_CHANGED` broadcast
so a connected site stops being told about an address the user removed
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
one in this wallet follows the splice.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
#### SettingsAddToken (`settings-addtoken`) #### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked - **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
@@ -1318,7 +1356,7 @@ Currently supported:
### Wallet Management ### Wallet Management
- [x] Delete wallet (with confirmation) - [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation) - [x] Delete address from HD wallet (with confirmation)
- [x] Show wallet's recovery phrase (requires password) - [x] Show wallet's recovery phrase (requires password)
### Transactions ### Transactions

View File

@@ -44,6 +44,11 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: An address can be removed from an HD or xprv wallet behind a
confirmation screen that states nothing is destroyed, sharing the deletion
state transitions with wallet deletion so the selection, site permissions and
active-address broadcast follow the same rules
([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
- 2026-08-11: Known-symbol spoof verification became a Settings toggle - 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history (`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds filter and the fraud-contract learning it feeds

View File

@@ -1109,6 +1109,57 @@
</button> </button>
</div> </div>
<!-- ============ DELETE ADDRESS CONFIRM ============ -->
<div id="view-delete-address-confirm" class="view hidden">
<button
id="btn-delete-address-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Remove Address</h2>
<p class="text-xs mb-2">
You are about to remove
<strong id="delete-address-label"></strong> from
<strong id="delete-address-wallet-name"></strong>.
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
>
This only stops this wallet from tracking the address.
Nothing is destroyed and no key is deleted. Any funds at the
address stay exactly where they are, the address remains
yours, and it can be brought back at any time by importing
this wallet's recovery phrase again. Any site permissions
granted to this address are forgotten.
</div>
<div
id="delete-address-balance"
class="text-xs mb-2 min-h-[1.25rem]"
>
&nbsp;
</div>
<p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead.
</p>
<div
id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-address-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Remove Address
</button>
</div>
<!-- ============ SHOW RECOVERY PHRASE ============ --> <!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden"> <div id="view-show-phrase" class="view hidden">
<button <button

View File

@@ -33,6 +33,7 @@ const receive = require("./views/receive");
const addToken = require("./views/addToken"); const addToken = require("./views/addToken");
const settings = require("./views/settings"); const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken"); const settingsAddToken = require("./views/settingsAddToken");
const deleteAddress = require("./views/deleteAddress");
const approval = require("./views/approval"); const approval = require("./views/approval");
function renderWalletList() { function renderWalletList() {
@@ -101,6 +102,10 @@ const ctx = {
pushCurrentView(); pushCurrentView();
settingsAddToken.show(); settingsAddToken.show();
}, },
showDeleteAddress: (walletIdx, addrIdx) => {
pushCurrentView();
deleteAddress.show(walletIdx, addrIdx);
},
}; };
function needsAddress(view) { function needsAddress(view) {
@@ -250,6 +255,7 @@ async function init() {
addToken.init(ctx); addToken.init(ctx);
settings.init(ctx); settings.init(ctx);
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx);
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");

View File

@@ -0,0 +1,112 @@
// Confirmation screen for removing one address from a wallet that derives
// its addresses from an extended key.
//
// No password is asked for, unlike delete-wallet. A password gates the
// disclosure or destruction of a secret, and this does neither: the address
// is derived from key material the wallet still holds, so removing it only
// stops the wallet tracking it. An explicit confirmation screen is the
// proportionate treatment.
const {
$,
showView,
showFlash,
goBack,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const {
canRemoveAddress,
removeAddressFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
// The wallet and address indices this screen is confirming, or null when it
// is not confirming anything.
let target = null;
let ctx = null;
function setFlash(msg) {
const el = $("delete-address-flash");
el.textContent = msg;
el.style.visibility = msg ? "visible" : "hidden";
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) return;
target = { walletIdx, addrIdx };
$("delete-address-label").textContent = "Address " + (addrIdx + 1);
$("delete-address-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
const value = $("delete-address-value");
value.innerHTML = renderAddressHtml(addr.address, {
ensName: addr.ensName,
});
attachCopyHandlers(value);
// A balance is a reason to be careful, not a reason to refuse: the funds
// are at the address, not in this list, and stay there either way.
const balance = parseFloat(addr.balance || "0");
$("delete-address-balance").innerHTML =
balance > 0
? "This address holds " +
balance.toFixed(4) +
" ETH. Removing it does not move or spend anything; the balance " +
"stays at the address."
: "&nbsp;";
setFlash("");
showView("delete-address-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-address-back").addEventListener("click", () => {
target = null;
goBack();
});
$("btn-delete-address-confirm").addEventListener("click", async () => {
if (target === null) {
setFlash("No address is selected for removal.");
return;
}
const { walletIdx, addrIdx } = target;
if (!canRemoveAddress(state.wallets[walletIdx])) {
setFlash(
"This address cannot be removed, because a wallet always " +
"keeps at least one address.",
);
return;
}
const { removed, activeAddressChanged } = removeAddressFromState(
state,
walletIdx,
addrIdx,
);
if (!removed) {
setFlash("This address could not be removed.");
return;
}
target = null;
// Save before broadcasting: the background reads the active address
// back out of storage to build accountsChanged.
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
ctx.renderWalletList();
goBack();
showFlash("Address removed.");
});
}
module.exports = { init, show };

View File

@@ -25,6 +25,7 @@ const VIEWS = [
"add-token", "add-token",
"settings", "settings",
"delete-wallet-confirm", "delete-wallet-confirm",
"delete-address-confirm",
"settings-addtoken", "settings-addtoken",
"transaction", "transaction",
"approve-site", "approve-site",

View File

@@ -21,6 +21,7 @@ const {
resetSendValidation, resetSendValidation,
} = require("./send"); } = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { canRemoveAddress } = require("../../shared/walletDelete");
const { const {
formatUsd, formatUsd,
getPrice, getPrice,
@@ -238,6 +239,12 @@ function render(ctx) {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`; html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
const isActive = state.activeAddress === addr.address; const isActive = state.activeAddress === addr.address;
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`; const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
// Only where a wallet can spare the address: a wallet holding a
// single address has no remove control, because its last address
// is never removable.
const removeBtn = canRemoveAddress(wallet)
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
: "";
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`; html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
@@ -246,7 +253,7 @@ function render(ctx) {
} }
html += `<div class="flex text-xs items-center justify-between">`; html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr)); const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
@@ -289,6 +296,16 @@ function render(ctx) {
}); });
}); });
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
ctx.showDeleteAddress(
parseInt(btn.dataset.wallet, 10),
parseInt(btn.dataset.address, 10),
);
});
});
container.querySelectorAll(".btn-add-address").forEach((btn) => { container.querySelectorAll(".btn-add-address").forEach((btn) => {
btn.addEventListener("click", async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -1,5 +1,22 @@
// Wallet deletion state transition, kept out of the view so the selection // Wallet and address deletion state transitions, kept out of the views so the
// and broadcast rules are testable without a DOM. // selection and broadcast rules are testable without a DOM.
// Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison.
function sameAddress(a, b) {
if (a === null || a === undefined || b === null || b === undefined) {
return false;
}
return String(a).toLowerCase() === String(b).toLowerCase();
}
// Forget every site permission held against the given addresses.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
}
// Remove wallet `walletIdx` from `state` and repair the derived state. // Remove wallet `walletIdx` from `state` and repair the derived state.
// //
@@ -18,19 +35,13 @@ function removeWalletFromState(state, walletIdx) {
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
const addresses = (wallet.addresses || []).map((a) => a.address); const addresses = (wallet.addresses || []).map((a) => a.address);
const previousActive = state.activeAddress; const previousActive = state.activeAddress;
const activeWasDeleted = const activeWasDeleted = addresses.some((a) =>
previousActive !== null && sameAddress(a, previousActive),
previousActive !== undefined && );
addresses.some(
(a) => a.toLowerCase() === String(previousActive).toLowerCase(),
);
state.wallets.splice(walletIdx, 1); state.wallets.splice(walletIdx, 1);
for (const addr of addresses) { dropSitePermissions(state, addresses);
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
state.hasWallet = state.wallets.length > 0; state.hasWallet = state.wallets.length > 0;
@@ -58,6 +69,77 @@ function removeWalletFromState(state, walletIdx) {
return { activeAddressChanged: state.activeAddress !== previousActive }; return { activeAddressChanged: state.activeAddress !== previousActive };
} }
// Whether a wallet may be offered a per-address remove control, and the same
// gate the removal itself is held behind.
//
// Only a wallet that derives its addresses from an extended key can hold more
// than one, so only those get the control — a key wallet has exactly one
// address and no "+" button either. The last address of any wallet is never
// removable: a wallet with no addresses is what delete-wallet is for.
function canRemoveAddress(wallet) {
if (!wallet) return false;
if (wallet.type !== "hd" && wallet.type !== "xprv") return false;
return (wallet.addresses || []).length > 1;
}
// Remove address `addrIdx` of wallet `walletIdx` and repair the derived state.
//
// Nothing is destroyed here. The address stays derivable from the wallet's own
// key material and any funds at it are untouched; this only stops the wallet
// tracking it. `nextIndex` is deliberately left alone — it is a derivation
// high-water mark, so "+" derives a fresh index rather than handing back the
// address just removed, and the gap it leaves is within what
// `scanForAddresses()` re-discovers on a later import.
//
// The rules mirror removeWalletFromState() one level down:
// - The call is refused unless canRemoveAddress() allows it, so the last
// address of a wallet always survives.
// - Site permissions are dropped for the removed address.
// - `selectedAddress` follows the splice, but only within the wallet that
// lost the address: it is decremented when an earlier address was
// removed, and falls back to that wallet's first address when the
// selection itself was removed. `selectedWallet` never moves, because the
// wallet list does not.
// - `activeAddress` moves only when it was the removed address, and then to
// the wallet's first remaining address.
//
// Returns whether the address was removed and whether `activeAddress`
// changed, so the caller can broadcast it.
function removeAddressFromState(state, walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const refused = { removed: false, activeAddressChanged: false };
if (!canRemoveAddress(wallet)) return refused;
if (!wallet.addresses[addrIdx]) return refused;
const address = wallet.addresses[addrIdx].address;
const previousActive = state.activeAddress;
const activeWasRemoved = sameAddress(address, previousActive);
wallet.addresses.splice(addrIdx, 1);
dropSitePermissions(state, [address]);
if (state.selectedWallet === walletIdx) {
if (state.selectedAddress === addrIdx) {
state.selectedAddress = 0;
} else if (
typeof state.selectedAddress === "number" &&
state.selectedAddress > addrIdx
) {
state.selectedAddress -= 1;
}
}
if (activeWasRemoved) {
state.activeAddress = wallet.addresses[0].address;
}
return {
removed: true,
activeAddressChanged: state.activeAddress !== previousActive,
};
}
// Tell the background the active address changed, so it re-emits // Tell the background the active address changed, so it re-emits
// accountsChanged to connected sites. Same call shape as the address // accountsChanged to connected sites. Same call shape as the address
// switch in the home view. // switch in the home view.
@@ -67,4 +149,9 @@ function broadcastActiveChanged() {
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" }); runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
} }
module.exports = { removeWalletFromState, broadcastActiveChanged }; module.exports = {
canRemoveAddress,
removeAddressFromState,
removeWalletFromState,
broadcastActiveChanged,
};

View File

@@ -376,6 +376,87 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
assertWiped(st, env.phrase, "after reopening the popup"); 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),
);
// "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 // ---------------------------------------------------------------- runner
async function main() { async function main() {

View File

@@ -1,4 +1,6 @@
const { const {
canRemoveAddress,
removeAddressFromState,
removeWalletFromState, removeWalletFromState,
broadcastActiveChanged, broadcastActiveChanged,
} = require("../src/shared/walletDelete"); } = require("../src/shared/walletDelete");
@@ -6,6 +8,7 @@ const {
// Fixed addresses — never used for anything but these tests. // Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const A2 = "0x514910771AF9Ca656af840dff83E8264EcF986CA";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -111,6 +114,219 @@ describe("removeWalletFromState", () => {
}); });
}); });
// An HD wallet with three addresses next to a single-address key wallet.
// `nextIndex` is the wallet's derivation high-water mark, three addresses in.
function makeAddressState(overrides = {}) {
return {
hasWallet: true,
wallets: [
{ ...wallet("A", [A0, A1, A2]), type: "hd", nextIndex: 3 },
{ ...wallet("B", [B0]), type: "key" },
],
selectedWallet: 0,
selectedAddress: 0,
activeAddress: A0,
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
...overrides,
};
}
describe("canRemoveAddress", () => {
test("an HD wallet with more than one address may remove one", () => {
expect(canRemoveAddress({ type: "hd", addresses: [{}, {}] })).toBe(
true,
);
});
test("an xprv wallet with more than one address may too", () => {
expect(canRemoveAddress({ type: "xprv", addresses: [{}, {}] })).toBe(
true,
);
});
// The last address is what delete-wallet is for.
test("a wallet holding a single address may not", () => {
expect(canRemoveAddress({ type: "hd", addresses: [{}] })).toBe(false);
});
// A key wallet holds one bare private key and cannot derive more, so it
// has no "+" button and gets no remove control either.
test("a key wallet may not, whatever its address count", () => {
expect(canRemoveAddress({ type: "key", addresses: [{}] })).toBe(false);
expect(canRemoveAddress({ type: "key", addresses: [{}, {}] })).toBe(
false,
);
});
test("a missing or typeless wallet may not", () => {
expect(canRemoveAddress(undefined)).toBe(false);
expect(canRemoveAddress({})).toBe(false);
});
});
describe("removeAddressFromState", () => {
test("removing a non-selected address leaves the selection where it is", () => {
const state = makeAddressState({
selectedAddress: 2,
activeAddress: A2,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
0,
);
expect(removed).toBe(true);
// A2 moved from index 2 to index 1 by the splice.
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([
A1,
A2,
]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(1);
expect(state.activeAddress).toBe(A2);
expect(activeAddressChanged).toBe(false);
// The wallet list itself is untouched.
expect(state.wallets).toHaveLength(2);
expect(state.hasWallet).toBe(true);
});
test("removing an address after the selection does not shift it", () => {
const state = makeAddressState({
selectedAddress: 0,
activeAddress: A0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
2,
);
expect(removed).toBe(true);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(false);
});
test("a selection in another wallet is untouched", () => {
const state = makeAddressState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
1,
);
expect(removed).toBe(true);
expect(state.selectedWallet).toBe(1);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(false);
});
test("removing the selected address falls back to the wallet's first address", () => {
const state = makeAddressState({
selectedAddress: 1,
activeAddress: A1,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
1,
);
expect(removed).toBe(true);
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([
A0,
A2,
]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(true);
});
// The active address can be persisted in a different case than the
// wallet's copy of it, so the comparison must not be literal.
test("the active address is matched case-insensitively", () => {
const state = makeAddressState({
selectedAddress: 1,
activeAddress: A1.toLowerCase(),
});
const { activeAddressChanged } = removeAddressFromState(state, 0, 1);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(true);
});
test("site permissions are dropped for the removed address only", () => {
const state = makeAddressState();
removeAddressFromState(state, 0, 1);
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
});
// The derivation counter is a high-water mark, never rewound: "+" derives
// a fresh index rather than re-deriving the address just removed.
test("the wallet's derivation counter is not rewound", () => {
const state = makeAddressState();
removeAddressFromState(state, 0, 1);
expect(state.wallets[0].nextIndex).toBe(3);
});
test("the last address of a wallet is refused, and nothing changes", () => {
const state = makeAddressState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
1,
0,
);
expect(removed).toBe(false);
expect(activeAddressChanged).toBe(false);
expect(state.wallets[1].addresses.map((a) => a.address)).toEqual([B0]);
expect(state.activeAddress).toBe(B0);
expect(state.hasWallet).toBe(true);
});
// The same refusal reached the other way: an HD wallet worn down to one
// address is no more removable than a key wallet.
test("an HD wallet down to its last address is refused too", () => {
const state = makeAddressState();
expect(removeAddressFromState(state, 0, 2).removed).toBe(true);
expect(removeAddressFromState(state, 0, 1).removed).toBe(true);
expect(removeAddressFromState(state, 0, 0).removed).toBe(false);
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([A0]);
});
test("an out-of-range address index is refused", () => {
const state = makeAddressState();
expect(removeAddressFromState(state, 0, 7).removed).toBe(false);
expect(removeAddressFromState(state, 7, 0).removed).toBe(false);
expect(state.wallets[0].addresses).toHaveLength(3);
});
});
describe("broadcastActiveChanged", () => { describe("broadcastActiveChanged", () => {
afterEach(() => { afterEach(() => {
delete global.chrome; delete global.chrome;