Add the ability to import an existing HD wallet using an extended private key (xprv) instead of a mnemonic phrase. - New 'xprv' wallet type with full HD derivation and address scanning - New importXprv view with password encryption - Updated getSignerForAddress to handle xprv wallet type - Added xprv link to the add-wallet view - Allow adding derived addresses for xprv wallets Closes #20
409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
isoDate,
|
|
timeAgo,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
truncateMiddle,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
|
const {
|
|
updateSendBalance,
|
|
renderSendTokenSelect,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
|
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) : " ";
|
|
}
|
|
}
|
|
|
|
const EXT_ICON =
|
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
|
`</svg></span>`;
|
|
|
|
function renderActiveAddress() {
|
|
const el = $("active-address-display");
|
|
if (!el) return;
|
|
if (state.activeAddress) {
|
|
const addr = state.activeAddress;
|
|
const dot = addressDotHtml(addr);
|
|
const link = `https://etherscan.io/address/${addr}`;
|
|
el.innerHTML =
|
|
`<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` +
|
|
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
|
$("active-addr-copy").addEventListener("click", () => {
|
|
navigator.clipboard.writeText(addr);
|
|
showFlash("Copied!");
|
|
});
|
|
} 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 = {
|
|
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>';
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let html = "";
|
|
state.wallets.forEach((wallet, wi) => {
|
|
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>`;
|
|
if (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>`;
|
|
|
|
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>`;
|
|
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}</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>`;
|
|
});
|
|
container.innerHTML = html;
|
|
|
|
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-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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
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();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-main-receive").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, render };
|