Max fills in a token's balance, cut down to the 18 decimal places the confirmation screen accepts, or for ETH the exact balance minus the fee reserve the confirmation screen's balance check gates on. An ETH fee estimate that finishes after the Send screen was left, or its address, holding, recipient or amount changed, fills nothing in. The confirmation screen works a max ETH amount out again from its own fee estimate and signs it with that estimate's fee fields, so a fee that rose before signing cannot push amount plus fee above the balance. validateTransfer() still gates every send, the check that ETH covers a token send's fee included. Where there is nothing to fill in, a flash message says why. Model: opus-5-5
449 lines
16 KiB
JavaScript
449 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,
|
|
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 and scale the Send screen states and hands the
|
|
// confirmation screen, so the two screens describe the holding the same way.
|
|
//
|
|
// 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. Stating it would leave validateTransfer() checking the amount
|
|
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
|
// scale means unknown balance. It is null rather than "0": both screens state
|
|
// 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.
|
|
// 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 bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
|
if (bal == null) {
|
|
showFlash("This token's balance is unknown.");
|
|
return;
|
|
}
|
|
const amount = maxTokenAmount(bal);
|
|
if (!(parseFloat(amount) > 0)) {
|
|
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) {
|
|
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;
|
|
}
|
|
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("That ENS name has no address.");
|
|
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;
|
|
let tokenDecimals = null;
|
|
if (token !== "ETH") {
|
|
tokenSymbol = resolveSymbol(
|
|
token,
|
|
addr.tokenBalances,
|
|
state.trackedTokens,
|
|
);
|
|
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
|
addr,
|
|
token,
|
|
));
|
|
}
|
|
|
|
ctx.showConfirmTx({
|
|
from: addr.address,
|
|
to: resolvedTo,
|
|
ensName: ensName,
|
|
amount: amount,
|
|
token: token,
|
|
balance: addr.balance,
|
|
tokenSymbol: tokenSymbol,
|
|
tokenBalance: tokenBalance,
|
|
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,
|
|
};
|