The stored ETH balance and the send-confirm screen's fee were each cut to six decimal places, so a value below 0.000001 read as 0.0, and the fee no longer matched the approval screen's. The ETH balance is now stored exactly, and the send screen's Current balance and the send-confirm screen's balance, fee, reserve and insufficient-balance messages go through truncateAmountNeverZero(), as the approval screen does. Token balances keep their six-decimal value: it is also what leaves dust off the balance list, and keeps the string within the 18 decimals the balance check reads. Model: opus-5-5
327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
// Send view: collect To, Amount, Token. Then go to confirmation.
|
|
|
|
const {
|
|
$,
|
|
showFlash,
|
|
addressTitle,
|
|
displaySymbol,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
} = require("./helpers");
|
|
const { state, currentAddress } = require("../../shared/state");
|
|
let ctx;
|
|
const { getProvider } = require("../../shared/balances");
|
|
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
|
const { resolveSymbol } = require("../../shared/tokenList");
|
|
const { isLowHolderCount } = require("../../shared/holders");
|
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
|
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
|
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 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 (isSpoofedSymbol(t.symbol, t.address)) continue;
|
|
if (fraudSet.has(t.address.toLowerCase())) continue;
|
|
// An unknown holder count does not withhold a token the user holds:
|
|
// only a count the explorer actually reported as below the threshold
|
|
// does. Otherwise a missing field makes a real asset unspendable.
|
|
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
|
|
const opt = document.createElement("option");
|
|
opt.value = t.address;
|
|
opt.textContent = displaySymbol(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: " +
|
|
truncateAmountNeverZero(addr.balance || "0") +
|
|
" ETH";
|
|
} else {
|
|
const tb = (addr.tokenBalances || []).find(
|
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
);
|
|
const symbol = resolveSymbol(
|
|
token,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
// A null balance is a holding whose scale nothing knows. Saying "0"
|
|
// for it would be a claim about the amount; the send itself is
|
|
// refused later by transferAmountUnits() for the same missing scale.
|
|
const bal = tb ? tb.balance : "0";
|
|
$("send-balance").textContent =
|
|
bal == null
|
|
? "Current balance: unknown (" + symbol + ")"
|
|
: "Current balance: " +
|
|
truncateAmountNeverZero(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, state.networkId);
|
|
const resolved = await provider.resolveName(to);
|
|
if (!resolved) {
|
|
showFlash("Could not resolve " + to);
|
|
return;
|
|
}
|
|
resolvedTo = resolved;
|
|
ensName = to;
|
|
} catch {
|
|
showFlash("Failed to resolve ENS name.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const token = state.selectedToken || $("send-token").value;
|
|
const addr = currentAddress();
|
|
|
|
let tokenSymbol = null;
|
|
let tokenBalance = null;
|
|
// The scale the amount and the balance below are rendered at, carried
|
|
// forward so the transfer is encoded with the number the user read
|
|
// rather than with whatever the contract answers at signing time. See
|
|
// src/shared/transferAmount.js.
|
|
let tokenDecimals = null;
|
|
if (token !== "ETH") {
|
|
const tb = (addr.tokenBalances || []).find(
|
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
);
|
|
tokenSymbol = resolveSymbol(
|
|
token,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
// null carried through rather than flattened to "0": the confirm
|
|
// screen states an unknown balance as unknown, and
|
|
// validateTransfer() treats it as no balance to spend from, which
|
|
// is the fail-closed side of an amount nobody can check.
|
|
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
|
// Resolved the same way balances.js resolved the scale it
|
|
// DISPLAYED this token's balance at: bundled list, then the user's
|
|
// tracked tokens, then the explorer. The stored
|
|
// tokenBalances[].decimals is the explorer's own answer alone, so
|
|
// reading it raw carries a null forward for a token the wallet
|
|
// does know the scale of — and displayedDecimals() then throws
|
|
// inside estimateGas(), which the confirmation screen reports as
|
|
// an unestimable fee. Unsendable, over a scale that was never in
|
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
// Still null when nothing knows: no fallback.
|
|
//
|
|
// Resolved WITH `wallets`, which balances.js does not pass: that
|
|
// adds explorerDecimals()'s cross-address check, so a contract two
|
|
// addresses report different scales for answers null rather than
|
|
// picking one. That check has to apply here, because this value
|
|
// encodes a transfer; balances.js is formatting one explorer row
|
|
// at fetch time and cannot consult a state it is in the middle of
|
|
// replacing.
|
|
tokenDecimals = resolveTokenDecimals(token, {
|
|
trackedTokens: state.trackedTokens,
|
|
wallets: state.wallets,
|
|
});
|
|
// The two resolutions can therefore differ, and where they do, the
|
|
// stored `balance` is a quantity computed at a scale this screen
|
|
// has just declined to stand behind. Stating it would leave
|
|
// validateTransfer() checking the amount against a number the
|
|
// wallet does not vouch for, and — since the unknown-balance path
|
|
// is gated on the balance, not on the scale — would leave the
|
|
// fee-estimate failure as the only thing on the confirmation
|
|
// screen, which says nothing about decimals. Unknown scale means
|
|
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
|
// for a token that has no row at all is an absence of holdings,
|
|
// which is true at every scale.
|
|
if (tb && tokenDecimals === null) tokenBalance = null;
|
|
}
|
|
|
|
ctx.showConfirmTx({
|
|
from: addr.address,
|
|
to: resolvedTo,
|
|
ensName: ensName,
|
|
amount: amount,
|
|
token: token,
|
|
balance: addr.balance,
|
|
tokenSymbol: tokenSymbol,
|
|
tokenBalance: tokenBalance,
|
|
tokenDecimals: tokenDecimals,
|
|
});
|
|
});
|
|
|
|
$("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,
|
|
};
|