Compare commits
1 Commits
issue-271-
...
125566d256
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125566d256 |
46
README.md
46
README.md
@@ -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
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
< 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]"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
112
src/popup/views/deleteAddress.js
Normal file
112
src/popup/views/deleteAddress.js
Normal 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."
|
||||||
|
: " ";
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user