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.
446 lines
16 KiB
JavaScript
446 lines
16 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
balanceLinesForAddress,
|
|
isoDate,
|
|
timeAgo,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
truncateMiddle,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
|
const {
|
|
updateSendBalance,
|
|
renderSendTokenSelect,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
|
const { canRemoveAddress } = require("../../shared/walletDelete");
|
|
const {
|
|
walletDefect,
|
|
walletDefectHtml,
|
|
} = require("../../shared/walletDefects");
|
|
const {
|
|
formatUsd,
|
|
getPrice,
|
|
getAddressValueUsd,
|
|
} = require("../../shared/prices");
|
|
const {
|
|
fetchRecentTransactions,
|
|
filterTransactions,
|
|
} = require("../../shared/transactions");
|
|
const { log } = require("../../shared/log");
|
|
|
|
function findActiveAddr() {
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
if (a.address === state.activeAddress) return a;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderTotalValue() {
|
|
const el = $("total-value");
|
|
const subEl = $("total-value-sub");
|
|
const priceEl = $("eth-price-display");
|
|
if (!el) return;
|
|
|
|
const ethPrice = getPrice("ETH");
|
|
if (priceEl) {
|
|
priceEl.innerHTML = ethPrice
|
|
? formatUsd(ethPrice) + " USD/ETH"
|
|
: " ";
|
|
}
|
|
|
|
const addr = findActiveAddr();
|
|
if (!addr) {
|
|
el.innerHTML = " ";
|
|
if (subEl) subEl.innerHTML = " ";
|
|
return;
|
|
}
|
|
const ethBal = parseFloat(addr.balance || "0");
|
|
const ethStr = ethBal.toFixed(4) + " ETH";
|
|
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
|
el.textContent = ethStr + ethUsd;
|
|
|
|
if (subEl) {
|
|
const totalUsd = getAddressValueUsd(addr);
|
|
subEl.innerHTML =
|
|
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : " ";
|
|
}
|
|
}
|
|
|
|
function renderActiveAddress() {
|
|
const el = $("active-address-display");
|
|
if (!el) return;
|
|
if (state.activeAddress) {
|
|
el.innerHTML = renderAddressHtml(state.activeAddress);
|
|
attachCopyHandlers(el);
|
|
} else {
|
|
el.textContent = "";
|
|
}
|
|
}
|
|
|
|
let homeTxs = [];
|
|
|
|
function renderHomeTxList(ctx) {
|
|
const list = $("home-tx-list");
|
|
if (!list) return;
|
|
if (homeTxs.length === 0) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
|
return;
|
|
}
|
|
let html = "";
|
|
let i = 0;
|
|
for (const tx of homeTxs) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address (the one that initiated the swap) instead of the
|
|
// contract address which is not useful in the list view.
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? tx.to
|
|
: tx.from;
|
|
const dirLabel = tx.directionLabel;
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + tx.symbol)
|
|
: escapeHtml(tx.symbol);
|
|
const title = addressTitle(counterparty, state.wallets);
|
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
|
const addrStr = escapeHtml(displayAddr);
|
|
const dot = addressDotHtml(counterparty);
|
|
const err = tx.isError ? " (failed)" : "";
|
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
|
html += `</div>`;
|
|
i++;
|
|
}
|
|
list.innerHTML = html;
|
|
list.querySelectorAll(".home-tx-row").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const idx = parseInt(row.dataset.tx, 10);
|
|
const tx = homeTxs[idx];
|
|
// Set selectedWallet/selectedAddress so back navigation works
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (
|
|
let ai = 0;
|
|
ai < state.wallets[wi].addresses.length;
|
|
ai++
|
|
) {
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (
|
|
addr.toLowerCase() === tx.from.toLowerCase() ||
|
|
addr.toLowerCase() === tx.to.toLowerCase()
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
state.selectedToken = null;
|
|
ctx.showTransactionDetail(tx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadHomeTxs(ctx) {
|
|
const allAddresses = [];
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
allAddresses.push(a.address);
|
|
}
|
|
}
|
|
if (allAddresses.length === 0) return;
|
|
|
|
const filters = {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
|
hideFraudContracts: state.hideFraudContracts,
|
|
hideDustTransactions: state.hideDustTransactions,
|
|
dustThresholdGwei: state.dustThresholdGwei,
|
|
fraudContracts: state.fraudContracts,
|
|
};
|
|
|
|
try {
|
|
const fetches = allAddresses.map((addr) =>
|
|
fetchRecentTransactions(addr, state.blockscoutUrl),
|
|
);
|
|
const results = await Promise.all(fetches);
|
|
|
|
// Merge, deduplicate by hash, filter, sort, take 25
|
|
const seen = new Set();
|
|
let merged = [];
|
|
for (const txs of results) {
|
|
for (const tx of txs) {
|
|
if (seen.has(tx.hash)) continue;
|
|
seen.add(tx.hash);
|
|
merged.push(tx);
|
|
}
|
|
}
|
|
|
|
const filtered = filterTransactions(merged, filters);
|
|
|
|
// Persist any newly discovered fraud contracts
|
|
if (filtered.newFraudContracts.length > 0) {
|
|
for (const addr of filtered.newFraudContracts) {
|
|
if (!state.fraudContracts.includes(addr)) {
|
|
state.fraudContracts.push(addr);
|
|
}
|
|
}
|
|
await saveState();
|
|
}
|
|
|
|
merged = filtered.transactions;
|
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
|
homeTxs = merged.slice(0, 25);
|
|
renderHomeTxList(ctx);
|
|
} catch (e) {
|
|
log.errorf("loadHomeTxs failed:", e.message);
|
|
const list = $("home-tx-list");
|
|
if (list) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// The wallet list markup. Pure: it reads state and returns a string, so the
|
|
// list can be asserted on without a DOM.
|
|
function walletListHtml() {
|
|
let html = "";
|
|
state.wallets.forEach((wallet, wi) => {
|
|
const defect = walletDefect(wallet);
|
|
html += `<div>`;
|
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
|
|
// No "+" on a defective wallet: deriving another address from that
|
|
// xpub would only add one more address the key does not produce
|
|
// under the standard path.
|
|
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
|
|
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
|
}
|
|
html += `</div>`;
|
|
html += walletDefectHtml(wallet);
|
|
|
|
wallet.addresses.forEach((addr, 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 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>`;
|
|
if (addr.ensName) {
|
|
html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
|
|
}
|
|
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}${removeBtn}</span>`;
|
|
html += `</div>`;
|
|
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</div>`;
|
|
html += balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
html += `</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
});
|
|
return html;
|
|
}
|
|
|
|
function render(ctx) {
|
|
const container = $("wallet-list");
|
|
if (state.wallets.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = walletListHtml();
|
|
|
|
container.querySelectorAll(".address-row").forEach((row) => {
|
|
row.addEventListener("click", async () => {
|
|
const wi = parseInt(row.dataset.wallet, 10);
|
|
const ai = parseInt(row.dataset.address, 10);
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (state.activeAddress !== addr) {
|
|
state.activeAddress = addr;
|
|
await saveState();
|
|
render(ctx);
|
|
const runtime =
|
|
typeof browser !== "undefined"
|
|
? browser.runtime
|
|
: chrome.runtime;
|
|
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-addr-info").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
state.selectedWallet = parseInt(btn.dataset.wallet, 10);
|
|
state.selectedAddress = parseInt(btn.dataset.address, 10);
|
|
ctx.showAddressDetail();
|
|
});
|
|
});
|
|
|
|
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();
|
|
const wi = parseInt(btn.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const newAddr = deriveAddressFromXpub(
|
|
wallet.xpub,
|
|
wallet.nextIndex,
|
|
);
|
|
wallet.addresses.push({
|
|
address: newAddr,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
});
|
|
wallet.nextIndex++;
|
|
await saveState();
|
|
render(ctx);
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".wallet-name").forEach((span) => {
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(span.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.value = wallet.name;
|
|
input.className =
|
|
"font-bold border border-border p-0 bg-bg text-fg";
|
|
input.style.width = "100%";
|
|
const save = async () => {
|
|
const val = input.value.trim();
|
|
if (val && val !== wallet.name) {
|
|
wallet.name = val;
|
|
await saveState();
|
|
}
|
|
render(ctx);
|
|
};
|
|
input.addEventListener("blur", save);
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") input.blur();
|
|
if (ev.key === "Escape") {
|
|
input.value = wallet.name;
|
|
input.blur();
|
|
}
|
|
});
|
|
span.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
});
|
|
});
|
|
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
loadHomeTxs(ctx);
|
|
}
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Call
|
|
// after selectActiveAddress().
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
function selectActiveAddress() {
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
|
|
if (
|
|
state.wallets[wi].addresses[ai].address === state.activeAddress
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function init(ctx) {
|
|
$("btn-add-wallet-bottom").addEventListener("click", ctx.showAddWalletView);
|
|
|
|
$("btn-main-send").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
// Before the balance check and before any password is asked for: this
|
|
// wallet cannot sign at all, so the send screen is a dead end.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
const addr = currentAddress();
|
|
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
|
showFlash("Cannot send \u2014 zero balance.");
|
|
return;
|
|
}
|
|
$("send-to").value = "";
|
|
$("send-amount").value = "";
|
|
$("send-token").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
renderSendTokenSelect(addr);
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-main-receive").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, render, walletListHtml };
|