ConfirmTx showed and checked the balance Send handed it, so a screen the popup reopened onto stated the balance as Send knew it, not as it is on the network the screen is drawn for. Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node reviewedNetworkProvider() picks for the network the transaction was reviewed for, the node the fee estimate and recipient checks ask. The fee estimate starts once the read returns. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns. Model: opus-5-5
454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
// Send view: collect To, Amount, Token. Then go to confirmation.
|
|
|
|
const {
|
|
$,
|
|
showFlash,
|
|
addressTitle,
|
|
displaySymbol,
|
|
escapeHtml,
|
|
nativeCurrency,
|
|
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,
|
|
isBelowOneMillionth,
|
|
} = require("../../shared/amountDisplay");
|
|
const {
|
|
feeReserveWei,
|
|
isValidAmount,
|
|
maxEthAmount,
|
|
maxTokenAmount,
|
|
} = require("../../shared/txValidation");
|
|
const { log } = require("../../shared/log");
|
|
const { getAddress, parseEther } = require("ethers");
|
|
|
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
|
|
// Whether the amount field holds what Max filled in. The confirmation screen
|
|
// re-derives a max ETH amount from its own fee estimate; typing in the field
|
|
// makes it an ordinary amount again.
|
|
let amountIsMax = false;
|
|
|
|
// Counts the times the Send screen has opened, so a Max fee estimate started
|
|
// before it was last opened fills nothing in.
|
|
let sendScreenOpenings = 0;
|
|
|
|
/**
|
|
* 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. Check the address.",
|
|
};
|
|
}
|
|
} catch {
|
|
return {
|
|
valid: false,
|
|
error: "Address checksum is invalid. 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">${escapeHtml(nativeCurrency())}</option>`;
|
|
const fraudSet = new Set(
|
|
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
|
);
|
|
for (const t of addr.tokenBalances || []) {
|
|
// A holding below 0.000001 is left out, as the balance lists leave it
|
|
// out. Its token's own screen can still send it: there
|
|
// state.selectedToken picks the token, not this list.
|
|
if (isBelowOneMillionth(t.balance)) continue;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// The token balance the Send screen states, and the scale it hands the
|
|
// confirmation screen, which reads the balance itself from the network it is
|
|
// drawn for.
|
|
//
|
|
// The scale is 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 scale encodes the transfer — it is 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). balances.js is formatting one explorer row at
|
|
// fetch time and cannot consult a state it is in the middle of replacing.
|
|
//
|
|
// 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, so it is withdrawn: unknown scale means unknown balance. It is
|
|
// null rather than "0": this screen states it as unknown, as the confirmation
|
|
// screen does, and Max fills nothing in for it. 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.
|
|
function tokenBalanceAndDecimals(addr, token) {
|
|
const tb = (addr.tokenBalances || []).find(
|
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
);
|
|
const tokenDecimals = resolveTokenDecimals(token, {
|
|
trackedTokens: state.trackedTokens,
|
|
wallets: state.wallets,
|
|
});
|
|
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
|
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
|
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
|
}
|
|
|
|
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") +
|
|
" " +
|
|
nativeCurrency();
|
|
} else {
|
|
const symbol = resolveSymbol(
|
|
token,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
// A null balance is a holding whose scale is unknown. Saying a figure
|
|
// for it would be a claim about the amount, so it reads as the
|
|
// confirmation screen's balance line reads it; the send itself is
|
|
// refused later by transferAmountUnits() for the same missing scale.
|
|
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
|
$("send-balance").textContent =
|
|
bal == null
|
|
? "Current balance: unknown (" + symbol + ")"
|
|
: "Current balance: " +
|
|
truncateAmountNeverZero(bal) +
|
|
" " +
|
|
symbol;
|
|
}
|
|
}
|
|
|
|
// Fill the amount field with the most the selected holding can send: a
|
|
// token's whole balance (cut to 18 decimal places), or for ETH the exact
|
|
// balance minus the fee reserve the confirmation screen checks against, never
|
|
// the rounded balance the screen shows. Where there is nothing to fill in, a
|
|
// flash message says why.
|
|
async function fillMaxAmount() {
|
|
const addr = currentAddress();
|
|
if (!addr) return;
|
|
const token = state.selectedToken || $("send-token").value;
|
|
|
|
if (token !== "ETH") {
|
|
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
|
|
addr,
|
|
token,
|
|
);
|
|
if (bal == null) {
|
|
showFlash("This token's balance is unknown.");
|
|
return;
|
|
}
|
|
const amount = maxTokenAmount(bal);
|
|
// The balance is stored at the token's own decimal places and cut to
|
|
// 18 above, so the only balance this refuses is zero.
|
|
if (!isValidAmount(amount, tokenDecimals)) {
|
|
showFlash("This token's balance is zero.");
|
|
return;
|
|
}
|
|
$("send-amount").value = amount;
|
|
amountIsMax = true;
|
|
return;
|
|
}
|
|
|
|
// The fee is estimated for this recipient, as the confirmation screen
|
|
// estimates it: sending to a contract can cost more gas.
|
|
const to = $("send-to").value.trim();
|
|
if (!validateToAddress(to).valid) {
|
|
showFlash("Please enter a recipient address first.");
|
|
return;
|
|
}
|
|
const typed = $("send-amount").value;
|
|
const opening = sendScreenOpenings;
|
|
let feeWei = null;
|
|
try {
|
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
|
const [feeData, gasLimit] = await Promise.all([
|
|
provider.getFeeData(),
|
|
provider.estimateGas({
|
|
from: addr.address,
|
|
to,
|
|
value: parseEther(addr.balance || "0"),
|
|
}),
|
|
]);
|
|
feeWei = feeReserveWei(gasLimit, feeData);
|
|
} catch (e) {
|
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
// src/popup/index.js.
|
|
if (ctx.pageClosed.aborted) return;
|
|
log.errorf(
|
|
"max amount fee estimate failed:",
|
|
e.shortMessage || e.message,
|
|
);
|
|
}
|
|
// While the estimate was in flight the user left the screen (and perhaps
|
|
// opened it again), typed an amount, or changed the address, the holding
|
|
// or the recipient: what they did wins.
|
|
if (
|
|
state.currentView !== "send" ||
|
|
sendScreenOpenings !== opening ||
|
|
currentAddress()?.address !== addr.address ||
|
|
(state.selectedToken || $("send-token").value) !== token ||
|
|
$("send-to").value.trim() !== to ||
|
|
$("send-amount").value !== typed
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (feeWei === null) {
|
|
showFlash("The network fee could not be estimated.");
|
|
return;
|
|
}
|
|
const amount = maxEthAmount(addr.balance, feeWei);
|
|
if (amount === null) {
|
|
showFlash("Your balance does not cover the network fee.");
|
|
return;
|
|
}
|
|
$("send-amount").value = amount;
|
|
amountIsMax = true;
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
$("send-token").addEventListener("change", () => {
|
|
// A filled-in maximum is the maximum of the holding it was filled in
|
|
// for.
|
|
if (amountIsMax) {
|
|
$("send-amount").value = "";
|
|
amountIsMax = false;
|
|
}
|
|
updateSendBalance();
|
|
});
|
|
|
|
$("btn-send-max").addEventListener("click", fillMaxAmount);
|
|
$("send-amount").addEventListener("input", () => {
|
|
amountIsMax = false;
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
const token = state.selectedToken || $("send-token").value;
|
|
const addr = currentAddress();
|
|
|
|
let tokenSymbol = null;
|
|
let tokenDecimals = null;
|
|
if (token !== "ETH") {
|
|
tokenSymbol = resolveSymbol(
|
|
token,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
|
}
|
|
|
|
// The confirmation screen's own amount check, so it is never shown
|
|
// an amount it would refuse.
|
|
if (!isValidAmount(amount, tokenDecimals)) {
|
|
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("That ENS name has no address.");
|
|
return;
|
|
}
|
|
resolvedTo = resolved;
|
|
ensName = to;
|
|
} catch {
|
|
showFlash("Failed to resolve ENS name.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
ctx.showConfirmTx({
|
|
from: addr.address,
|
|
to: resolvedTo,
|
|
ensName: ensName,
|
|
amount: amount,
|
|
token: token,
|
|
tokenSymbol: tokenSymbol,
|
|
tokenDecimals: tokenDecimals,
|
|
max: amountIsMax,
|
|
});
|
|
});
|
|
|
|
$("btn-send-back").addEventListener("click", () => {
|
|
$("send-token").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
goBack();
|
|
});
|
|
}
|
|
|
|
// Called each time the Send screen opens, with its fields cleared.
|
|
function resetSendValidation() {
|
|
sendScreenOpenings++;
|
|
amountIsMax = false;
|
|
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,
|
|
};
|