bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it, with the chain id of the transaction, balance or history entry it draws. The token sources the screens build carry that chain id. The Uniswap decoder sets the transaction's chain id on them. fetchTokenBalances() takes the chain id of the network it asks about. isSpoofedSymbol() takes the chain id of the token's network. It checks the bundled list's symbols only on Ethereum Mainnet. The native asset's labels stay reserved on every network. Twenty existing tests and four shared test helpers read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5
471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
// Send view: collect To, Amount, Token. Then go to confirmation.
|
|
|
|
const {
|
|
$,
|
|
showFlash,
|
|
addressTitle,
|
|
displaySymbol,
|
|
escapeHtml,
|
|
nativeCurrency,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
tokenSourcesByChainId,
|
|
} = require("./helpers");
|
|
const { state, currentAddress, currentNetwork } = 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) {
|
|
// Only Blockscout reports the tokens the user has not tracked.
|
|
$("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
|
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, currentNetwork().chainId)) {
|
|
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. As on
|
|
// the approval screens, tokenSourcesByChainId() gives the wallets only while
|
|
// "Use Blockscout" is on.
|
|
//
|
|
// 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. A token with no row whose tracked
|
|
// record carries `balanceNotFetched` gets null instead, because no refresh has
|
|
// read its balance.
|
|
function tokenBalanceAndDecimals(addr, token) {
|
|
const tb = (addr.tokenBalances || []).find(
|
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
);
|
|
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
|
|
if (!tb) {
|
|
const tracked = state.trackedTokens.find(
|
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
);
|
|
const notRead = tracked && tracked.balanceNotFetched;
|
|
return { tokenBalance: notRead ? null : "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,
|
|
currentNetwork().chainId,
|
|
);
|
|
// 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,
|
|
currentNetwork().chainId,
|
|
);
|
|
({ 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);
|
|
// getProvider() does not look up a name resolved off-chain,
|
|
// so such a name comes back with no address too. The message
|
|
// covers both, in the one line the flash area shows.
|
|
if (!resolved) {
|
|
showFlash("That ENS name has no on-chain 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-dropdown").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,
|
|
};
|