// Send view: collect To, Amount, Token. Then go to confirmation. const { $, showFlash, addressTitle, escapeHtml, renderAddressHtml, attachCopyHandlers, goBack, } = require("./helpers"); const { state, currentAddress } = require("../../shared/state"); let ctx; const { getProvider } = require("../../shared/balances"); const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList"); const { getAddress } = require("ethers"); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; /** * Validate a destination address string. * Returns { valid: true } or { valid: false, error: "..." }. */ function validateToAddress(value) { const v = value.trim(); if (!v) return { valid: false, error: "" }; // ENS names: contains a dot and doesn't start with 0x if (v.includes(".") && !v.startsWith("0x")) { // Basic ENS format check: at least one label before and after dot if (/^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(v)) { return { valid: true }; } return { valid: false, error: "Please enter a valid ENS name.", }; } // Must look like an Ethereum address if (!/^0x[0-9a-fA-F]{40}$/.test(v)) { return { valid: false, error: "Please enter a valid Ethereum address.", }; } // Reject zero address if (v.toLowerCase() === ZERO_ADDRESS) { return { valid: false, error: "Sending to the zero address is not allowed.", }; } // EIP-55 checksum validation: all-lowercase is ok, otherwise must match checksum if (v !== v.toLowerCase()) { try { const checksummed = getAddress(v); if (checksummed !== v) { return { valid: false, error: "Address checksum is invalid. Please double-check the address.", }; } } catch { return { valid: false, error: "Address checksum is invalid. Please double-check the address.", }; } } // Warn if sending to own address const addr = currentAddress(); if (addr && v.toLowerCase() === addr.address.toLowerCase()) { // Allow but will warn — we return valid with a warning return { valid: true, warning: "This is your own address. Are you sure?", }; } return { valid: true }; } function updateToValidation() { const input = $("send-to"); const errorEl = $("send-to-error"); const btn = $("btn-send-review"); const value = input.value.trim(); if (!value) { errorEl.textContent = ""; btn.disabled = true; btn.classList.add("opacity-50"); return; } const result = validateToAddress(value); if (!result.valid) { errorEl.textContent = result.error; errorEl.style.color = "#cc0000"; btn.disabled = true; btn.classList.add("opacity-50"); } else if (result.warning) { errorEl.textContent = result.warning; errorEl.style.color = "#b8860b"; btn.disabled = false; btn.classList.remove("opacity-50"); } else { errorEl.textContent = ""; btn.disabled = false; btn.classList.remove("opacity-50"); } } function isSpoofedToken(t) { const upper = (t.symbol || "").toUpperCase(); if (!KNOWN_SYMBOLS.has(upper)) return false; const legit = KNOWN_SYMBOLS.get(upper); if (legit === null) return true; return t.address.toLowerCase() !== legit; } function renderSendTokenSelect(addr) { const sel = $("send-token"); sel.innerHTML = ''; const fraudSet = new Set( (state.fraudContracts || []).map((a) => a.toLowerCase()), ); for (const t of addr.tokenBalances || []) { if (isSpoofedToken(t)) continue; if (fraudSet.has(t.address.toLowerCase())) continue; if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue; const opt = document.createElement("option"); opt.value = t.address; opt.textContent = t.symbol; sel.appendChild(opt); } } function updateSendBalance() { const addr = currentAddress(); if (!addr) return; const title = addressTitle(addr.address, state.wallets); $("send-from").innerHTML = renderAddressHtml(addr.address, { title, ensName: addr.ensName, }); attachCopyHandlers($("send-from")); const token = state.selectedToken || $("send-token").value; if (token === "ETH") { $("send-balance").textContent = "Current balance: " + (addr.balance || "0") + " ETH"; } else { const tb = (addr.tokenBalances || []).find( (t) => t.address.toLowerCase() === token.toLowerCase(), ); const symbol = resolveSymbol( token, addr.tokenBalances, state.trackedTokens, ); const bal = tb ? tb.balance || "0" : "0"; $("send-balance").textContent = "Current balance: " + bal + " " + symbol; } } function init(_ctx) { ctx = _ctx; $("send-token").addEventListener("change", updateSendBalance); // Initial state: disable review button until address is entered $("btn-send-review").disabled = true; $("btn-send-review").classList.add("opacity-50"); // Validate address on input $("send-to").addEventListener("input", updateToValidation); $("btn-send-review").addEventListener("click", async () => { const to = $("send-to").value.trim(); const amount = $("send-amount").value.trim(); if (!to) { showFlash("Please enter a recipient address."); return; } // Re-validate before proceeding const validation = validateToAddress(to); if (!validation.valid) { showFlash( validation.error || "Please enter a valid Ethereum address.", ); return; } if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { showFlash("Please enter a valid amount."); return; } // Resolve ENS if needed let resolvedTo = to; let ensName = null; if (to.includes(".") && !to.startsWith("0x")) { try { const provider = getProvider(state.rpcUrl); const resolved = await provider.resolveName(to); if (!resolved) { showFlash("Could not resolve " + to); return; } resolvedTo = resolved; ensName = to; } catch (e) { showFlash("Failed to resolve ENS name."); return; } } const token = state.selectedToken || $("send-token").value; const addr = currentAddress(); let tokenSymbol = null; let tokenBalance = null; if (token !== "ETH") { const tb = (addr.tokenBalances || []).find( (t) => t.address.toLowerCase() === token.toLowerCase(), ); tokenSymbol = resolveSymbol( token, addr.tokenBalances, state.trackedTokens, ); tokenBalance = tb ? tb.balance || "0" : "0"; } ctx.showConfirmTx({ from: addr.address, to: resolvedTo, ensName: ensName, amount: amount, token: token, balance: addr.balance, tokenSymbol: tokenSymbol, tokenBalance: tokenBalance, }); }); $("btn-send-back").addEventListener("click", () => { $("send-token").classList.remove("hidden"); $("send-token-static").classList.add("hidden"); goBack(); }); } function resetSendValidation() { const errorEl = $("send-to-error"); const btn = $("btn-send-review"); if (errorEl) errorEl.textContent = ""; if (btn) { btn.disabled = true; btn.classList.add("opacity-50"); } } module.exports = { init, updateSendBalance, renderSendTokenSelect, resetSendValidation, };