All checks were successful
check / check (push) Successful in 22s
Add blockNumberHtml() following the same pattern as txHashHtml() and
toAddressHtml(): dashed-underline click-to-copy text plus an external
Etherscan link icon (etherscan.io/block/{number}).
Closes #99
274 lines
8.8 KiB
JavaScript
274 lines
8.8 KiB
JavaScript
// Post-broadcast transaction status views: wait, success, error.
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
} = require("./helpers");
|
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
|
const { state, saveState } = 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>`;
|
|
const title = addressTitle(address, state.wallets);
|
|
if (title) {
|
|
return (
|
|
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
|
|
`<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
|
|
extLink
|
|
);
|
|
}
|
|
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
|
|
}
|
|
|
|
function blockNumberHtml(blockNumber) {
|
|
const num = String(blockNumber);
|
|
const link = `https://etherscan.io/block/${num}`;
|
|
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" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
|
|
extLink
|
|
);
|
|
}
|
|
|
|
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 || "?";
|
|
state.viewData = {
|
|
amount: txInfo.amount,
|
|
symbol: symbol,
|
|
to: txInfo.to,
|
|
hash: txHash,
|
|
blockNumber: blockNumber,
|
|
decoded: txInfo.decoded || null,
|
|
};
|
|
renderSuccess();
|
|
ctx.doRefreshAndRender();
|
|
}
|
|
|
|
function tokenLabel(address) {
|
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
|
return t ? t.symbol : null;
|
|
}
|
|
|
|
function etherscanTokenLink(address) {
|
|
return `https://etherscan.io/token/${address}`;
|
|
}
|
|
|
|
function decodedDetailsHtml(decoded) {
|
|
if (!decoded || !decoded.details) return "";
|
|
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
|
if (decoded.name) {
|
|
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
|
|
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
|
|
}
|
|
if (decoded.description) {
|
|
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
|
|
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
|
|
}
|
|
for (const d of decoded.details) {
|
|
html += `<div class="mb-2">`;
|
|
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
|
if (d.address) {
|
|
if (d.isToken) {
|
|
const sym = tokenLabel(d.address) || "Unknown token";
|
|
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
|
html += toAddressHtml(d.address);
|
|
} else {
|
|
html += toAddressHtml(d.address);
|
|
}
|
|
} else {
|
|
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
|
|
function renderSuccess() {
|
|
const d = state.viewData;
|
|
if (!d || !d.hash) return;
|
|
|
|
const hasDecoded = d.decoded && d.decoded.details;
|
|
|
|
// When decoded details are present, the Amount and To are already
|
|
// shown inside the decoded well — hide the top-level duplicates.
|
|
const summarySection = $("success-tx-summary").parentElement;
|
|
const toSection = $("success-tx-to").parentElement;
|
|
if (hasDecoded) {
|
|
summarySection.classList.add("hidden");
|
|
toSection.classList.add("hidden");
|
|
} else {
|
|
summarySection.classList.remove("hidden");
|
|
toSection.classList.remove("hidden");
|
|
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
|
|
$("success-tx-to").innerHTML = toAddressHtml(d.to);
|
|
}
|
|
|
|
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
|
|
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
|
|
|
|
// Show decoded calldata details if present
|
|
const decodedEl = $("success-tx-decoded");
|
|
if (decodedEl && hasDecoded) {
|
|
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
|
|
decodedEl.classList.remove("hidden");
|
|
} else if (decodedEl) {
|
|
decodedEl.classList.add("hidden");
|
|
}
|
|
|
|
attachCopyHandlers("view-success-tx");
|
|
showView("success-tx");
|
|
}
|
|
|
|
function showError(txInfo, txHash, message) {
|
|
clearTimers();
|
|
|
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
|
state.viewData = {
|
|
amount: txInfo.amount,
|
|
symbol: symbol,
|
|
to: txInfo.to,
|
|
hash: txHash || null,
|
|
message: message,
|
|
};
|
|
renderError();
|
|
}
|
|
|
|
function renderError() {
|
|
const d = state.viewData;
|
|
if (!d || !d.message) return;
|
|
$("error-tx-summary").textContent = d.amount + " " + d.symbol;
|
|
$("error-tx-to").innerHTML = toAddressHtml(d.to);
|
|
$("error-tx-message").textContent = d.message;
|
|
|
|
if (d.hash) {
|
|
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
|
|
$("error-tx-hash-section").classList.remove("hidden");
|
|
attachCopyHandlers("view-error-tx");
|
|
} else {
|
|
$("error-tx-hash-section").classList.add("hidden");
|
|
}
|
|
|
|
showView("error-tx");
|
|
}
|
|
|
|
function isApprovalPopup() {
|
|
return new URLSearchParams(window.location.search).has("approval");
|
|
}
|
|
|
|
function navigateBack() {
|
|
if (isApprovalPopup()) {
|
|
window.close();
|
|
return;
|
|
}
|
|
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, renderSuccess, renderError };
|