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:
@@ -1142,6 +1142,62 @@
|
||||
</button>
|
||||
</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, and the address remains
|
||||
yours. Any site permissions granted to this address are
|
||||
forgotten.
|
||||
</div>
|
||||
<!-- Filled by src/popup/views/deleteAddress.js: the route
|
||||
back names the wallet's own kind of key material. -->
|
||||
<div
|
||||
id="delete-address-recovery"
|
||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||
></div>
|
||||
<div
|
||||
id="delete-address-balance"
|
||||
class="text-xs mb-2 min-h-[1.25rem] pointer-events-none"
|
||||
>
|
||||
|
||||
</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 ============ -->
|
||||
<div id="view-show-phrase" class="view hidden">
|
||||
<button
|
||||
|
||||
@@ -33,6 +33,7 @@ const receive = require("./views/receive");
|
||||
const addToken = require("./views/addToken");
|
||||
const settings = require("./views/settings");
|
||||
const settingsAddToken = require("./views/settingsAddToken");
|
||||
const deleteAddress = require("./views/deleteAddress");
|
||||
const approval = require("./views/approval");
|
||||
|
||||
function renderWalletList() {
|
||||
@@ -101,6 +102,10 @@ const ctx = {
|
||||
pushCurrentView();
|
||||
settingsAddToken.show();
|
||||
},
|
||||
showDeleteAddress: (walletIdx, addrIdx) => {
|
||||
pushCurrentView();
|
||||
deleteAddress.show(walletIdx, addrIdx);
|
||||
},
|
||||
};
|
||||
|
||||
function needsAddress(view) {
|
||||
@@ -256,6 +261,7 @@ async function init() {
|
||||
addToken.init(ctx);
|
||||
settings.init(ctx);
|
||||
settingsAddToken.init(ctx);
|
||||
deleteAddress.init(ctx);
|
||||
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
|
||||
176
src/popup/views/deleteAddress.js
Normal file
176
src/popup/views/deleteAddress.js
Normal file
@@ -0,0 +1,176 @@
|
||||
// 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,
|
||||
addressHoldsFunds,
|
||||
balanceLinesForAddress,
|
||||
} = require("./helpers");
|
||||
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
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";
|
||||
}
|
||||
|
||||
// What it actually takes to get the address back, which is not what the
|
||||
// screen used to claim.
|
||||
//
|
||||
// Neither obvious route works: "+" derives the next unused index, because
|
||||
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
|
||||
// re-importing this wallet's key material is refused as a duplicate by
|
||||
// findWalletByXpub() for as long as the wallet is here. What remains is to
|
||||
// delete the whole wallet in Settings — which asks for the password and
|
||||
// destroys the stored secret — and import again, after which
|
||||
// scanForAddresses() rediscovers the address only if it has on-chain
|
||||
// activity. An address that was never used is not found by that scan, and
|
||||
// the copy must not imply otherwise.
|
||||
//
|
||||
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
||||
// this screen is offered on xprv wallets too.
|
||||
function recoveryPathText(wallet) {
|
||||
const secret = walletHasRecoveryPhrase(wallet)
|
||||
? "recovery phrase"
|
||||
: "extended private key";
|
||||
return (
|
||||
"Getting the address back into this list is not easy, so be sure. " +
|
||||
"Adding an address derives the next unused one, not this one, and " +
|
||||
"importing this " +
|
||||
secret +
|
||||
" again is refused while this wallet is still here. The way back is " +
|
||||
"to delete the whole wallet in Settings, which asks for your " +
|
||||
"password and destroys the stored " +
|
||||
secret +
|
||||
", and then import that " +
|
||||
secret +
|
||||
" again. The scan that follows only finds addresses that have " +
|
||||
"on-chain activity, so an address that has never been used is not " +
|
||||
"found by it."
|
||||
);
|
||||
}
|
||||
|
||||
// The balance warning, or a blank line when the address holds nothing.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
|
||||
// ETH and a five-figure stablecoin position must not get the blank line on
|
||||
// the one screen whose job is to warn. The sentence names no figure of its
|
||||
// own: the rendered lines round to four decimals, so a sentence built from a
|
||||
// rounded number would report "0.0000 ETH" for an address holding real money.
|
||||
// The lines below it carry the amounts, in the same format as Home and
|
||||
// AddressDetail, followed by the USD total when prices are known (null on
|
||||
// testnet and before the first price fetch, where the line is left off rather
|
||||
// than printed as $0.00).
|
||||
function balanceWarningHtml(addr) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const usd = getAddressValueUsd(addr);
|
||||
const total =
|
||||
usd === null
|
||||
? ""
|
||||
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
|
||||
return (
|
||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
||||
`move or spend anything; the balance stays at the address.</p>` +
|
||||
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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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 += `<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 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 titleBold = isActive ? "font-bold" : "";
|
||||
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
||||
@@ -248,7 +255,7 @@ function walletListHtml() {
|
||||
}
|
||||
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-shrink-0 ml-1">${infoBtn}</span>`;
|
||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
||||
html += `</div>`;
|
||||
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</div>`;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user