Files
AutistMask/src/popup/index.js
sneak 933c13ad1a
All checks were successful
check / check (push) Successful in 14s
Add ENS support: reverse lookup and forward resolution
Reverse ENS lookup on balance refresh — if an address has an
ENS name, it's shown in the wallet list and address detail view.
Send form accepts ENS names in the To field (resolves before
sending). Placeholder updated to indicate ENS support.
2026-02-25 17:09:44 +07:00

580 lines
18 KiB
JavaScript

// AutistMask popup UI — view management and event wiring
const {
Mnemonic,
HDNodeWallet,
Wallet,
JsonRpcProvider,
formatEther,
} = require("ethers");
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 VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"send",
"receive",
"add-token",
"settings",
"approve",
];
function showView(name) {
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
el.classList.toggle("hidden", v !== 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;
}
// -- 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 provider = getProvider();
const updates = [];
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
// Fetch ETH balance
updates.push(
provider
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
})
.catch(() => {}),
);
// Reverse ENS lookup
updates.push(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
})
.catch(() => {
addr.ensName = null;
}),
);
}
}
await Promise.all(updates);
await saveState();
}
// -- render wallet list on main view --
function renderWalletList() {
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>';
return;
}
let html = "";
state.wallets.forEach((wallet, wi) => {
html += `<div class="mb-3">`;
html += `<div class="flex justify-between items-center border-b border-border pb-1 mb-1">`;
html += `<span class="font-bold">${wallet.name}</span>`;
if (wallet.type === "hd") {
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 px-1" data-wallet="${wi}" data-address="${ai}">`;
if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`;
}
html += `<div class="flex justify-between items-center">`;
html += `<span class="text-xs break-all">${addr.address}</span>`;
html += `<span class="text-xs ml-1 whitespace-nowrap">${addr.balance} ETH</span>`;
html += `</div>`;
html += `</div>`;
});
html += `</div>`;
});
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",
tokens: [],
});
wallet.nextIndex++;
await saveState();
renderWalletList();
});
});
}
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 = "";
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 =
'<div class="text-muted text-xs py-1">No tokens added yet. Use "+ Add" to track a token.</div>';
return;
}
list.innerHTML = addr.tokens
.map(
(t) =>
`<div class="py-1 border-b border-border-light flex justify-between">` +
`<span>${t.symbol}</span>` +
`<span>${t.balance || "0"}</span>` +
`</div>`,
)
.join("");
}
function updateSendTokenSelect(addr) {
const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>';
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;
await saveState();
renderWalletList();
showView("main");
}
function showAddWalletView() {
$("wallet-mnemonic").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet");
}
function showImportKeyView() {
$("import-private-key").value = "";
hideError("import-key-error");
showView("import-key");
}
function backFromWalletAdd() {
if (!state.hasWallet) {
showView("welcome");
} else {
renderWalletList();
showView("main");
}
}
// -- init --
async function init() {
if (DEBUG) {
const banner = document.createElement("div");
banner.textContent = "DEBUG / INSECURE";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
}
await loadState();
if (!state.hasWallet) {
showView("welcome");
} else {
renderWalletList();
showView("main");
// Fetch balances in the background, re-render when done
refreshBalances().then(() => {
renderWalletList();
});
}
// -- Welcome --
$("btn-welcome-add").addEventListener("click", showAddWalletView);
// -- Add wallet (unified create/import) --
$("btn-generate-phrase").addEventListener("click", () => {
const phrase = generateMnemonic();
$("wallet-mnemonic").value = phrase;
$("add-wallet-phrase-warning").classList.remove("hidden");
});
$("btn-add-wallet-confirm").addEventListener("click", () => {
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;
}
// Validate the mnemonic is real BIP-39
if (!Mnemonic.isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
"Invalid recovery phrase. Please check for typos.",
);
return;
}
hideError("add-wallet-error");
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokens: [] },
],
});
});
$("btn-add-wallet-back").addEventListener("click", backFromWalletAdd);
$("btn-add-wallet-import-key").addEventListener("click", showImportKeyView);
// -- Import private key --
$("btn-import-key-confirm").addEventListener("click", () => {
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;
}
hideError("import-key-error");
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "key",
name: "Wallet " + walletNum,
addresses: [{ address: addr, balance: "0.0000", tokens: [] }],
});
});
$("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-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
showView("send");
});
$("btn-receive").addEventListener("click", () => {
const addr = currentAddress();
$("receive-address").textContent = addr ? addr.address : "";
showView("receive");
});
$("btn-add-token").addEventListener("click", () => {
$("add-token-address").value = "";
$("add-token-info").classList.add("hidden");
hideError("add-token-error");
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;
}
// 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 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;
}
}
// TODO: prompt for password, decrypt key, construct and send transaction
const el = $("send-status");
el.textContent =
"Sending to " +
resolvedTo +
" (stub — password/signing not yet implemented)";
el.classList.remove("hidden");
});
$("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;
}
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",
});
await saveState();
}
showAddressDetail();
});
$("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", () => {
// TODO: send approval to background
renderWalletList();
showView("main");
});
$("btn-reject").addEventListener("click", () => {
// TODO: send rejection to background
renderWalletList();
showView("main");
});
}
document.addEventListener("DOMContentLoaded", init);