From 023d8441bc61c45ca56f0023633c39a7d6827e8f Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 25 Feb 2026 18:51:41 +0700 Subject: [PATCH] Split popup into one file per view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popup/index.js reduced to ~75 lines: loads state, builds a shared context object, initializes all views, shows first screen. Each view in popup/views/: helpers.js — $(), showError, hideError, showView welcome.js — welcome screen addWallet.js — unified create/import recovery phrase importKey.js — private key import home.js — wallet list, total value, address derivation addressDetail.js — address view, token list, QR, copy send.js — send form, ENS resolution, tx broadcast receive.js — QR + copy addToken.js — token lookup, common token picker settings.js — RPC endpoint approval.js — dApp approval (stub) Views communicate via a ctx object with shared callbacks (renderWalletList, showAddressDetail, doRefreshAndRender, etc). --- src/popup/index.js | 620 ++----------------------------- src/popup/views/addToken.js | 82 ++++ src/popup/views/addWallet.js | 103 +++++ src/popup/views/addressDetail.js | 99 +++++ src/popup/views/approval.js | 15 + src/popup/views/helpers.js | 47 +++ src/popup/views/home.js | 88 +++++ src/popup/views/importKey.js | 72 ++++ src/popup/views/receive.js | 12 + src/popup/views/send.js | 93 +++++ src/popup/views/settings.js | 16 + src/popup/views/welcome.js | 7 + 12 files changed, 673 insertions(+), 581 deletions(-) create mode 100644 src/popup/views/addToken.js create mode 100644 src/popup/views/addWallet.js create mode 100644 src/popup/views/addressDetail.js create mode 100644 src/popup/views/approval.js create mode 100644 src/popup/views/helpers.js create mode 100644 src/popup/views/home.js create mode 100644 src/popup/views/importKey.js create mode 100644 src/popup/views/receive.js create mode 100644 src/popup/views/send.js create mode 100644 src/popup/views/settings.js create mode 100644 src/popup/views/welcome.js diff --git a/src/popup/index.js b/src/popup/index.js index e928554..f11cddf 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -1,237 +1,25 @@ -// AutistMask popup UI — view switching and event wiring only. -// All business logic lives in src/shared/*. +// AutistMask popup entry point. +// Loads state, initializes views, triggers first render. -const { parseEther } = require("ethers"); -const QRCode = require("qrcode"); -const { TOKENS } = require("../shared/tokens"); -const { - state, - saveState, - loadState, - currentAddress, -} = require("../shared/state"); -const { - DEBUG, - generateMnemonic, - deriveAddressFromXpub, - hdWalletFromMnemonic, - addressFromPrivateKey, - getSignerForAddress, - isValidMnemonic, -} = require("../shared/wallet"); -const { - refreshPrices, - formatUsd, - getAddressValueUsd, - getTotalValueUsd, -} = require("../shared/prices"); -const { - refreshBalances, - lookupTokenInfo, - invalidateBalanceCache, - getProvider, -} = require("../shared/balances"); -const { encryptWithPassword, decryptWithPassword } = require("../shared/vault"); +const { DEBUG } = require("../shared/wallet"); +const { state, saveState, loadState } = require("../shared/state"); +const { refreshPrices } = require("../shared/prices"); +const { refreshBalances } = require("../shared/balances"); +const { showView } = require("./views/helpers"); -const VIEWS = [ - "welcome", - "add-wallet", - "import-key", - "main", - "address", - "send", - "receive", - "add-token", - "settings", - "approve", -]; - -// -- DOM helpers -- -function $(id) { - return document.getElementById(id); -} - -function showError(id, msg) { - const el = $(id); - el.textContent = msg; - el.classList.remove("hidden"); -} - -function hideError(id) { - $(id).classList.add("hidden"); -} - -function showView(name) { - for (const v of VIEWS) { - const el = document.getElementById(`view-${v}`); - if (el) { - el.classList.toggle("hidden", v !== name); - } - } - if (DEBUG) { - const banner = document.getElementById("debug-banner"); - if (banner) { - banner.textContent = "DEBUG / INSECURE (" + name + ")"; - } - } -} - -// -- rendering -- -function renderTotalValue() { - const el = $("total-value"); - if (!el) return; - el.textContent = formatUsd(getTotalValueUsd(state.wallets)); -} - -function renderTokenList(addr) { - const list = $("token-list"); - const balances = addr.tokenBalances || []; - if (balances.length === 0 && state.trackedTokens.length === 0) { - list.innerHTML = - '
No tokens added yet. Use "+ Add" to track a token.
'; - return; - } - list.innerHTML = balances - .map( - (t) => - `
` + - `${t.symbol}` + - `${t.balance || "0"}` + - `
`, - ) - .join(""); -} - -function renderSendTokenSelect(addr) { - const sel = $("send-token"); - sel.innerHTML = ''; - for (const t of addr.tokenBalances || []) { - const opt = document.createElement("option"); - opt.value = t.address; - opt.textContent = t.symbol; - sel.appendChild(opt); - } -} - -function showAddressDetail() { - const wallet = state.wallets[state.selectedWallet]; - const addr = wallet.addresses[state.selectedAddress]; - $("address-title").textContent = wallet.name; - $("address-full").textContent = addr.address; - $("address-copied-msg").textContent = ""; - $("address-eth-balance").textContent = addr.balance; - $("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr)); - const ensEl = $("address-ens"); - if (addr.ensName) { - ensEl.textContent = addr.ensName; - ensEl.classList.remove("hidden"); - } else { - ensEl.classList.add("hidden"); - } - renderTokenList(addr); - renderSendTokenSelect(addr); - showView("address"); -} +const home = require("./views/home"); +const welcome = require("./views/welcome"); +const addWallet = require("./views/addWallet"); +const importKey = require("./views/importKey"); +const addressDetail = require("./views/addressDetail"); +const send = require("./views/send"); +const receive = require("./views/receive"); +const addToken = require("./views/addToken"); +const settings = require("./views/settings"); +const approval = require("./views/approval"); function renderWalletList() { - const container = $("wallet-list"); - if (state.wallets.length === 0) { - container.innerHTML = - '

No wallets yet. Add one to get started.

'; - renderTotalValue(); - return; - } - - let html = ""; - state.wallets.forEach((wallet, wi) => { - html += `
`; - html += `
`; - html += `${wallet.name}`; - if (wallet.type === "hd") { - html += ``; - } - html += `
`; - - wallet.addresses.forEach((addr, ai) => { - html += `
`; - if (addr.ensName) { - html += `
${addr.ensName}
`; - } - html += `
${addr.address}
`; - html += `
`; - html += `${addr.balance} ETH`; - html += `${formatUsd(getAddressValueUsd(addr))}`; - html += `
`; - html += `
`; - }); - - html += `
`; - }); - container.innerHTML = html; - - container.querySelectorAll(".address-row").forEach((row) => { - row.addEventListener("click", () => { - state.selectedWallet = parseInt(row.dataset.wallet, 10); - state.selectedAddress = parseInt(row.dataset.address, 10); - 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(); - renderWalletList(); - }); - }); - - renderTotalValue(); -} - -async function addWalletAndGoToMain(wallet) { - state.wallets.push(wallet); - state.hasWallet = true; - await saveState(); - renderWalletList(); - showView("main"); -} - -function showAddWalletView() { - $("wallet-mnemonic").value = ""; - $("add-wallet-password").value = ""; - $("add-wallet-password-confirm").value = ""; - $("add-wallet-phrase-warning").classList.add("hidden"); - hideError("add-wallet-error"); - showView("add-wallet"); -} - -function showImportKeyView() { - $("import-private-key").value = ""; - $("import-key-password").value = ""; - $("import-key-password-confirm").value = ""; - hideError("import-key-error"); - showView("import-key"); -} - -function backFromWalletAdd() { - if (!state.hasWallet) { - showView("welcome"); - } else { - renderWalletList(); - showView("main"); - } + home.render(ctx); } async function doRefreshAndRender() { @@ -243,7 +31,15 @@ async function doRefreshAndRender() { renderWalletList(); } -// -- init -- +const ctx = { + renderWalletList, + doRefreshAndRender, + showAddWalletView: () => addWallet.show(), + showImportKeyView: () => importKey.show(), + showAddressDetail: () => addressDetail.show(), + showAddTokenView: () => addToken.show(), +}; + async function init() { if (DEBUG) { const banner = document.createElement("div"); @@ -256,6 +52,18 @@ async function init() { await loadState(); + // Initialize all view event handlers + welcome.init(ctx); + addWallet.init(ctx); + importKey.init(ctx); + home.init(ctx); + addressDetail.init(ctx); + send.init(ctx); + receive.init(ctx); + addToken.init(ctx); + settings.init(ctx); + approval.init(ctx); + if (!state.hasWallet) { showView("welcome"); } else { @@ -263,356 +71,6 @@ async function init() { showView("main"); doRefreshAndRender(); } - - // -- Welcome -- - $("btn-welcome-add").addEventListener("click", showAddWalletView); - - // -- Add wallet -- - $("btn-generate-phrase").addEventListener("click", () => { - $("wallet-mnemonic").value = generateMnemonic(); - $("add-wallet-phrase-warning").classList.remove("hidden"); - }); - - $("btn-add-wallet-confirm").addEventListener("click", async () => { - const mnemonic = $("wallet-mnemonic").value.trim(); - if (!mnemonic) { - showError( - "add-wallet-error", - "Please enter a recovery phrase or press the die to generate one.", - ); - return; - } - const words = mnemonic.split(/\s+/); - if (words.length !== 12 && words.length !== 24) { - showError( - "add-wallet-error", - "Recovery phrase must be 12 or 24 words. You entered " + - words.length + - ".", - ); - return; - } - if (!isValidMnemonic(mnemonic)) { - showError( - "add-wallet-error", - "Invalid recovery phrase. Please check for typos.", - ); - return; - } - const pw = $("add-wallet-password").value; - const pw2 = $("add-wallet-password-confirm").value; - if (!pw) { - showError("add-wallet-error", "Please choose a password."); - return; - } - if (pw.length < 8) { - showError( - "add-wallet-error", - "Password must be at least 8 characters.", - ); - return; - } - if (pw !== pw2) { - showError("add-wallet-error", "Passwords do not match."); - return; - } - hideError("add-wallet-error"); - const encrypted = await encryptWithPassword(mnemonic, pw); - const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); - const walletNum = state.wallets.length + 1; - addWalletAndGoToMain({ - type: "hd", - name: "Wallet " + walletNum, - xpub: xpub, - encryptedSecret: encrypted, - nextIndex: 1, - addresses: [ - { address: firstAddress, balance: "0.0000", tokenBalances: [] }, - ], - }); - }); - - $("btn-add-wallet-back").addEventListener("click", backFromWalletAdd); - $("btn-add-wallet-import-key").addEventListener("click", showImportKeyView); - - // -- Import private key -- - $("btn-import-key-confirm").addEventListener("click", async () => { - const key = $("import-private-key").value.trim(); - if (!key) { - showError("import-key-error", "Please enter your private key."); - return; - } - let addr; - try { - addr = addressFromPrivateKey(key); - } catch (e) { - showError("import-key-error", "Invalid private key."); - return; - } - const pw = $("import-key-password").value; - const pw2 = $("import-key-password-confirm").value; - if (!pw) { - showError("import-key-error", "Please choose a password."); - return; - } - if (pw.length < 8) { - showError( - "import-key-error", - "Password must be at least 8 characters.", - ); - return; - } - if (pw !== pw2) { - showError("import-key-error", "Passwords do not match."); - return; - } - hideError("import-key-error"); - const encrypted = await encryptWithPassword(key, pw); - const walletNum = state.wallets.length + 1; - addWalletAndGoToMain({ - type: "key", - name: "Wallet " + walletNum, - encryptedSecret: encrypted, - addresses: [ - { address: addr, balance: "0.0000", tokenBalances: [] }, - ], - }); - }); - - $("btn-import-key-back").addEventListener("click", backFromWalletAdd); - - // -- Main view -- - $("btn-settings").addEventListener("click", () => { - $("settings-rpc").value = state.rpcUrl; - showView("settings"); - }); - - $("btn-main-add-wallet").addEventListener("click", showAddWalletView); - - // -- Address detail -- - $("address-full").addEventListener("click", () => { - const addr = $("address-full").textContent; - if (addr) { - navigator.clipboard.writeText(addr); - $("address-copied-msg").textContent = "Copied!"; - setTimeout(() => { - $("address-copied-msg").textContent = ""; - }, 2000); - } - }); - - $("btn-address-back").addEventListener("click", () => { - renderWalletList(); - showView("main"); - }); - - $("btn-send").addEventListener("click", () => { - $("send-to").value = ""; - $("send-amount").value = ""; - $("send-password").value = ""; - $("send-fee-estimate").classList.add("hidden"); - $("send-status").classList.add("hidden"); - showView("send"); - }); - - $("btn-receive").addEventListener("click", () => { - const addr = currentAddress(); - const address = addr ? addr.address : ""; - $("receive-address").textContent = address; - if (address) { - QRCode.toCanvas($("receive-qr"), address, { - width: 200, - margin: 2, - color: { dark: "#000000", light: "#ffffff" }, - }); - } - showView("receive"); - }); - - $("btn-add-token").addEventListener("click", () => { - $("add-token-address").value = ""; - $("add-token-info").classList.add("hidden"); - hideError("add-token-error"); - const list = $("common-token-list"); - list.innerHTML = TOKENS.slice(0, 25) - .map( - (t) => - ``, - ) - .join(""); - list.querySelectorAll(".common-token").forEach((btn) => { - btn.addEventListener("click", () => { - $("add-token-address").value = btn.dataset.address; - }); - }); - showView("add-token"); - }); - - // -- Send -- - $("btn-send-confirm").addEventListener("click", async () => { - const to = $("send-to").value.trim(); - const amount = $("send-amount").value.trim(); - if (!to) { - showError("send-status", "Please enter a recipient address."); - $("send-status").classList.remove("hidden"); - return; - } - if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { - showError("send-status", "Please enter a valid amount."); - $("send-status").classList.remove("hidden"); - return; - } - let resolvedTo = to; - if (to.includes(".") && !to.startsWith("0x")) { - const statusEl = $("send-status"); - statusEl.textContent = "Resolving " + to + "..."; - statusEl.classList.remove("hidden"); - try { - const provider = getProvider(state.rpcUrl); - const resolved = await provider.resolveName(to); - if (!resolved) { - showError("send-status", "Could not resolve " + to); - return; - } - resolvedTo = resolved; - } catch (e) { - showError("send-status", "Failed to resolve ENS name."); - return; - } - } - const password = $("send-password").value; - if (!password) { - showError("send-status", "Please enter your password."); - $("send-status").classList.remove("hidden"); - return; - } - const wallet = state.wallets[state.selectedWallet]; - let decryptedSecret; - const statusEl = $("send-status"); - statusEl.textContent = "Decrypting..."; - statusEl.classList.remove("hidden"); - try { - decryptedSecret = await decryptWithPassword( - wallet.encryptedSecret, - password, - ); - } catch (e) { - showError("send-status", "Wrong password."); - return; - } - statusEl.textContent = "Sending..."; - try { - const signer = getSignerForAddress( - wallet, - state.selectedAddress, - decryptedSecret, - ); - const provider = getProvider(state.rpcUrl); - const connectedSigner = signer.connect(provider); - const tx = await connectedSigner.sendTransaction({ - to: resolvedTo, - value: parseEther(amount), - }); - statusEl.textContent = "Sent. Waiting for confirmation..."; - const receipt = await tx.wait(); - statusEl.textContent = - "Confirmed in block " + - receipt.blockNumber + - ". Tx: " + - receipt.hash; - invalidateBalanceCache(); - doRefreshAndRender(); - } catch (e) { - statusEl.textContent = "Failed: " + (e.shortMessage || e.message); - } - }); - - $("btn-send-back").addEventListener("click", () => showAddressDetail()); - - // -- Receive -- - $("btn-receive-copy").addEventListener("click", () => { - const addr = $("receive-address").textContent; - if (addr) navigator.clipboard.writeText(addr); - }); - - $("btn-receive-back").addEventListener("click", () => showAddressDetail()); - - // -- Add Token -- - $("btn-add-token-confirm").addEventListener("click", async () => { - const contractAddr = $("add-token-address").value.trim(); - if (!contractAddr || !contractAddr.startsWith("0x")) { - showError( - "add-token-error", - "Please enter a valid contract address starting with 0x.", - ); - return; - } - // Check if already tracked - const already = state.trackedTokens.find( - (t) => t.address.toLowerCase() === contractAddr.toLowerCase(), - ); - if (already) { - showError( - "add-token-error", - already.symbol + " is already being tracked.", - ); - return; - } - hideError("add-token-error"); - const infoEl = $("add-token-info"); - infoEl.textContent = "Looking up token..."; - infoEl.classList.remove("hidden"); - try { - const info = await lookupTokenInfo(contractAddr, state.rpcUrl); - state.trackedTokens.push({ - address: contractAddr, - symbol: info.symbol, - decimals: info.decimals, - name: info.name, - }); - await saveState(); - invalidateBalanceCache(); - await refreshBalances( - state.wallets, - state.trackedTokens, - state.rpcUrl, - ); - await saveState(); - showAddressDetail(); - } catch (e) { - showError( - "add-token-error", - "Could not read token contract. Check the address.", - ); - infoEl.classList.add("hidden"); - } - }); - - $("btn-add-token-back").addEventListener("click", () => - showAddressDetail(), - ); - - // -- Settings -- - $("btn-save-rpc").addEventListener("click", async () => { - state.rpcUrl = $("settings-rpc").value.trim(); - await saveState(); - }); - - $("btn-settings-back").addEventListener("click", () => { - renderWalletList(); - showView("main"); - }); - - // -- Approval -- - $("btn-approve").addEventListener("click", () => { - renderWalletList(); - showView("main"); - }); - - $("btn-reject").addEventListener("click", () => { - renderWalletList(); - showView("main"); - }); } document.addEventListener("DOMContentLoaded", init); diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js new file mode 100644 index 0000000..18f6f77 --- /dev/null +++ b/src/popup/views/addToken.js @@ -0,0 +1,82 @@ +const { $, showError, hideError, showView } = require("./helpers"); +const { TOKENS } = require("../../shared/tokens"); +const { state, saveState } = require("../../shared/state"); +const { + lookupTokenInfo, + invalidateBalanceCache, + refreshBalances, +} = require("../../shared/balances"); + +function show() { + $("add-token-address").value = ""; + $("add-token-info").classList.add("hidden"); + hideError("add-token-error"); + const list = $("common-token-list"); + list.innerHTML = TOKENS.slice(0, 25) + .map( + (t) => + ``, + ) + .join(""); + list.querySelectorAll(".common-token").forEach((btn) => { + btn.addEventListener("click", () => { + $("add-token-address").value = btn.dataset.address; + }); + }); + showView("add-token"); +} + +function init(ctx) { + $("btn-add-token-confirm").addEventListener("click", async () => { + const contractAddr = $("add-token-address").value.trim(); + if (!contractAddr || !contractAddr.startsWith("0x")) { + showError( + "add-token-error", + "Please enter a valid contract address starting with 0x.", + ); + return; + } + const already = state.trackedTokens.find( + (t) => t.address.toLowerCase() === contractAddr.toLowerCase(), + ); + if (already) { + showError( + "add-token-error", + already.symbol + " is already being tracked.", + ); + return; + } + hideError("add-token-error"); + const infoEl = $("add-token-info"); + infoEl.textContent = "Looking up token..."; + infoEl.classList.remove("hidden"); + try { + const info = await lookupTokenInfo(contractAddr, state.rpcUrl); + state.trackedTokens.push({ + address: contractAddr, + symbol: info.symbol, + decimals: info.decimals, + name: info.name, + }); + await saveState(); + invalidateBalanceCache(); + await refreshBalances( + state.wallets, + state.trackedTokens, + state.rpcUrl, + ); + await saveState(); + ctx.showAddressDetail(); + } catch (e) { + showError( + "add-token-error", + "Could not read token contract. Check the address.", + ); + infoEl.classList.add("hidden"); + } + }); + + $("btn-add-token-back").addEventListener("click", ctx.showAddressDetail); +} + +module.exports = { init, show }; diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js new file mode 100644 index 0000000..6336a02 --- /dev/null +++ b/src/popup/views/addWallet.js @@ -0,0 +1,103 @@ +const { $, showError, hideError, showView } = require("./helpers"); +const { + generateMnemonic, + hdWalletFromMnemonic, + isValidMnemonic, +} = require("../../shared/wallet"); +const { encryptWithPassword } = require("../../shared/vault"); +const { state, saveState } = require("../../shared/state"); + +function show() { + $("wallet-mnemonic").value = ""; + $("add-wallet-password").value = ""; + $("add-wallet-password-confirm").value = ""; + $("add-wallet-phrase-warning").classList.add("hidden"); + hideError("add-wallet-error"); + showView("add-wallet"); +} + +function init(ctx) { + $("btn-generate-phrase").addEventListener("click", () => { + $("wallet-mnemonic").value = generateMnemonic(); + $("add-wallet-phrase-warning").classList.remove("hidden"); + }); + + $("btn-add-wallet-confirm").addEventListener("click", async () => { + const mnemonic = $("wallet-mnemonic").value.trim(); + if (!mnemonic) { + showError( + "add-wallet-error", + "Please enter a recovery phrase or press the die to generate one.", + ); + return; + } + const words = mnemonic.split(/\s+/); + if (words.length !== 12 && words.length !== 24) { + showError( + "add-wallet-error", + "Recovery phrase must be 12 or 24 words. You entered " + + words.length + + ".", + ); + return; + } + if (!isValidMnemonic(mnemonic)) { + showError( + "add-wallet-error", + "Invalid recovery phrase. Please check for typos.", + ); + return; + } + const pw = $("add-wallet-password").value; + const pw2 = $("add-wallet-password-confirm").value; + if (!pw) { + showError("add-wallet-error", "Please choose a password."); + return; + } + if (pw.length < 8) { + showError( + "add-wallet-error", + "Password must be at least 8 characters.", + ); + return; + } + if (pw !== pw2) { + showError("add-wallet-error", "Passwords do not match."); + return; + } + hideError("add-wallet-error"); + const encrypted = await encryptWithPassword(mnemonic, pw); + const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); + const walletNum = state.wallets.length + 1; + state.wallets.push({ + type: "hd", + name: "Wallet " + walletNum, + xpub: xpub, + encryptedSecret: encrypted, + nextIndex: 1, + addresses: [ + { address: firstAddress, balance: "0.0000", tokenBalances: [] }, + ], + }); + state.hasWallet = true; + await saveState(); + ctx.renderWalletList(); + showView("main"); + }); + + $("btn-add-wallet-back").addEventListener("click", () => { + if (!state.hasWallet) { + showView("welcome"); + } else { + ctx.renderWalletList(); + showView("main"); + } + }); + + $("btn-add-wallet-import-key").addEventListener( + "click", + ctx.showImportKeyView, + ); +} + +module.exports = { init, show }; diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js new file mode 100644 index 0000000..048bba3 --- /dev/null +++ b/src/popup/views/addressDetail.js @@ -0,0 +1,99 @@ +const { $, showView } = require("./helpers"); +const { state, currentAddress } = require("../../shared/state"); +const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); +const QRCode = require("qrcode"); + +function show() { + const wallet = state.wallets[state.selectedWallet]; + const addr = wallet.addresses[state.selectedAddress]; + $("address-title").textContent = wallet.name; + $("address-full").textContent = addr.address; + $("address-copied-msg").textContent = ""; + $("address-eth-balance").textContent = addr.balance; + $("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr)); + const ensEl = $("address-ens"); + if (addr.ensName) { + ensEl.textContent = addr.ensName; + ensEl.classList.remove("hidden"); + } else { + ensEl.classList.add("hidden"); + } + renderTokenList(addr); + renderSendTokenSelect(addr); + showView("address"); +} + +function renderTokenList(addr) { + const list = $("token-list"); + const balances = addr.tokenBalances || []; + if (balances.length === 0 && state.trackedTokens.length === 0) { + list.innerHTML = + '
No tokens added yet. Use "+ Add" to track a token.
'; + return; + } + list.innerHTML = balances + .map( + (t) => + `
` + + `${t.symbol}` + + `${t.balance || "0"}` + + `
`, + ) + .join(""); +} + +function renderSendTokenSelect(addr) { + const sel = $("send-token"); + sel.innerHTML = ''; + for (const t of addr.tokenBalances || []) { + const opt = document.createElement("option"); + opt.value = t.address; + opt.textContent = t.symbol; + sel.appendChild(opt); + } +} + +function init(ctx) { + $("address-full").addEventListener("click", () => { + const addr = $("address-full").textContent; + if (addr) { + navigator.clipboard.writeText(addr); + $("address-copied-msg").textContent = "Copied!"; + setTimeout(() => { + $("address-copied-msg").textContent = ""; + }, 2000); + } + }); + + $("btn-address-back").addEventListener("click", () => { + ctx.renderWalletList(); + showView("main"); + }); + + $("btn-send").addEventListener("click", () => { + $("send-to").value = ""; + $("send-amount").value = ""; + $("send-password").value = ""; + $("send-fee-estimate").classList.add("hidden"); + $("send-status").classList.add("hidden"); + showView("send"); + }); + + $("btn-receive").addEventListener("click", () => { + const addr = currentAddress(); + const address = addr ? addr.address : ""; + $("receive-address").textContent = address; + if (address) { + QRCode.toCanvas($("receive-qr"), address, { + width: 200, + margin: 2, + color: { dark: "#000000", light: "#ffffff" }, + }); + } + showView("receive"); + }); + + $("btn-add-token").addEventListener("click", ctx.showAddTokenView); +} + +module.exports = { init, show }; diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js new file mode 100644 index 0000000..fde0269 --- /dev/null +++ b/src/popup/views/approval.js @@ -0,0 +1,15 @@ +const { $, showView } = require("./helpers"); + +function init(ctx) { + $("btn-approve").addEventListener("click", () => { + ctx.renderWalletList(); + showView("main"); + }); + + $("btn-reject").addEventListener("click", () => { + ctx.renderWalletList(); + showView("main"); + }); +} + +module.exports = { init }; diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js new file mode 100644 index 0000000..dd4ac02 --- /dev/null +++ b/src/popup/views/helpers.js @@ -0,0 +1,47 @@ +// Shared DOM helpers used by all views. + +const { DEBUG } = require("../../shared/wallet"); + +const VIEWS = [ + "welcome", + "add-wallet", + "import-key", + "main", + "address", + "send", + "receive", + "add-token", + "settings", + "approve", +]; + +function $(id) { + return document.getElementById(id); +} + +function showError(id, msg) { + const el = $(id); + el.textContent = msg; + el.classList.remove("hidden"); +} + +function hideError(id) { + $(id).classList.add("hidden"); +} + +function showView(name) { + for (const v of VIEWS) { + const el = document.getElementById(`view-${v}`); + if (el) { + el.classList.toggle("hidden", v !== name); + } + } + if (DEBUG) { + const banner = document.getElementById("debug-banner"); + if (banner) { + banner.textContent = "DEBUG / INSECURE (" + name + ")"; + } + } +} + +module.exports = { $, showError, hideError, showView }; diff --git a/src/popup/views/home.js b/src/popup/views/home.js new file mode 100644 index 0000000..ed59985 --- /dev/null +++ b/src/popup/views/home.js @@ -0,0 +1,88 @@ +const { $, showView } = require("./helpers"); +const { state, saveState } = require("../../shared/state"); +const { deriveAddressFromXpub } = require("../../shared/wallet"); +const { + formatUsd, + getAddressValueUsd, + getTotalValueUsd, +} = require("../../shared/prices"); + +function renderTotalValue() { + const el = $("total-value"); + if (!el) return; + el.textContent = formatUsd(getTotalValueUsd(state.wallets)); +} + +function render(ctx) { + const container = $("wallet-list"); + if (state.wallets.length === 0) { + container.innerHTML = + '

No wallets yet. Add one to get started.

'; + renderTotalValue(); + return; + } + + let html = ""; + state.wallets.forEach((wallet, wi) => { + html += `
`; + html += `
`; + html += `${wallet.name}`; + if (wallet.type === "hd") { + html += ``; + } + html += `
`; + + wallet.addresses.forEach((addr, ai) => { + html += `
`; + if (addr.ensName) { + html += `
${addr.ensName}
`; + } + html += `
${addr.address}
`; + html += `
`; + html += `${addr.balance} ETH`; + html += `${formatUsd(getAddressValueUsd(addr))}`; + html += `
`; + html += `
`; + }); + + html += `
`; + }); + container.innerHTML = html; + + container.querySelectorAll(".address-row").forEach((row) => { + row.addEventListener("click", () => { + state.selectedWallet = parseInt(row.dataset.wallet, 10); + state.selectedAddress = parseInt(row.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]; + wallet.addresses.push({ + address: deriveAddressFromXpub(wallet.xpub, wallet.nextIndex), + balance: "0.0000", + tokenBalances: [], + }); + wallet.nextIndex++; + await saveState(); + render(ctx); + }); + }); + + renderTotalValue(); +} + +function init(ctx) { + $("btn-settings").addEventListener("click", () => { + $("settings-rpc").value = state.rpcUrl; + showView("settings"); + }); + + $("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView); +} + +module.exports = { init, render }; diff --git a/src/popup/views/importKey.js b/src/popup/views/importKey.js new file mode 100644 index 0000000..0794e81 --- /dev/null +++ b/src/popup/views/importKey.js @@ -0,0 +1,72 @@ +const { $, showError, hideError, showView } = require("./helpers"); +const { addressFromPrivateKey } = require("../../shared/wallet"); +const { encryptWithPassword } = require("../../shared/vault"); +const { state, saveState } = require("../../shared/state"); + +function show() { + $("import-private-key").value = ""; + $("import-key-password").value = ""; + $("import-key-password-confirm").value = ""; + hideError("import-key-error"); + showView("import-key"); +} + +function init(ctx) { + $("btn-import-key-confirm").addEventListener("click", async () => { + const key = $("import-private-key").value.trim(); + if (!key) { + showError("import-key-error", "Please enter your private key."); + return; + } + let addr; + try { + addr = addressFromPrivateKey(key); + } catch (e) { + showError("import-key-error", "Invalid private key."); + return; + } + const pw = $("import-key-password").value; + const pw2 = $("import-key-password-confirm").value; + if (!pw) { + showError("import-key-error", "Please choose a password."); + return; + } + if (pw.length < 8) { + showError( + "import-key-error", + "Password must be at least 8 characters.", + ); + return; + } + if (pw !== pw2) { + showError("import-key-error", "Passwords do not match."); + return; + } + hideError("import-key-error"); + const encrypted = await encryptWithPassword(key, pw); + const walletNum = state.wallets.length + 1; + state.wallets.push({ + type: "key", + name: "Wallet " + walletNum, + encryptedSecret: encrypted, + addresses: [ + { address: addr, balance: "0.0000", tokenBalances: [] }, + ], + }); + state.hasWallet = true; + await saveState(); + ctx.renderWalletList(); + showView("main"); + }); + + $("btn-import-key-back").addEventListener("click", () => { + if (!state.hasWallet) { + showView("welcome"); + } else { + ctx.renderWalletList(); + showView("main"); + } + }); +} + +module.exports = { init, show }; diff --git a/src/popup/views/receive.js b/src/popup/views/receive.js new file mode 100644 index 0000000..3bfe573 --- /dev/null +++ b/src/popup/views/receive.js @@ -0,0 +1,12 @@ +const { $ } = require("./helpers"); + +function init(ctx) { + $("btn-receive-copy").addEventListener("click", () => { + const addr = $("receive-address").textContent; + if (addr) navigator.clipboard.writeText(addr); + }); + + $("btn-receive-back").addEventListener("click", ctx.showAddressDetail); +} + +module.exports = { init }; diff --git a/src/popup/views/send.js b/src/popup/views/send.js new file mode 100644 index 0000000..c24c12d --- /dev/null +++ b/src/popup/views/send.js @@ -0,0 +1,93 @@ +const { parseEther } = require("ethers"); +const { $, showError } = require("./helpers"); +const { state } = require("../../shared/state"); +const { getSignerForAddress } = require("../../shared/wallet"); +const { decryptWithPassword } = require("../../shared/vault"); +const { + getProvider, + invalidateBalanceCache, +} = require("../../shared/balances"); + +function init(ctx) { + $("btn-send-confirm").addEventListener("click", async () => { + const to = $("send-to").value.trim(); + const amount = $("send-amount").value.trim(); + if (!to) { + showError("send-status", "Please enter a recipient address."); + $("send-status").classList.remove("hidden"); + return; + } + if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { + showError("send-status", "Please enter a valid amount."); + $("send-status").classList.remove("hidden"); + return; + } + let resolvedTo = to; + if (to.includes(".") && !to.startsWith("0x")) { + const statusEl = $("send-status"); + statusEl.textContent = "Resolving " + to + "..."; + statusEl.classList.remove("hidden"); + try { + const provider = getProvider(state.rpcUrl); + const resolved = await provider.resolveName(to); + if (!resolved) { + showError("send-status", "Could not resolve " + to); + return; + } + resolvedTo = resolved; + } catch (e) { + showError("send-status", "Failed to resolve ENS name."); + return; + } + } + const password = $("send-password").value; + if (!password) { + showError("send-status", "Please enter your password."); + $("send-status").classList.remove("hidden"); + return; + } + const wallet = state.wallets[state.selectedWallet]; + let decryptedSecret; + const statusEl = $("send-status"); + statusEl.textContent = "Decrypting..."; + statusEl.classList.remove("hidden"); + try { + decryptedSecret = await decryptWithPassword( + wallet.encryptedSecret, + password, + ); + } catch (e) { + showError("send-status", "Wrong password."); + return; + } + statusEl.textContent = "Sending..."; + try { + const signer = getSignerForAddress( + wallet, + state.selectedAddress, + decryptedSecret, + ); + const provider = getProvider(state.rpcUrl); + const connectedSigner = signer.connect(provider); + const tx = await connectedSigner.sendTransaction({ + to: resolvedTo, + value: parseEther(amount), + }); + statusEl.textContent = "Sent. Waiting for confirmation..."; + const receipt = await tx.wait(); + statusEl.textContent = + "Confirmed in block " + + receipt.blockNumber + + ". Tx: " + + receipt.hash; + invalidateBalanceCache(); + ctx.doRefreshAndRender(); + } catch (e) { + statusEl.textContent = "Failed: " + (e.shortMessage || e.message); + } + }); + + $("btn-send-back").addEventListener("click", ctx.showAddressDetail); +} + +module.exports = { init }; diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js new file mode 100644 index 0000000..a32baa6 --- /dev/null +++ b/src/popup/views/settings.js @@ -0,0 +1,16 @@ +const { $, showView } = require("./helpers"); +const { state, saveState } = require("../../shared/state"); + +function init(ctx) { + $("btn-save-rpc").addEventListener("click", async () => { + state.rpcUrl = $("settings-rpc").value.trim(); + await saveState(); + }); + + $("btn-settings-back").addEventListener("click", () => { + ctx.renderWalletList(); + showView("main"); + }); +} + +module.exports = { init }; diff --git a/src/popup/views/welcome.js b/src/popup/views/welcome.js new file mode 100644 index 0000000..46d27f3 --- /dev/null +++ b/src/popup/views/welcome.js @@ -0,0 +1,7 @@ +const { $ } = require("./helpers"); + +function init(ctx) { + $("btn-welcome-add").addEventListener("click", ctx.showAddWalletView); +} + +module.exports = { init };