This address holds a balance. Removing it does not ` +
+ `move or spend anything; the balance stays at the address.
` +
+ balanceLinesForAddress(addr, state.trackedTokens, false) +
+ total
+ );
+}
+
+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);
+
+ $("delete-address-recovery").textContent = recoveryPathText(wallet);
+ $("delete-address-balance").innerHTML = balanceWarningHtml(addr);
+
+ 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.");
+ });
+}
+
+// recoveryPathText and balanceWarningHtml are exported so the two pieces of
+// copy that carry the screen's substance can be tested without a DOM; show()
+// is a one-line assignment for each.
+module.exports = { init, show, recoveryPathText, balanceWarningHtml };
diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js
index e95c390..ab8468a 100644
--- a/src/popup/views/helpers.js
+++ b/src/popup/views/helpers.js
@@ -25,6 +25,7 @@ const VIEWS = [
"add-token",
"settings",
"delete-wallet-confirm",
+ "delete-address-confirm",
"settings-addtoken",
"transaction",
"approve-site",
@@ -217,6 +218,20 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
return html;
}
+// Whether an address holds anything at all: ETH or any ERC-20 the wallet
+// knows about. Deliberately unrounded — the rendered lines round to four
+// decimals, so a dust balance displays as 0.0000 while still being real
+// money at a real address. Callers that warn about holdings must ask this,
+// not the rendered figure.
+function addressHoldsFunds(addr) {
+ if (!addr) return false;
+ if (parseFloat(addr.balance || "0") > 0) return true;
+ for (const t of addr.tokenBalances || []) {
+ if (parseFloat(t.balance || "0") > 0) return true;
+ }
+ return false;
+}
+
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
@@ -463,6 +478,7 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
+ addressHoldsFunds,
addressColor,
addressDotHtml,
escapeHtml,
diff --git a/src/popup/views/home.js b/src/popup/views/home.js
index 46d8b24..bdf1e6f 100644
--- a/src/popup/views/home.js
+++ b/src/popup/views/home.js
@@ -21,6 +21,7 @@ const {
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
+const { canRemoveAddress } = require("../../shared/walletDelete");
const {
walletDefect,
walletDefectHtml,
@@ -240,6 +241,12 @@ function walletListHtml() {
html += ``;
const isActive = state.activeAddress === addr.address;
const infoBtn = `
[info]`;
+ // 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)
+ ? `
[x]`
+ : "";
const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : "";
html += `
Address ${ai + 1}
`;
@@ -248,7 +255,7 @@ function walletListHtml() {
}
html += `
`;
html += `${addr.ensName ? "" : dot}${addr.address}`;
- html += `${infoBtn}`;
+ html += `${infoBtn}${removeBtn}`;
html += `
`;
const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `
${addrUsd || " "}
`;
@@ -304,6 +311,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) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
diff --git a/src/shared/walletDelete.js b/src/shared/walletDelete.js
index dea26ee..81bc65a 100644
--- a/src/shared/walletDelete.js
+++ b/src/shared/walletDelete.js
@@ -1,5 +1,22 @@
-// Wallet deletion state transition, kept out of the view so the selection
-// and broadcast rules are testable without a DOM.
+// Wallet and address deletion state transitions, kept out of the views so the
+// 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.
//
@@ -18,19 +35,13 @@ function removeWalletFromState(state, walletIdx) {
const wallet = state.wallets[walletIdx];
const addresses = (wallet.addresses || []).map((a) => a.address);
const previousActive = state.activeAddress;
- const activeWasDeleted =
- previousActive !== null &&
- previousActive !== undefined &&
- addresses.some(
- (a) => a.toLowerCase() === String(previousActive).toLowerCase(),
- );
+ const activeWasDeleted = addresses.some((a) =>
+ sameAddress(a, previousActive),
+ );
state.wallets.splice(walletIdx, 1);
- for (const addr of addresses) {
- delete state.allowedSites[addr];
- delete state.deniedSites[addr];
- }
+ dropSitePermissions(state, addresses);
state.hasWallet = state.wallets.length > 0;
@@ -58,6 +69,77 @@ function removeWalletFromState(state, walletIdx) {
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
// accountsChanged to connected sites. Same call shape as the address
// switch in the home view.
@@ -67,4 +149,9 @@ function broadcastActiveChanged() {
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
-module.exports = { removeWalletFromState, broadcastActiveChanged };
+module.exports = {
+ canRemoveAddress,
+ removeAddressFromState,
+ removeWalletFromState,
+ broadcastActiveChanged,
+};
diff --git a/tests/deleteAddress.test.js b/tests/deleteAddress.test.js
new file mode 100644
index 0000000..f8d2682
--- /dev/null
+++ b/tests/deleteAddress.test.js
@@ -0,0 +1,158 @@
+// Tests for the copy on the address-removal confirmation (issue #162).
+//
+// The screen's whole job is to warn before a destructive-looking action, so
+// the copy is the substance and is tested as such. Two things it must not
+// get wrong: what it takes to get the address back — the app refuses both
+// obvious routes — and what counts as holding something, which is any
+// ERC-20 as well as ETH, at any size, including a balance that rounds to
+// zero at the four decimals the balance lines render. The DOM behaviour
+// around them is driven against the real popup by tests/e2e/run.js.
+
+// helpers.js pulls in state.js, which reads chrome.storage.local at load.
+globalThis.chrome = {
+ storage: { local: { get: async () => ({}), set: async () => {} } },
+};
+
+const { addressHoldsFunds } = require("../src/popup/views/helpers");
+const {
+ recoveryPathText,
+ balanceWarningHtml,
+} = require("../src/popup/views/deleteAddress");
+const { prices, clearPrices } = require("../src/shared/prices");
+
+const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
+
+const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
+const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
+const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
+const TOKEN_ONLY = {
+ address: "0x1",
+ balance: "0.0000",
+ tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
+};
+const ZERO_TOKEN = {
+ address: "0x1",
+ balance: "0",
+ tokenBalances: [{ address: USDC, symbol: "USDC", balance: "0" }],
+};
+
+afterEach(() => {
+ clearPrices();
+});
+
+describe("what the screen says it takes to get the address back", () => {
+ // The screen used to promise the address "can be brought back at any
+ // time by importing this wallet's recovery phrase again". That import is
+ // refused as a duplicate for as long as the wallet is present, which it
+ // always is here — a wallet never gives up its last address.
+ test("it does not promise a re-import while the wallet is here", () => {
+ const text = recoveryPathText({ type: "hd" });
+ expect(text).not.toMatch(/at any time/);
+ expect(text).toContain("is refused while this wallet is still here");
+ });
+
+ test("it names deleting the whole wallet as the route back", () => {
+ expect(recoveryPathText({ type: "hd" })).toContain(
+ "delete the whole wallet in Settings",
+ );
+ });
+
+ // The scan after a re-import finds used addresses only, so an address
+ // that never saw a transaction does not come back at all. Saying so is
+ // the difference between a warning and a false reassurance.
+ test("it states the limit: only on-chain activity is found", () => {
+ const text = recoveryPathText({ type: "hd" });
+ expect(text).toContain("only finds addresses that have on-chain");
+ expect(text).toContain("never been used is not found by it");
+ });
+
+ // The screen is offered on xprv wallets too, and an xprv wallet holds no
+ // recovery phrase — telling its owner to import one would send them
+ // looking for words that do not exist.
+ test("an xprv wallet is told about its extended private key", () => {
+ const text = recoveryPathText({ type: "xprv" });
+ expect(text).toContain("extended private key");
+ expect(text).not.toContain("recovery phrase");
+ });
+
+ test("an HD wallet is told about its recovery phrase", () => {
+ const text = recoveryPathText({ type: "hd" });
+ expect(text).toContain("recovery phrase");
+ expect(text).not.toContain("extended private key");
+ });
+});
+
+describe("whether an address holds anything", () => {
+ test("ETH counts", () => {
+ expect(addressHoldsFunds(ETH_ONLY)).toBe(true);
+ });
+
+ // The case that decides the screen: no ETH at all, and $2500 of a
+ // stablecoin sitting at the address.
+ test("an ERC-20 balance counts even with no ETH", () => {
+ expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
+ });
+
+ // 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
+ test("an ETH balance below the displayed precision counts", () => {
+ expect(addressHoldsFunds(DUST)).toBe(true);
+ });
+
+ test("an address holding nothing does not", () => {
+ expect(addressHoldsFunds(EMPTY)).toBe(false);
+ expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
+ });
+
+ test("a missing address or missing fields do not", () => {
+ expect(addressHoldsFunds(undefined)).toBe(false);
+ expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
+ });
+});
+
+describe("the balance warning on the removal confirmation", () => {
+ test("an address holding nothing gets a blank line, not a warning", () => {
+ expect(balanceWarningHtml(EMPTY)).toBe(" ");
+ expect(balanceWarningHtml(ZERO_TOKEN)).toBe(" ");
+ });
+
+ test("an ERC-20-only address is warned about, and its token listed", () => {
+ const html = balanceWarningHtml(TOKEN_ONLY);
+ expect(html).toContain("This address holds a balance.");
+ expect(html).toContain("does not move or spend anything");
+ expect(html).toContain("USDC");
+ expect(html).toContain("2500.0000");
+ });
+
+ // The rendered line says 0.0000 for this address — that is the display
+ // format, shared with Home and AddressDetail — and the warning is shown
+ // all the same, because the balance is not zero.
+ test("an ETH balance that renders as 0.0000 is warned about", () => {
+ const html = balanceWarningHtml(DUST);
+ expect(html).toContain("This address holds a balance.");
+ expect(html).toContain("
0.0000");
+ });
+
+ // The sentence must not assert an amount, because any amount it could
+ // assert has been rounded: "This address holds 0.0000 ETH." is what the
+ // rounded form produces for an address that holds real money.
+ test("the warning sentence asserts no rounded amount", () => {
+ for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
+ expect(balanceWarningHtml(addr)).not.toMatch(
+ /holds [\d.]+ (ETH|USDC)/,
+ );
+ }
+ });
+
+ test("the USD total is shown when prices are known", () => {
+ prices.ETH = 2000;
+ prices.USDC = 1;
+ expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
+ expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
+ });
+
+ // getAddressValueUsd() returns null on testnet and before the first
+ // price fetch. A "Total: $0.00" there would be a lie about the holdings.
+ test("no USD total is shown when prices are not known", () => {
+ expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
+ });
+});
diff --git a/tests/e2e/run.js b/tests/e2e/run.js
index bbdd3b5..29a87f9 100644
--- a/tests/e2e/run.js
+++ b/tests/e2e/run.js
@@ -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() {
diff --git a/tests/walletDelete.test.js b/tests/walletDelete.test.js
index dc592d6..201ddff 100644
--- a/tests/walletDelete.test.js
+++ b/tests/walletDelete.test.js
@@ -1,4 +1,6 @@
const {
+ canRemoveAddress,
+ removeAddressFromState,
removeWalletFromState,
broadcastActiveChanged,
} = require("../src/shared/walletDelete");
@@ -6,6 +8,7 @@ const {
// Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
+const A2 = "0x514910771AF9Ca656af840dff83E8264EcF986CA";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
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", () => {
afterEach(() => {
delete global.chrome;