All checks were successful
check / check (push) Successful in 22s
Clear the error/warning text and disable the review button when entering the send view from home, address detail, or address token views. This prevents stale validation messages from persisting after leaving and returning to the send view.
295 lines
9.4 KiB
JavaScript
295 lines
9.4 KiB
JavaScript
// Send view: collect To, Amount, Token. Then go to confirmation.
|
|
|
|
const {
|
|
$,
|
|
showFlash,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
} = 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");
|
|
}
|
|
}
|
|
|
|
const EXT_ICON =
|
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
|
`</svg></span>`;
|
|
|
|
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 = '<option value="ETH">ETH</option>';
|
|
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 dot = addressDotHtml(addr.address);
|
|
const link = `https://etherscan.io/address/${addr.address}`;
|
|
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
|
const title = addressTitle(addr.address, state.wallets);
|
|
let fromHtml = "";
|
|
if (title) {
|
|
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
|
if (addr.ensName) {
|
|
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
|
|
}
|
|
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
|
|
} else if (addr.ensName) {
|
|
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
|
|
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
|
|
} else {
|
|
fromHtml += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(addr.address)}</span>${extLink}</div>`;
|
|
}
|
|
$("send-from").innerHTML = fromHtml;
|
|
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");
|
|
if (state.selectedToken) {
|
|
ctx.showAddressToken();
|
|
} else {
|
|
ctx.showAddressDetail();
|
|
}
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|