Enhance confirm transaction page with full verification details
All checks were successful
check / check (push) Successful in 17s

The confirmation page now shows:
- Transaction type (Native ETH transfer vs ERC-20 token transfer)
- Full ERC-20 token contract address with etherscan link
- Token symbol throughout (not raw contract address)
- Current balance of the token being sent, with USD value
- Estimated network fee in ETH and USD (fetched async)
- USD value for ERC-20 token amounts (not just ETH)
- Insufficient balance errors for ERC-20 tokens

Also implements actual ERC-20 token transfers via the token contract's
transfer() function, rather than only supporting native ETH sends.
This commit is contained in:
2026-02-27 11:42:42 +07:00
parent 01201d54b2
commit b85eac1e75
3 changed files with 212 additions and 25 deletions

View File

@@ -441,30 +441,57 @@
< Back
</button>
<h2 class="font-bold mb-2">Confirm Transaction</h2>
<div class="mb-2">
<div class="text-xs text-muted">From</div>
<!-- transaction type -->
<div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div>
<div id="confirm-type" class="text-xs font-bold"></div>
</div>
<!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="confirm-token-contract"
class="text-xs break-all"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="confirm-from" class="text-xs break-all"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted">To</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="confirm-to" class="text-xs break-all"></div>
<div
id="confirm-to-ens"
class="text-xs text-muted hidden"
></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted">Amount</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="confirm-amount" class="font-bold"></div>
<div
id="confirm-amount-usd"
class="text-xs text-muted"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div>
<div
id="confirm-fee"
class="mb-2 text-xs text-muted hidden"
id="confirm-balance-usd"
class="text-xs text-muted"
></div>
</div>
<div id="confirm-fee" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Estimated network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div>
<div id="confirm-fee-usd" class="text-xs text-muted"></div>
</div>
<div id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-errors"

View File

@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On proceed, opens
// password modal, decrypts secret, signs and broadcasts.
const { parseEther } = require("ethers");
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const {
$,
showError,
@@ -10,6 +16,7 @@ const {
showView,
addressTitle,
formatAddressHtml,
escapeHtml,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
@@ -17,13 +24,50 @@ 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 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;
let elapsedTimer = null;
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
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
const fromTitle = addressTitle(txInfo.from, state.wallets);
$("confirm-from").innerHTML = formatAddressHtml(
txInfo.from,
@@ -31,6 +75,8 @@ function show(txInfo) {
null,
fromTitle,
);
// To
const toTitle = addressTitle(txInfo.to, state.wallets);
$("confirm-to").innerHTML = formatAddressHtml(
txInfo.to,
@@ -38,20 +84,44 @@ function show(txInfo) {
null,
toTitle,
);
// Hide the separate ENS element — it's now inline in the address display
$("confirm-to-ens").classList.add("hidden");
$("confirm-amount").textContent = txInfo.amount + " " + txInfo.token;
// Amount
$("confirm-amount").textContent = txInfo.amount + " " + symbol;
const ethPrice = getPrice("ETH");
if (txInfo.token === "ETH" && ethPrice) {
const tokenPrice = getPrice(symbol);
if (isErc20 && tokenPrice) {
const usd = parseFloat(txInfo.amount) * tokenPrice;
$("confirm-amount-usd").textContent = formatUsd(usd);
} else if (!isErc20 && ethPrice) {
const usd = parseFloat(txInfo.amount) * ethPrice;
$("confirm-amount-usd").textContent = formatUsd(usd);
} else {
$("confirm-amount-usd").textContent = "";
}
// Balance
if (isErc20) {
const bal = txInfo.tokenBalance || "0";
$("confirm-balance").textContent = bal + " " + symbol;
if (tokenPrice) {
$("confirm-balance-usd").textContent = formatUsd(
parseFloat(bal) * tokenPrice,
);
} else {
$("confirm-balance-usd").textContent = "";
}
} else {
$("confirm-balance").textContent = (txInfo.balance || "0") + " ETH";
if (ethPrice) {
$("confirm-balance-usd").textContent = formatUsd(
parseFloat(txInfo.balance || "0") * ethPrice,
);
} else {
$("confirm-balance-usd").textContent = "";
}
}
// Check for warnings
const warnings = [];
if (isScamAddress(txInfo.to)) {
@@ -78,10 +148,24 @@ function show(txInfo) {
// Check for errors
const errors = [];
if (
txInfo.token === "ETH" &&
parseFloat(txInfo.amount) > parseFloat(txInfo.balance)
) {
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 +
@@ -106,9 +190,60 @@ function show(txInfo) {
sendBtn.classList.remove("text-muted");
}
$("confirm-fee").classList.add("hidden");
// Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating...";
$("confirm-fee-usd").textContent = "";
$("confirm-status").classList.add("hidden");
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";
$("confirm-fee-amount").textContent = feeStr;
const ethPrice = getPrice("ETH");
if (ethPrice) {
$("confirm-fee-usd").textContent = formatUsd(
parseFloat(gasCostEth) * ethPrice,
);
}
} catch (e) {
log.errorf("gas estimation failed:", e.message);
$("confirm-fee-amount").textContent = "Unable to estimate";
$("confirm-fee-usd").textContent = "";
}
}
function showPasswordModal() {
@@ -169,10 +304,23 @@ function init(ctx) {
);
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({
let tx;
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);
}
// Disable send button immediately after broadcast
const sendBtn = $("btn-confirm-send");

View File

@@ -92,6 +92,16 @@ function init(_ctx) {
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = tb ? tb.symbol : "?";
tokenBalance = tb ? tb.balance || "0" : "0";
}
ctx.showConfirmTx({
from: addr.address,
to: resolvedTo,
@@ -99,6 +109,8 @@ function init(_ctx) {
amount: amount,
token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
});
});