feat: remove an address from an HD wallet, behind a confirmation (closes #162)
All checks were successful
check / check (push) Successful in 36s
All checks were successful
check / check (push) Successful in 36s
Address rows on Home gain an [x] control, on wallets that derive addresses from an extended key and hold more than one, opening a DeleteAddress confirmation screen. Removal cannot destroy anything: the key material stays. Derivation indices are not renumbered, so the next "+" derives the next unused index rather than resurrecting the removed one. The confirmation states the real route back -- delete the whole wallet in Settings, which asks for the password and destroys the stored recovery phrase, then import it again -- and notes that the scan which follows only finds addresses with on-chain activity. The copy varies by wallet type, since an xprv wallet has no recovery phrase. Removing an address that holds a balance is allowed, with a warning naming no figure; the funds are at the address on-chain and stay there either way. Selection and active address move only when the removed address was the one selected, and site permissions are dropped for it alone. The state transition shares its address comparison, permission cleanup and active-changed broadcast with the wallet-level removal.
This commit was merged in pull request #240.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user