All checks were successful
check / check (push) Successful in 5s
Expands the confirm-tx warning system with three new warning types, all using the existing `visibility:hidden/visible` pattern from PR #98 (no animations, no layout shift). ## Changes 1. **Scam address list expanded** (7 → 652 addresses): Sourced from [MyEtherWallet/ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) darklist (MIT license). Checked synchronously before sending. 2. **Contract address warning**: When the recipient is a smart contract (detected via `getCode`), shows a warning that sending directly to a contract may result in permanent loss of funds. 3. **Null/burn address warning**: Detects known burn addresses (`0x0000...0000`, `0x...dead`, `0x...deadbeef`) and warns that funds are permanently destroyed. 4. **No-history warning** (existing from #98): Unchanged, still shows for EOAs with zero transaction history. All warnings use reserved-space `visibility:hidden/visible` elements — no layout shift, no animations. closes #114 Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@eeqj.de> Reviewed-on: #118 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
399 lines
13 KiB
JavaScript
399 lines
13 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,
|
|
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 =
|
|
`<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 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 `<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 dot = addressDotHtml(txInfo.token);
|
|
const link = etherscanTokenLink(txInfo.token);
|
|
$("confirm-token-contract").innerHTML =
|
|
`<div class="flex items-center">${dot}` +
|
|
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
|
|
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
|
|
`</div>`;
|
|
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) =>
|
|
`<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";
|
|
}
|
|
|
|
// 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.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 };
|