Some checks failed
check / check (push) Has been cancelled
A rejected password was reported three different ways depending on which screen you were on, including the fragment "Wrong password." which is not a sentence. All six decryptWithPassword call sites now show the same full sentence. Strings only -- a wrong password still fails closed on every screen and still resolves no pending approval. A test pins the invariant per call site: each decryptWithPassword call is walked out to its enclosing try and forward to that block's catch, and the prose shown there must equal the canonical sentence. Per-file matching was not enough, since a file with two call sites kept passing while one of them diverged.
482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
// Transaction confirmation view with inline password.
|
|
// Shows transaction details, warnings, errors. On Sign & Send,
|
|
// reads inline password, decrypts secret, signs and broadcasts.
|
|
|
|
const {
|
|
parseEther,
|
|
parseUnits,
|
|
formatEther,
|
|
formatUnits,
|
|
Contract,
|
|
} = require("ethers");
|
|
const {
|
|
$,
|
|
showError,
|
|
hideError,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
addressTitle,
|
|
escapeHtml,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
goBack,
|
|
onViewLeave,
|
|
} = require("./helpers");
|
|
const { state, currentNetwork } = require("../../shared/state");
|
|
const { getSignerForAddress } = require("../../shared/wallet");
|
|
const { decryptWithPassword } = require("../../shared/vault");
|
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
|
const { getProvider } = require("../../shared/balances");
|
|
const {
|
|
getLocalWarnings,
|
|
getFullWarnings,
|
|
} = require("../../shared/addressWarnings");
|
|
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
|
const {
|
|
CODES,
|
|
FEE_PENDING,
|
|
FEE_KNOWN,
|
|
FEE_UNAVAILABLE,
|
|
feeReserveWei,
|
|
feeEstimateWei,
|
|
validateTransfer,
|
|
} = require("../../shared/txValidation");
|
|
const { log } = require("../../shared/log");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const txStatus = require("./txStatus");
|
|
|
|
let pendingTx = null;
|
|
// Network fee for the transaction currently on screen. Reset by show() and
|
|
// filled in by estimateGas() when the estimate resolves or fails.
|
|
let feeStatus = FEE_PENDING;
|
|
let feeWei = null;
|
|
|
|
function restore() {
|
|
const d = state.viewData;
|
|
if (d && d.pendingTx) {
|
|
show(d.pendingTx);
|
|
}
|
|
}
|
|
|
|
function blockieHtml(address) {
|
|
const src = makeBlockie(address);
|
|
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
|
}
|
|
|
|
function confirmAddressHtml(address, ensName, title) {
|
|
const blockie = blockieHtml(address);
|
|
return (
|
|
`<div class="mb-1">${blockie}</div>` +
|
|
renderAddressHtml(address, { title, ensName })
|
|
);
|
|
}
|
|
|
|
function valueWithUsd(text, usdAmount) {
|
|
if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) {
|
|
return text + " (" + formatUsd(usdAmount) + ")";
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function show(txInfo) {
|
|
pendingTx = txInfo;
|
|
feeStatus = FEE_PENDING;
|
|
feeWei = null;
|
|
|
|
const isErc20 = txInfo.token !== "ETH";
|
|
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
|
|
|
// Transaction type
|
|
if (isErc20) {
|
|
$("confirm-type").textContent =
|
|
"ERC-20 token transfer (" + symbol + ")";
|
|
} else {
|
|
$("confirm-type").textContent = "Native ETH transfer";
|
|
}
|
|
|
|
// Token contract section (ERC-20 only)
|
|
const tokenSection = $("confirm-token-section");
|
|
if (isErc20) {
|
|
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
|
txInfo.token,
|
|
{},
|
|
);
|
|
tokenSection.classList.remove("hidden");
|
|
attachCopyHandlers(tokenSection);
|
|
} else {
|
|
tokenSection.classList.add("hidden");
|
|
}
|
|
|
|
// From (with blockie)
|
|
const fromTitle = addressTitle(txInfo.from, state.wallets);
|
|
$("confirm-from").innerHTML = confirmAddressHtml(
|
|
txInfo.from,
|
|
null,
|
|
fromTitle,
|
|
);
|
|
|
|
// To (with blockie)
|
|
const toTitle = addressTitle(txInfo.to, state.wallets);
|
|
$("confirm-to").innerHTML = confirmAddressHtml(
|
|
txInfo.to,
|
|
txInfo.ensName,
|
|
toTitle,
|
|
);
|
|
$("confirm-to-ens").classList.add("hidden");
|
|
|
|
// Amount (with inline USD)
|
|
const ethPrice = getPrice("ETH");
|
|
const tokenPrice = getPrice(symbol);
|
|
const amountNum = parseFloat(txInfo.amount);
|
|
const price = isErc20 ? tokenPrice : ethPrice;
|
|
const amountUsd = price ? amountNum * price : null;
|
|
$("confirm-amount").textContent = valueWithUsd(
|
|
txInfo.amount + " " + symbol,
|
|
amountUsd,
|
|
);
|
|
|
|
// Balance (with inline USD)
|
|
if (isErc20) {
|
|
const bal = txInfo.tokenBalance || "0";
|
|
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
|
$("confirm-balance").textContent = valueWithUsd(
|
|
bal + " " + symbol,
|
|
balUsd,
|
|
);
|
|
} else {
|
|
const bal = txInfo.balance || "0";
|
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
|
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
|
}
|
|
|
|
// Check for warnings (synchronous local checks)
|
|
const localWarnings = getLocalWarnings(txInfo.to, {
|
|
fromAddress: txInfo.from,
|
|
});
|
|
|
|
const warningsEl = $("confirm-warnings");
|
|
if (localWarnings.length > 0) {
|
|
warningsEl.innerHTML = localWarnings
|
|
.map(
|
|
(w) =>
|
|
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
|
|
)
|
|
.join("");
|
|
warningsEl.style.visibility = "visible";
|
|
} else {
|
|
warningsEl.innerHTML = "";
|
|
warningsEl.style.visibility = "hidden";
|
|
}
|
|
|
|
// The two fee messages are mutually exclusive per transaction type, and
|
|
// the type is known here, before the first paint. Drop the one that can
|
|
// never apply and reserve the space of the one that can, so the async
|
|
// estimate landing later never moves anything.
|
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
|
|
|
renderValidation(txInfo);
|
|
|
|
// Reset password field and error
|
|
$("confirm-tx-password").value = "";
|
|
hideError("confirm-tx-password-error");
|
|
|
|
// Gas estimate — show placeholder then fetch async
|
|
$("confirm-fee").style.visibility = "visible";
|
|
$("confirm-fee-amount").textContent = "Estimating...";
|
|
setVisible("confirm-fee-reserve", false);
|
|
state.viewData = { pendingTx: txInfo };
|
|
showView("confirm-tx");
|
|
attachCopyHandlers("view-confirm-tx");
|
|
|
|
// Reset async warnings to hidden (space always reserved, no layout shift)
|
|
$("confirm-recipient-warning").style.visibility = "hidden";
|
|
$("confirm-contract-warning").style.visibility = "hidden";
|
|
$("confirm-burn-warning").style.visibility = "hidden";
|
|
$("confirm-etherscan-warning").style.visibility = "hidden";
|
|
|
|
// Show burn warning via reserved element (in addition to inline warning)
|
|
if (isBurnAddress(txInfo.to)) {
|
|
$("confirm-burn-warning").style.visibility = "visible";
|
|
}
|
|
|
|
estimateGas(txInfo);
|
|
checkRecipientHistory(txInfo);
|
|
}
|
|
|
|
// Render the balance check for the transaction on screen. Called once during
|
|
// show() and again when the fee estimate resolves or fails. Every element it
|
|
// touches already occupies its space, so re-running it never moves anything.
|
|
function renderValidation(txInfo) {
|
|
const isErc20 = txInfo.token !== "ETH";
|
|
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
|
|
|
const { canSend, codes } = validateTransfer({
|
|
isErc20,
|
|
amount: txInfo.amount,
|
|
ethBalance: txInfo.balance,
|
|
tokenBalance: txInfo.tokenBalance,
|
|
feeStatus,
|
|
feeWei,
|
|
});
|
|
|
|
// Messages carrying the user's own numbers are built here; the fixed
|
|
// sentences live in the reserved elements in index.html.
|
|
const messages = [];
|
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
|
messages.push("Please enter a valid amount to send.");
|
|
}
|
|
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
|
messages.push(
|
|
"Insufficient " +
|
|
symbol +
|
|
" balance. You have " +
|
|
txInfo.tokenBalance +
|
|
" " +
|
|
symbol +
|
|
" but are trying to send " +
|
|
txInfo.amount +
|
|
" " +
|
|
symbol +
|
|
".",
|
|
);
|
|
}
|
|
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
|
messages.push(
|
|
"Insufficient balance. You have " +
|
|
txInfo.balance +
|
|
" ETH but are trying to send " +
|
|
txInfo.amount +
|
|
" ETH.",
|
|
);
|
|
}
|
|
|
|
const errorsEl = $("confirm-errors");
|
|
if (messages.length > 0) {
|
|
errorsEl.innerHTML = messages
|
|
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
|
|
.join("");
|
|
errorsEl.style.visibility = "visible";
|
|
} else {
|
|
errorsEl.innerHTML = "";
|
|
errorsEl.style.visibility = "hidden";
|
|
}
|
|
|
|
setVisible(
|
|
"confirm-amount-fee-error",
|
|
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
|
);
|
|
setVisible(
|
|
"confirm-gas-error",
|
|
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
|
);
|
|
setVisible(
|
|
"confirm-fee-unknown-error",
|
|
codes.includes(CODES.FEE_UNAVAILABLE),
|
|
);
|
|
|
|
// While the estimate is in flight there is no error to show — the fee
|
|
// line already reads "Estimating..." — but sending stays blocked so a
|
|
// transaction the fee would break cannot be signed in the meantime.
|
|
const sendBtn = $("btn-confirm-send");
|
|
sendBtn.disabled = !canSend;
|
|
sendBtn.classList.toggle("text-muted", !canSend);
|
|
}
|
|
|
|
function setVisible(id, visible) {
|
|
$(id).style.visibility = visible ? "visible" : "hidden";
|
|
}
|
|
|
|
// A fee in wei as an ETH string, truncated to 6 decimal places.
|
|
function formatFeeEth(wei) {
|
|
const parts = formatEther(wei).split(".");
|
|
const dec =
|
|
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
|
return parts[0] + "." + dec + " ETH";
|
|
}
|
|
|
|
async function estimateGas(txInfo) {
|
|
try {
|
|
const provider = getProvider(state.rpcUrl);
|
|
const feeData = await provider.getFeeData();
|
|
let gasLimit;
|
|
|
|
if (txInfo.token === "ETH") {
|
|
gasLimit = await provider.estimateGas({
|
|
from: txInfo.from,
|
|
to: txInfo.to,
|
|
value: parseEther(txInfo.amount),
|
|
});
|
|
} else {
|
|
const contract = new Contract(txInfo.token, ERC20_ABI, provider);
|
|
const decimals = await contract.decimals();
|
|
const amount = parseUnits(txInfo.amount, decimals);
|
|
gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, {
|
|
from: txInfo.from,
|
|
});
|
|
}
|
|
|
|
// What the node will require to be reserved, which is what the gate
|
|
// must be: the send pins no fee fields, so it is broadcast as a
|
|
// type-2 transaction priced at maxFeePerGas.
|
|
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
|
if (gasCostWei === null) {
|
|
throw new Error("no usable gas price from the provider");
|
|
}
|
|
// What the transaction is expected to cost, which is a different and
|
|
// usually much smaller number. Both are shown: quoting only the
|
|
// reserve overstates the typical cost by roughly double on mainnet,
|
|
// and quoting only the estimate contradicts the balance check.
|
|
const estimateWei = feeEstimateWei(gasLimit, feeData);
|
|
// The user may have left this transaction while the estimate was in
|
|
// flight; a stale fee must not reach the screen or the balance check.
|
|
if (pendingTx !== txInfo) return;
|
|
|
|
const ethPrice = getPrice("ETH");
|
|
const usd = (wei) =>
|
|
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
|
|
|
if (estimateWei !== null && estimateWei < gasCostWei) {
|
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
|
"~" + formatFeeEth(estimateWei),
|
|
usd(estimateWei),
|
|
);
|
|
$("confirm-fee-reserve").textContent =
|
|
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
|
setVisible("confirm-fee-reserve", true);
|
|
} else {
|
|
// No spread to report: either there is no estimate, or the node
|
|
// quotes a gas price at or above maxFeePerGas, so the expected
|
|
// cost is not below the reserve. Show the reserve alone.
|
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
|
formatFeeEth(gasCostWei),
|
|
usd(gasCostWei),
|
|
);
|
|
setVisible("confirm-fee-reserve", false);
|
|
}
|
|
feeStatus = FEE_KNOWN;
|
|
feeWei = gasCostWei;
|
|
renderValidation(txInfo);
|
|
} catch (e) {
|
|
log.errorf("gas estimation failed:", e.message);
|
|
if (pendingTx !== txInfo) return;
|
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
|
setVisible("confirm-fee-reserve", false);
|
|
feeStatus = FEE_UNAVAILABLE;
|
|
feeWei = null;
|
|
renderValidation(txInfo);
|
|
}
|
|
}
|
|
|
|
async function checkRecipientHistory(txInfo) {
|
|
try {
|
|
const provider = getProvider(state.rpcUrl);
|
|
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
|
fromAddress: txInfo.from,
|
|
});
|
|
for (const w of asyncWarnings) {
|
|
if (w.type === "contract") {
|
|
$("confirm-contract-warning").style.visibility = "visible";
|
|
}
|
|
if (w.type === "new-address") {
|
|
$("confirm-recipient-warning").style.visibility = "visible";
|
|
}
|
|
if (w.type === "etherscan-phishing") {
|
|
$("confirm-etherscan-warning").style.visibility = "visible";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log.errorf("recipient history check failed:", e.message);
|
|
}
|
|
}
|
|
|
|
// Drop the password from the DOM. Registered as the view-leave handler so
|
|
// it does not sit in the hidden view once the screen navigates on — to the
|
|
// wait screen after a send, or anywhere else the user goes.
|
|
function clearPassword() {
|
|
$("confirm-tx-password").value = "";
|
|
hideError("confirm-tx-password-error");
|
|
}
|
|
|
|
function init(ctx) {
|
|
onViewLeave("confirm-tx", clearPassword);
|
|
|
|
$("btn-confirm-send").addEventListener("click", async () => {
|
|
const password = $("confirm-tx-password").value;
|
|
if (!password) {
|
|
showError(
|
|
"confirm-tx-password-error",
|
|
"Please enter your password.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const wallet = state.wallets[state.selectedWallet];
|
|
let decryptedSecret;
|
|
hideError("confirm-tx-password-error");
|
|
|
|
try {
|
|
decryptedSecret = await decryptWithPassword(
|
|
wallet.encryptedSecret,
|
|
password,
|
|
);
|
|
} catch (e) {
|
|
showError(
|
|
"confirm-tx-password-error",
|
|
"That password is incorrect. Please try again.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
$("btn-confirm-send").disabled = true;
|
|
$("btn-confirm-send").classList.add("text-muted");
|
|
|
|
let tx;
|
|
try {
|
|
const signer = getSignerForAddress(
|
|
wallet,
|
|
state.selectedAddress,
|
|
decryptedSecret,
|
|
);
|
|
const provider = getProvider(state.rpcUrl);
|
|
const connectedSigner = signer.connect(provider);
|
|
|
|
if (pendingTx.token === "ETH") {
|
|
tx = await connectedSigner.sendTransaction({
|
|
to: pendingTx.to,
|
|
value: parseEther(pendingTx.amount),
|
|
});
|
|
} else {
|
|
const contract = new Contract(
|
|
pendingTx.token,
|
|
ERC20_ABI,
|
|
connectedSigner,
|
|
);
|
|
const decimals = await contract.decimals();
|
|
const amount = parseUnits(pendingTx.amount, decimals);
|
|
tx = await contract.transfer(pendingTx.to, amount);
|
|
}
|
|
|
|
// Best-effort: clear decrypted secret after use.
|
|
// Note: JS strings are immutable; this nulls the reference but
|
|
// the original string may persist in memory until GC.
|
|
decryptedSecret = null;
|
|
txStatus.showWait(pendingTx, tx.hash);
|
|
} catch (e) {
|
|
decryptedSecret = null;
|
|
const hash = tx ? tx.hash : null;
|
|
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
|
} finally {
|
|
$("btn-confirm-send").disabled = false;
|
|
$("btn-confirm-send").classList.remove("text-muted");
|
|
}
|
|
});
|
|
|
|
$("btn-confirm-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show, restore };
|