// 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,
addressDotHtml,
escapeHtml,
} = require("./helpers");
const { state } = 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 { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
const EXT_ICON =
`` +
``;
let pendingTx = null;
function restore() {
const d = state.viewData;
if (d && d.pendingTx) {
show(d.pendingTx);
}
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function etherscanAddressLink(address) {
return `https://etherscan.io/address/${address}`;
}
function blockieHtml(address) {
const src = makeBlockie(address);
return `
`;
}
function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = etherscanAddressLink(address);
const extLink = `${EXT_ICON}`;
let html = `
${blockie}
`;
if (title) {
html += `${dot}${escapeHtml(title)}
`;
}
if (ensName) {
html += `${title ? "" : dot}${escapeHtml(ensName)}
`;
}
html +=
`${title || ensName ? "" : dot}` +
`${escapeHtml(address)}` +
extLink +
`
`;
return html;
}
function valueWithUsd(text, usdAmount) {
if (usdAmount !== null && usdAmount !== undefined && !isNaN(usdAmount)) {
return text + " (" + formatUsd(usdAmount) + ")";
}
return text;
}
function show(txInfo) {
pendingTx = txInfo;
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) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(txInfo.token);
$("confirm-token-contract").innerHTML =
`${dot}` +
`
${escapeHtml(txInfo.token)}` +
`
${EXT_ICON}` +
`
`;
tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
};
}
} 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) =>
`WARNING: ${w.message}
`,
)
.join("");
warningsEl.style.visibility = "visible";
} else {
warningsEl.innerHTML = "";
warningsEl.style.visibility = "hidden";
}
// Check for errors
const errors = [];
if (isErc20) {
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
if (parseFloat(txInfo.amount) > tokenBal) {
errors.push(
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
errors.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
);
}
const errorsEl = $("confirm-errors");
const sendBtn = $("btn-confirm-send");
if (errors.length > 0) {
errorsEl.innerHTML = errors
.map((e) => `${e}
`)
.join("");
errorsEl.style.visibility = "visible";
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
// 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...";
state.viewData = { pendingTx: txInfo };
showView("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);
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice;
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,
});
}
const gasCostWei = gasLimit * gasPrice;
const gasCostEth = formatEther(gasCostWei);
// Format to 6 significant decimal places
const parts = gasCostEth.split(".");
const dec =
parts.length > 1
? parts[1].slice(0, 6).replace(/0+$/, "") || "0"
: "0";
const feeStr = parts[0] + "." + dec + " ETH";
const ethPrice = getPrice("ETH");
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null;
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd);
} catch (e) {
log.errorf("gas estimation failed:", e.message);
$("confirm-fee-amount").textContent = "Unable to estimate";
}
}
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);
}
}
function init(ctx) {
$("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", "Wrong password.");
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", () => {
showView("send");
});
}
module.exports = { init, show, restore };