diff --git a/src/popup/index.js b/src/popup/index.js index 5458ae2..e928554 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -1,21 +1,37 @@ -// AutistMask popup UI — view management and event wiring -const { - Mnemonic, - HDNodeWallet, - Wallet, - JsonRpcProvider, - formatEther, - parseEther, -} = require("ethers"); -const { TOKENS, getTopTokenPrices } = require("../shared/tokens"); -const { encryptWithPassword, decryptWithPassword } = require("../shared/vault"); +// AutistMask popup UI — view switching and event wiring only. +// All business logic lives in src/shared/*. + +const { parseEther } = require("ethers"); const QRCode = require("qrcode"); - -const DEBUG = true; -const DEBUG_MNEMONIC = - "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; - -const BIP44_ETH_BASE = "m/44'/60'/0'/0"; +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 VIEWS = [ "welcome", @@ -30,6 +46,21 @@ const VIEWS = [ "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}`); @@ -45,228 +76,63 @@ function showView(name) { } } -// Browser-agnostic storage API -const storageApi = - typeof browser !== "undefined" - ? browser.storage.local - : chrome.storage.local; - -// Persisted state (unencrypted, public data only): -// wallets[]: { type, name, xpub (for hd), addresses: [{ address, balance, tokens }], nextIndex } -// Mnemonic/private key will be stored encrypted separately (not yet implemented). -const DEFAULT_STATE = { - hasWallet: false, - wallets: [], - rpcUrl: "https://eth.llamarpc.com", -}; - -const state = { - ...DEFAULT_STATE, - selectedWallet: null, - selectedAddress: null, -}; - -async function saveState() { - const persisted = { - hasWallet: state.hasWallet, - wallets: state.wallets, - rpcUrl: state.rpcUrl, - }; - await storageApi.set({ autistmask: persisted }); -} - -async function loadState() { - const result = await storageApi.get("autistmask"); - if (result.autistmask) { - const saved = result.autistmask; - state.hasWallet = saved.hasWallet; - state.wallets = saved.wallets || []; - state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; - } -} - -// -- 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 generateMnemonic() { - if (DEBUG) return DEBUG_MNEMONIC; - const m = Mnemonic.fromEntropy( - globalThis.crypto.getRandomValues(new Uint8Array(16)), - ); - return m.phrase; -} - -// Derive an Ethereum address at index from an xpub string -function deriveAddressFromXpub(xpub, index) { - const node = HDNodeWallet.fromExtendedKey(xpub); - const child = node.deriveChild(index); - return child.address; -} - -// Create an HD wallet from a mnemonic: returns { xpub, firstAddress } -function hdWalletFromMnemonic(mnemonic) { - const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_BASE); - const xpub = node.neuter().extendedKey; - const firstAddress = node.deriveChild(0).address; - return { xpub, firstAddress }; -} - -// Get address from a private key -function addressFromPrivateKey(key) { - const w = new Wallet(key); - return w.address; -} - -// -- caching layer -- -const PRICE_CACHE_TTL = 300000; // 5 minutes -const BALANCE_CACHE_TTL = 60000; // 60 seconds - -const cache = { - prices: { data: {}, fetchedAt: 0 }, - balances: { fetchedAt: 0 }, -}; - -// { "ETH": 1234.56, "LINK": 8.60, ... } -const prices = {}; - -async function refreshPrices() { - const now = Date.now(); - if (now - cache.prices.fetchedAt < PRICE_CACHE_TTL) return; - try { - const fetched = await getTopTokenPrices(25); - Object.assign(prices, fetched); - cache.prices.fetchedAt = now; - } catch (e) { - // prices stay stale on error - } -} - -function formatUsd(amount) { - if (amount === null || amount === undefined || isNaN(amount)) return ""; - if (amount === 0) return "$0.00"; - if (amount < 0.01) return "< $0.01"; - return ( - "$" + - amount.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - ); -} - -// Get an ethers Wallet (signer) for the currently selected address. -// Requires the decrypted secret (mnemonic or private key). -function getSignerForCurrentAddress(decryptedSecret) { - const wallet = state.wallets[state.selectedWallet]; - const addrIndex = state.selectedAddress; - if (wallet.type === "hd") { - const node = HDNodeWallet.fromPhrase( - decryptedSecret, - "", - BIP44_ETH_BASE, - ); - return node.deriveChild(addrIndex); - } else { - return new Wallet(decryptedSecret); - } -} - -// -- balance fetching -- -function getProvider() { - return new JsonRpcProvider(state.rpcUrl); -} - -function formatBalance(wei) { - const eth = formatEther(wei); - // Show up to 6 decimal places, trim trailing zeros - const parts = eth.split("."); - if (parts.length === 1) return eth + ".0"; - const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; - return parts[0] + "." + dec; -} - -async function refreshBalances() { - const now = Date.now(); - if (now - cache.balances.fetchedAt < BALANCE_CACHE_TTL) return; - const provider = getProvider(); - const updates = []; - for (const wallet of state.wallets) { - for (const addr of wallet.addresses) { - updates.push( - provider - .getBalance(addr.address) - .then((bal) => { - addr.balance = formatBalance(bal); - }) - .catch(() => {}), - ); - updates.push( - provider - .lookupAddress(addr.address) - .then((name) => { - addr.ensName = name || null; - }) - .catch(() => { - addr.ensName = null; - }), - ); - } - } - await Promise.all(updates); - cache.balances.fetchedAt = now; - await saveState(); -} - -function getAddressValueUsd(addr) { - let total = 0; - const ethBal = parseFloat(addr.balance || "0"); - if (prices.ETH) { - total += ethBal * prices.ETH; - } - for (const token of addr.tokens || []) { - const tokenBal = parseFloat(token.balance || "0"); - if (tokenBal > 0 && prices[token.symbol]) { - total += tokenBal * prices[token.symbol]; - } - } - return total; -} - -function getWalletValueUsd(wallet) { - let total = 0; - for (const addr of wallet.addresses) { - total += getAddressValueUsd(addr); - } - return total; -} - -function getTotalValueUsd() { - let total = 0; - for (const wallet of state.wallets) { - total += getWalletValueUsd(wallet); - } - return total; -} - +// -- rendering -- function renderTotalValue() { const el = $("total-value"); if (!el) return; - el.textContent = formatUsd(getTotalValueUsd()); + 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"); } -// -- render wallet list on main view -- function renderWalletList() { const container = $("wallet-list"); if (state.wallets.length === 0) { @@ -294,8 +160,7 @@ function renderWalletList() { html += `
${addr.address}
`; html += `
`; html += `${addr.balance} ETH`; - const addrUsd = getAddressValueUsd(addr); - html += `${formatUsd(addrUsd)}`; + html += `${formatUsd(getAddressValueUsd(addr))}`; html += `
`; html += ``; }); @@ -324,7 +189,7 @@ function renderWalletList() { wallet.addresses.push({ address: newAddr, balance: "0.0000", - tokens: [], + tokenBalances: [], }); wallet.nextIndex++; await saveState(); @@ -335,62 +200,6 @@ function renderWalletList() { renderTotalValue(); } -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"); - } - updateTokenList(addr); - updateSendTokenSelect(addr); - showView("address"); -} - -function updateTokenList(addr) { - const list = $("token-list"); - if (addr.tokens.length === 0) { - list.innerHTML = - '
No tokens added yet. Use "+ Add" to track a token.
'; - return; - } - list.innerHTML = addr.tokens - .map( - (t) => - `
` + - `${t.symbol}` + - `${t.balance || "0"}` + - `
`, - ) - .join(""); -} - -function updateSendTokenSelect(addr) { - const sel = $("send-token"); - sel.innerHTML = ''; - addr.tokens.forEach((t) => { - const opt = document.createElement("option"); - opt.value = t.contractAddress; - opt.textContent = t.symbol; - sel.appendChild(opt); - }); -} - -function currentAddress() { - if (state.selectedWallet === null || state.selectedAddress === null) { - return null; - } - return state.wallets[state.selectedWallet].addresses[state.selectedAddress]; -} - async function addWalletAndGoToMain(wallet) { state.wallets.push(wallet); state.hasWallet = true; @@ -401,6 +210,8 @@ async function addWalletAndGoToMain(wallet) { 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"); @@ -408,6 +219,8 @@ function showAddWalletView() { function showImportKeyView() { $("import-private-key").value = ""; + $("import-key-password").value = ""; + $("import-key-password-confirm").value = ""; hideError("import-key-error"); showView("import-key"); } @@ -421,6 +234,15 @@ function backFromWalletAdd() { } } +async function doRefreshAndRender() { + await Promise.all([ + refreshPrices(), + refreshBalances(state.wallets, state.trackedTokens, state.rpcUrl), + ]); + await saveState(); + renderWalletList(); +} + // -- init -- async function init() { if (DEBUG) { @@ -439,18 +261,15 @@ async function init() { } else { renderWalletList(); showView("main"); - // Fetch prices and balances in parallel, re-render as each completes - refreshPrices().then(() => renderWalletList()); - refreshBalances().then(() => renderWalletList()); + doRefreshAndRender(); } // -- Welcome -- $("btn-welcome-add").addEventListener("click", showAddWalletView); - // -- Add wallet (unified create/import) -- + // -- Add wallet -- $("btn-generate-phrase").addEventListener("click", () => { - const phrase = generateMnemonic(); - $("wallet-mnemonic").value = phrase; + $("wallet-mnemonic").value = generateMnemonic(); $("add-wallet-phrase-warning").classList.remove("hidden"); }); @@ -473,7 +292,7 @@ async function init() { ); return; } - if (!Mnemonic.isValidMnemonic(mnemonic)) { + if (!isValidMnemonic(mnemonic)) { showError( "add-wallet-error", "Invalid recovery phrase. Please check for typos.", @@ -498,7 +317,6 @@ async function init() { return; } hideError("add-wallet-error"); - const encrypted = await encryptWithPassword(mnemonic, pw); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const walletNum = state.wallets.length + 1; @@ -509,7 +327,7 @@ async function init() { encryptedSecret: encrypted, nextIndex: 1, addresses: [ - { address: firstAddress, balance: "0.0000", tokens: [] }, + { address: firstAddress, balance: "0.0000", tokenBalances: [] }, ], }); }); @@ -555,7 +373,9 @@ async function init() { type: "key", name: "Wallet " + walletNum, encryptedSecret: encrypted, - addresses: [{ address: addr, balance: "0.0000", tokens: [] }], + addresses: [ + { address: addr, balance: "0.0000", tokenBalances: [] }, + ], }); }); @@ -642,21 +462,19 @@ async function init() { $("send-status").classList.remove("hidden"); return; } - // Resolve ENS name if it looks like one 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(); + const provider = getProvider(state.rpcUrl); const resolved = await provider.resolveName(to); if (!resolved) { showError("send-status", "Could not resolve " + to); return; } resolvedTo = resolved; - statusEl.textContent = to + " = " + resolvedTo; } catch (e) { showError("send-status", "Failed to resolve ENS name."); return; @@ -684,8 +502,12 @@ async function init() { } statusEl.textContent = "Sending..."; try { - const signer = getSignerForCurrentAddress(decryptedSecret); - const provider = getProvider(); + const signer = getSignerForAddress( + wallet, + state.selectedAddress, + decryptedSecret, + ); + const provider = getProvider(state.rpcUrl); const connectedSigner = signer.connect(provider); const tx = await connectedSigner.sendTransaction({ to: resolvedTo, @@ -698,28 +520,22 @@ async function init() { receipt.blockNumber + ". Tx: " + receipt.hash; - // Refresh balance after send - refreshBalances().then(() => renderWalletList()); + invalidateBalanceCache(); + doRefreshAndRender(); } catch (e) { statusEl.textContent = "Failed: " + (e.shortMessage || e.message); } }); - $("btn-send-back").addEventListener("click", () => { - showAddressDetail(); - }); + $("btn-send-back").addEventListener("click", () => showAddressDetail()); // -- Receive -- $("btn-receive-copy").addEventListener("click", () => { const addr = $("receive-address").textContent; - if (addr) { - navigator.clipboard.writeText(addr); - } + if (addr) navigator.clipboard.writeText(addr); }); - $("btn-receive-back").addEventListener("click", () => { - showAddressDetail(); - }); + $("btn-receive-back").addEventListener("click", () => showAddressDetail()); // -- Add Token -- $("btn-add-token-confirm").addEventListener("click", async () => { @@ -731,24 +547,50 @@ async function init() { ); 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"); - // TODO: look up token name/symbol/decimals from contract via RPC - const addr = currentAddress(); - if (addr) { - addr.tokens.push({ - contractAddress: contractAddr, - symbol: "TKN", - decimals: 18, - balance: "0", + 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"); } - showAddressDetail(); }); - $("btn-add-token-back").addEventListener("click", () => { - showAddressDetail(); - }); + $("btn-add-token-back").addEventListener("click", () => + showAddressDetail(), + ); // -- Settings -- $("btn-save-rpc").addEventListener("click", async () => { @@ -763,13 +605,11 @@ async function init() { // -- Approval -- $("btn-approve").addEventListener("click", () => { - // TODO: send approval to background renderWalletList(); showView("main"); }); $("btn-reject").addEventListener("click", () => { - // TODO: send rejection to background renderWalletList(); showView("main"); }); diff --git a/src/shared/balances.js b/src/shared/balances.js new file mode 100644 index 0000000..5b84eab --- /dev/null +++ b/src/shared/balances.js @@ -0,0 +1,130 @@ +// Balance fetching: ETH balances, ERC-20 token balances, ENS reverse lookup. +// Cached for 60 seconds. + +const { + JsonRpcProvider, + Contract, + formatEther, + formatUnits, +} = require("ethers"); +const { ERC20_ABI } = require("./constants"); + +const BALANCE_CACHE_TTL = 60000; // 60 seconds +let lastFetchedAt = 0; + +function getProvider(rpcUrl) { + return new JsonRpcProvider(rpcUrl); +} + +function formatBalance(wei) { + const eth = formatEther(wei); + const parts = eth.split("."); + if (parts.length === 1) return eth + ".0"; + const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; + return parts[0] + "." + dec; +} + +function formatTokenBalance(raw, decimals) { + const val = formatUnits(raw, decimals); + const parts = val.split("."); + if (parts.length === 1) return val + ".0"; + const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0"; + return parts[0] + "." + dec; +} + +// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. +// trackedTokens: [{ address, symbol, decimals }] +async function refreshBalances(wallets, trackedTokens, rpcUrl) { + const now = Date.now(); + if (now - lastFetchedAt < BALANCE_CACHE_TTL) return; + const provider = getProvider(rpcUrl); + const updates = []; + + for (const wallet of wallets) { + for (const addr of wallet.addresses) { + // ETH balance + updates.push( + provider + .getBalance(addr.address) + .then((bal) => { + addr.balance = formatBalance(bal); + }) + .catch(() => {}), + ); + + // ENS reverse lookup + updates.push( + provider + .lookupAddress(addr.address) + .then((name) => { + addr.ensName = name || null; + }) + .catch(() => { + addr.ensName = null; + }), + ); + + // ERC-20 token balances + if (!addr.tokenBalances) addr.tokenBalances = []; + for (const token of trackedTokens) { + updates.push( + (async () => { + try { + const contract = new Contract( + token.address, + ERC20_ABI, + provider, + ); + const raw = await contract.balanceOf(addr.address); + const existing = addr.tokenBalances.find( + (t) => + t.address.toLowerCase() === + token.address.toLowerCase(), + ); + const bal = formatTokenBalance(raw, token.decimals); + if (existing) { + existing.balance = bal; + } else { + addr.tokenBalances.push({ + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + balance: bal, + }); + } + } catch (e) { + // skip on error + } + })(), + ); + } + } + } + + await Promise.all(updates); + lastFetchedAt = now; +} + +// Look up token metadata from its contract. +async function lookupTokenInfo(contractAddress, rpcUrl) { + const provider = getProvider(rpcUrl); + const contract = new Contract(contractAddress, ERC20_ABI, provider); + const [name, symbol, decimals] = await Promise.all([ + contract.name(), + contract.symbol(), + contract.decimals(), + ]); + return { name, symbol, decimals: Number(decimals) }; +} + +// Force-invalidate the balance cache (e.g. after sending a tx). +function invalidateBalanceCache() { + lastFetchedAt = 0; +} + +module.exports = { + refreshBalances, + lookupTokenInfo, + invalidateBalanceCache, + getProvider, +}; diff --git a/src/shared/prices.js b/src/shared/prices.js new file mode 100644 index 0000000..de83bd4 --- /dev/null +++ b/src/shared/prices.js @@ -0,0 +1,79 @@ +// Price fetching with 5-minute cache, USD formatting, value aggregation. + +const { getTopTokenPrices } = require("./tokens"); + +const PRICE_CACHE_TTL = 300000; // 5 minutes + +const prices = {}; +let lastFetchedAt = 0; + +async function refreshPrices() { + const now = Date.now(); + if (now - lastFetchedAt < PRICE_CACHE_TTL) return; + try { + const fetched = await getTopTokenPrices(25); + Object.assign(prices, fetched); + lastFetchedAt = now; + } catch (e) { + // prices stay stale on error + } +} + +function getPrice(symbol) { + return prices[symbol] || null; +} + +function formatUsd(amount) { + if (amount === null || amount === undefined || isNaN(amount)) return ""; + if (amount === 0) return "$0.00"; + if (amount < 0.01) return "< $0.01"; + return ( + "$" + + amount.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ); +} + +function getAddressValueUsd(addr) { + let total = 0; + const ethBal = parseFloat(addr.balance || "0"); + const ethPrice = prices.ETH; + if (ethPrice) { + total += ethBal * ethPrice; + } + for (const token of addr.tokenBalances || []) { + const tokenBal = parseFloat(token.balance || "0"); + if (tokenBal > 0 && prices[token.symbol]) { + total += tokenBal * prices[token.symbol]; + } + } + return total; +} + +function getWalletValueUsd(wallet) { + let total = 0; + for (const addr of wallet.addresses) { + total += getAddressValueUsd(addr); + } + return total; +} + +function getTotalValueUsd(wallets) { + let total = 0; + for (const wallet of wallets) { + total += getWalletValueUsd(wallet); + } + return total; +} + +module.exports = { + prices, + refreshPrices, + getPrice, + formatUsd, + getAddressValueUsd, + getWalletValueUsd, + getTotalValueUsd, +}; diff --git a/src/shared/state.js b/src/shared/state.js new file mode 100644 index 0000000..aa6d000 --- /dev/null +++ b/src/shared/state.js @@ -0,0 +1,49 @@ +// State management and extension storage persistence. + +const storageApi = + typeof browser !== "undefined" + ? browser.storage.local + : chrome.storage.local; + +const DEFAULT_STATE = { + hasWallet: false, + wallets: [], + trackedTokens: [], + rpcUrl: "https://eth.llamarpc.com", +}; + +const state = { + ...DEFAULT_STATE, + selectedWallet: null, + selectedAddress: null, +}; + +async function saveState() { + const persisted = { + hasWallet: state.hasWallet, + wallets: state.wallets, + trackedTokens: state.trackedTokens, + rpcUrl: state.rpcUrl, + }; + await storageApi.set({ autistmask: persisted }); +} + +async function loadState() { + const result = await storageApi.get("autistmask"); + if (result.autistmask) { + const saved = result.autistmask; + state.hasWallet = saved.hasWallet; + state.wallets = saved.wallets || []; + state.trackedTokens = saved.trackedTokens || []; + state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; + } +} + +function currentAddress() { + if (state.selectedWallet === null || state.selectedAddress === null) { + return null; + } + return state.wallets[state.selectedWallet].addresses[state.selectedAddress]; +} + +module.exports = { state, saveState, loadState, currentAddress }; diff --git a/src/shared/wallet.js b/src/shared/wallet.js new file mode 100644 index 0000000..24371d6 --- /dev/null +++ b/src/shared/wallet.js @@ -0,0 +1,63 @@ +// Wallet operations: mnemonic generation, HD derivation, signing. +// All crypto delegated to ethers.js. + +const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); + +const BIP44_ETH_BASE = "m/44'/60'/0'/0"; + +const DEBUG = true; +const DEBUG_MNEMONIC = + "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; + +function generateMnemonic() { + if (DEBUG) return DEBUG_MNEMONIC; + const m = Mnemonic.fromEntropy( + globalThis.crypto.getRandomValues(new Uint8Array(16)), + ); + return m.phrase; +} + +function deriveAddressFromXpub(xpub, index) { + const node = HDNodeWallet.fromExtendedKey(xpub); + return node.deriveChild(index).address; +} + +function hdWalletFromMnemonic(mnemonic) { + const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_BASE); + const xpub = node.neuter().extendedKey; + const firstAddress = node.deriveChild(0).address; + return { xpub, firstAddress }; +} + +function addressFromPrivateKey(key) { + const w = new Wallet(key); + return w.address; +} + +function getSignerForAddress(walletData, addrIndex, decryptedSecret) { + if (walletData.type === "hd") { + const node = HDNodeWallet.fromPhrase( + decryptedSecret, + "", + BIP44_ETH_BASE, + ); + return node.deriveChild(addrIndex); + } + return new Wallet(decryptedSecret); +} + +function isValidMnemonic(mnemonic) { + return Mnemonic.isValidMnemonic(mnemonic); +} + +module.exports = { + BIP44_ETH_BASE, + DEBUG, + DEBUG_MNEMONIC, + generateMnemonic, + deriveAddressFromXpub, + hdWalletFromMnemonic, + addressFromPrivateKey, + getSignerForAddress, + isValidMnemonic, +};