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
|
< Back
|
||||||
</button>
|
</button>
|
||||||
<h2 class="font-bold mb-2">Confirm Transaction</h2>
|
<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 id="confirm-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="confirm-to" class="text-xs break-all"></div>
|
<div id="confirm-to" class="text-xs break-all"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-to-ens"
|
id="confirm-to-ens"
|
||||||
class="text-xs text-muted hidden"
|
class="text-xs text-muted hidden"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="confirm-amount" class="font-bold"></div>
|
<div id="confirm-amount" class="font-bold"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-amount-usd"
|
id="confirm-amount-usd"
|
||||||
class="text-xs text-muted"
|
class="text-xs text-muted"
|
||||||
></div>
|
></div>
|
||||||
</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
|
<div
|
||||||
id="confirm-fee"
|
id="confirm-balance-usd"
|
||||||
class="mb-2 text-xs text-muted hidden"
|
class="text-xs text-muted"
|
||||||
></div>
|
></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-warnings" class="mb-2 hidden"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-errors"
|
id="confirm-errors"
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
// Shows transaction details, warnings, errors. On proceed, opens
|
// Shows transaction details, warnings, errors. On proceed, opens
|
||||||
// password modal, decrypts secret, signs and broadcasts.
|
// password modal, decrypts secret, signs and broadcasts.
|
||||||
|
|
||||||
const { parseEther } = require("ethers");
|
const {
|
||||||
|
parseEther,
|
||||||
|
parseUnits,
|
||||||
|
formatEther,
|
||||||
|
formatUnits,
|
||||||
|
Contract,
|
||||||
|
} = require("ethers");
|
||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
showError,
|
showError,
|
||||||
@@ -10,6 +16,7 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
|
escapeHtml,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
@@ -17,13 +24,50 @@ const { decryptWithPassword } = require("../../shared/vault");
|
|||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
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 pendingTx = null;
|
||||||
let elapsedTimer = null;
|
let elapsedTimer = null;
|
||||||
|
|
||||||
|
function etherscanTokenLink(address) {
|
||||||
|
return `https://etherscan.io/token/${address}`;
|
||||||
|
}
|
||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = 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);
|
const fromTitle = addressTitle(txInfo.from, state.wallets);
|
||||||
$("confirm-from").innerHTML = formatAddressHtml(
|
$("confirm-from").innerHTML = formatAddressHtml(
|
||||||
txInfo.from,
|
txInfo.from,
|
||||||
@@ -31,6 +75,8 @@ function show(txInfo) {
|
|||||||
null,
|
null,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// To
|
||||||
const toTitle = addressTitle(txInfo.to, state.wallets);
|
const toTitle = addressTitle(txInfo.to, state.wallets);
|
||||||
$("confirm-to").innerHTML = formatAddressHtml(
|
$("confirm-to").innerHTML = formatAddressHtml(
|
||||||
txInfo.to,
|
txInfo.to,
|
||||||
@@ -38,20 +84,44 @@ function show(txInfo) {
|
|||||||
null,
|
null,
|
||||||
toTitle,
|
toTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hide the separate ENS element — it's now inline in the address display
|
|
||||||
$("confirm-to-ens").classList.add("hidden");
|
$("confirm-to-ens").classList.add("hidden");
|
||||||
|
|
||||||
$("confirm-amount").textContent = txInfo.amount + " " + txInfo.token;
|
// Amount
|
||||||
|
$("confirm-amount").textContent = txInfo.amount + " " + symbol;
|
||||||
const ethPrice = getPrice("ETH");
|
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;
|
const usd = parseFloat(txInfo.amount) * ethPrice;
|
||||||
$("confirm-amount-usd").textContent = formatUsd(usd);
|
$("confirm-amount-usd").textContent = formatUsd(usd);
|
||||||
} else {
|
} else {
|
||||||
$("confirm-amount-usd").textContent = "";
|
$("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
|
// Check for warnings
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (isScamAddress(txInfo.to)) {
|
if (isScamAddress(txInfo.to)) {
|
||||||
@@ -78,10 +148,24 @@ function show(txInfo) {
|
|||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
const errors = [];
|
const errors = [];
|
||||||
if (
|
if (isErc20) {
|
||||||
txInfo.token === "ETH" &&
|
const tokenBal = parseFloat(txInfo.tokenBalance || "0");
|
||||||
parseFloat(txInfo.amount) > parseFloat(txInfo.balance)
|
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(
|
errors.push(
|
||||||
"Insufficient balance. You have " +
|
"Insufficient balance. You have " +
|
||||||
txInfo.balance +
|
txInfo.balance +
|
||||||
@@ -106,9 +190,60 @@ function show(txInfo) {
|
|||||||
sendBtn.classList.remove("text-muted");
|
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");
|
$("confirm-status").classList.add("hidden");
|
||||||
showView("confirm-tx");
|
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() {
|
function showPasswordModal() {
|
||||||
@@ -169,10 +304,23 @@ function init(ctx) {
|
|||||||
);
|
);
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
const connectedSigner = signer.connect(provider);
|
const connectedSigner = signer.connect(provider);
|
||||||
const tx = await connectedSigner.sendTransaction({
|
|
||||||
|
let tx;
|
||||||
|
if (pendingTx.token === "ETH") {
|
||||||
|
tx = await connectedSigner.sendTransaction({
|
||||||
to: pendingTx.to,
|
to: pendingTx.to,
|
||||||
value: parseEther(pendingTx.amount),
|
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
|
// Disable send button immediately after broadcast
|
||||||
const sendBtn = $("btn-confirm-send");
|
const sendBtn = $("btn-confirm-send");
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ function init(_ctx) {
|
|||||||
const token = state.selectedToken || $("send-token").value;
|
const token = state.selectedToken || $("send-token").value;
|
||||||
const addr = currentAddress();
|
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({
|
ctx.showConfirmTx({
|
||||||
from: addr.address,
|
from: addr.address,
|
||||||
to: resolvedTo,
|
to: resolvedTo,
|
||||||
@@ -99,6 +109,8 @@ function init(_ctx) {
|
|||||||
amount: amount,
|
amount: amount,
|
||||||
token: token,
|
token: token,
|
||||||
balance: addr.balance,
|
balance: addr.balance,
|
||||||
|
tokenSymbol: tokenSymbol,
|
||||||
|
tokenBalance: tokenBalance,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user