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,
+};