Enhance confirm transaction page with full verification details
All checks were successful
check / check (push) Successful in 17s
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:
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user