Add dedicated wait/success/error screens for transaction status

After broadcast, the user is taken to a full-screen wait view showing
the amount, recipient, tx hash (copyable + etherscan link), and a
count-up timer. The view polls every 10 seconds for confirmation.

On confirmation: navigates to success screen showing block number,
tx hash, and a Done button that returns to the address view.

On 60-second timeout or error: navigates to error screen with the
failure message, tx hash (if available), and Done button.

Replaces the previous inline confirm-status div that was crammed
onto the confirmation page.
This commit is contained in:
2026-02-27 12:06:32 +07:00
parent e58f113cda
commit d229000258
5 changed files with 238 additions and 66 deletions

View File

@@ -494,10 +494,80 @@
>
Send
</button>
<div
id="confirm-status"
class="mt-2 border border-border p-1 hidden"
></div>
</div>
<!-- ============ WAIT FOR TX CONFIRMATION ============ -->
<div id="view-wait-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Broadcast</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="wait-tx-to" class="text-xs break-all"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="wait-tx-hash" class="text-xs break-all"></div>
</div>
<div id="wait-tx-status" class="text-xs text-muted"></div>
</div>
<!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Confirmed</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="success-tx-to" class="text-xs break-all"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Block</div>
<div id="success-tx-block" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="success-tx-hash" class="text-xs break-all"></div>
</div>
<button
id="btn-success-tx-done"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Done
</button>
</div>
<!-- ============ TX ERROR ============ -->
<div id="view-error-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Failed</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="error-tx-to" class="text-xs break-all"></div>
</div>
<div class="mb-3">
<div
id="error-tx-message"
class="text-xs border border-border border-dashed p-2"
></div>
</div>
<div id="error-tx-hash-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="error-tx-hash" class="text-xs break-all"></div>
</div>
<button
id="btn-error-tx-done"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Done
</button>
</div>
<!-- ============ PASSWORD MODAL ============ -->

View File

@@ -15,6 +15,7 @@ const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
const confirmTx = require("./views/confirmTx");
const txStatus = require("./views/txStatus");
const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
@@ -111,6 +112,7 @@ async function init() {
addressToken.init(ctx);
send.init(ctx);
confirmTx.init(ctx);
txStatus.init(ctx);
receive.init(ctx);
addToken.init(ctx);
settings.init(ctx);

View File

@@ -27,6 +27,7 @@ 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">` +
@@ -36,7 +37,6 @@ const EXT_ICON =
`</svg></span>`;
let pendingTx = null;
let elapsedTimer = null;
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
@@ -217,7 +217,6 @@ function show(txInfo) {
// Gas estimate — show placeholder then fetch async
$("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating...";
$("confirm-status").classList.add("hidden");
showView("confirm-tx");
estimateGas(txInfo);
@@ -309,10 +308,7 @@ function init(ctx) {
hidePasswordModal();
const statusEl = $("confirm-status");
statusEl.textContent = "Sending...";
statusEl.classList.remove("hidden");
let tx;
try {
const signer = getSignerForAddress(
wallet,
@@ -322,7 +318,6 @@ function init(ctx) {
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
let tx;
if (pendingTx.token === "ETH") {
tx = await connectedSigner.sendTransaction({
to: pendingTx.to,
@@ -339,62 +334,10 @@ function init(ctx) {
tx = await contract.transfer(pendingTx.to, amount);
}
// Disable send button immediately after broadcast
const sendBtn = $("btn-confirm-send");
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
// Show etherscan link and elapsed timer
const broadcastTime = Date.now();
statusEl.innerHTML = "";
statusEl.appendChild(
document.createTextNode(
"Broadcast. Waiting for confirmation... ",
),
);
const timerSpan = document.createElement("span");
timerSpan.textContent = "(0s)";
statusEl.appendChild(timerSpan);
statusEl.appendChild(document.createElement("br"));
const txLink = document.createElement("a");
txLink.href = "https://etherscan.io/tx/" + tx.hash;
txLink.target = "_blank";
txLink.rel = "noopener";
txLink.className = "underline decoration-dashed break-all";
txLink.textContent = tx.hash;
statusEl.appendChild(document.createTextNode("Tx: "));
statusEl.appendChild(txLink);
if (elapsedTimer) clearInterval(elapsedTimer);
elapsedTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
timerSpan.textContent = "(" + elapsed + "s)";
}, 1000);
const receipt = await tx.wait();
clearInterval(elapsedTimer);
elapsedTimer = null;
statusEl.innerHTML = "";
statusEl.appendChild(
document.createTextNode(
"Confirmed in block " + receipt.blockNumber + ". Tx: ",
),
);
const link = document.createElement("a");
link.href = "https://etherscan.io/tx/" + receipt.hash;
link.target = "_blank";
link.rel = "noopener";
link.className = "underline decoration-dashed break-all";
link.textContent = receipt.hash;
statusEl.appendChild(link);
ctx.doRefreshAndRender();
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
}
});
}

View File

@@ -16,6 +16,9 @@ const VIEWS = [
"address-token",
"send",
"confirm-tx",
"wait-tx",
"success-tx",
"error-tx",
"receive",
"add-token",
"settings",

154
src/popup/views/txStatus.js Normal file
View File

@@ -0,0 +1,154 @@
// Post-broadcast transaction status views: wait, success, error.
const {
$,
showView,
showFlash,
addressDotHtml,
escapeHtml,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
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 ctx;
let elapsedTimer = null;
let pollTimer = null;
function clearTimers() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer break-all" data-copy="${escapeHtml(hash)}">${escapeHtml(hash)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) {
document
.getElementById(viewId)
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
}
function showWait(txInfo, txHash) {
clearTimers();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
const broadcastTime = Date.now();
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
elapsedTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
$("wait-tx-status").textContent =
"Waiting for confirmation... " + elapsed + "s";
}, 1000);
const provider = getProvider(state.rpcUrl);
pollTimer = setInterval(async () => {
try {
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
}
} catch (e) {
log.errorf("poll receipt failed:", e.message);
}
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
if (elapsed >= 60) {
showError(
txInfo,
txHash,
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
);
}
}, 10000);
showView("wait-tx");
}
function showSuccess(txInfo, txHash, blockNumber) {
clearTimers();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
$("success-tx-summary").textContent = txInfo.amount + " " + symbol;
$("success-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("success-tx-block").textContent = String(blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-success-tx");
showView("success-tx");
ctx.doRefreshAndRender();
}
function showError(txInfo, txHash, message) {
clearTimers();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
$("error-tx-summary").textContent = txInfo.amount + " " + symbol;
$("error-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("error-tx-message").textContent = message;
if (txHash) {
$("error-tx-hash").innerHTML = txHashHtml(txHash);
$("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx");
} else {
$("error-tx-hash-section").classList.add("hidden");
}
showView("error-tx");
}
function navigateBack() {
if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-success-tx-done").addEventListener("click", navigateBack);
$("btn-error-tx-done").addEventListener("click", navigateBack);
}
module.exports = { init, showWait, showError };