All checks were successful
check / check (push) Successful in 22s
Replace the modal overlay password dialog in the confirm-tx view with an inline password field, matching the pattern used by approve-tx and approve-sign views for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
11 KiB
JavaScript
344 lines
11 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,
|
|
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 { isScamAddress } = require("../../shared/scamlist");
|
|
const { ERC20_ABI } = require("../../shared/constants");
|
|
const { log } = require("../../shared/log");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const txStatus = require("./txStatus");
|
|
|
|
const EXT_ICON =
|
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
|
`</svg></span>`;
|
|
|
|
let pendingTx = null;
|
|
|
|
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 `<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);
|
|
const dot = addressDotHtml(address);
|
|
const link = etherscanAddressLink(address);
|
|
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
|
let html = `<div class="mb-1">${blockie}</div>`;
|
|
if (title) {
|
|
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
|
}
|
|
if (ensName) {
|
|
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
|
}
|
|
html +=
|
|
`<div class="flex items-center">${title || ensName ? "" : dot}` +
|
|
`<span class="break-all">${escapeHtml(address)}</span>` +
|
|
extLink +
|
|
`</div>`;
|
|
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 link = etherscanTokenLink(txInfo.token);
|
|
$("confirm-token-contract").innerHTML =
|
|
escapeHtml(txInfo.token) +
|
|
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
|
|
tokenSection.classList.remove("hidden");
|
|
} 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
|
|
const warnings = [];
|
|
if (isScamAddress(txInfo.to)) {
|
|
warnings.push(
|
|
"This address is on a known scam/fraud list. Do not send funds to this address.",
|
|
);
|
|
}
|
|
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
|
|
warnings.push("You are sending to your own address.");
|
|
}
|
|
|
|
const warningsEl = $("confirm-warnings");
|
|
if (warnings.length > 0) {
|
|
warningsEl.innerHTML = warnings
|
|
.map(
|
|
(w) =>
|
|
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
|
|
)
|
|
.join("");
|
|
warningsEl.classList.remove("hidden");
|
|
} else {
|
|
warningsEl.classList.add("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) => `<div class="text-xs">${e}</div>`)
|
|
.join("");
|
|
errorsEl.classList.remove("hidden");
|
|
sendBtn.disabled = true;
|
|
sendBtn.classList.add("text-muted");
|
|
} else {
|
|
errorsEl.classList.add("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").classList.remove("hidden");
|
|
$("confirm-fee-amount").textContent = "Estimating...";
|
|
showView("confirm-tx");
|
|
|
|
estimateGas(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";
|
|
}
|
|
}
|
|
|
|
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 };
|