// Post-broadcast transaction status views: wait, success, error.
const {
$,
showView,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
explorerUrl,
displaySymbol,
clearViewStack,
tokenLabel,
blockscoutOffTokenNoteHtml,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const {
SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as
// a timeout. Both are documented in the WaitTx section of README.md.
const POLL_INTERVAL_MS = 10000;
const TIMEOUT_MS = 60000;
// How many receipt lookups may fail in a row before the wait is ended and
// the failure reported. A lookup that throws says nothing about the
// transaction, so one must not end the wait — but an RPC that never answers
// (a mistyped URL in settings is the ordinary case) must not leave the wait
// running forever either, least of all a persisted one that every popup
// open would resume. Six is 60 seconds at the poll cadence: the same
// patience the confirmation deadline gets. Any lookup that answers, with a
// receipt or with null, resets the count.
const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6;
let ctx;
let elapsedTimer = null;
let pollTimer = null;
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
// callback or an in-flight receipt lookup that outlives its wait can tell
// that it is stale and leave the current view alone. Without it, a receipt
// resolving after the wait has ended renders over whatever view replaced it.
let waitId = 0;
// End the wait on screen: stop its timers and invalidate its pending async
// work. Called on receipt, on timeout, when a new wait starts, and when the
// user navigates away.
function endWait() {
waitId++;
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`). The links below open the explorer of the network
// with `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title, chainId });
}
// A decoded record can come back from storage in any shape, so these screens
// show only the lines whose label and value are text. An address that is not
// text would throw in toAddressHtml().
function isReadableLine(d) {
return (
d &&
typeof d.label === "string" &&
typeof d.value === "string" &&
(!d.address || typeof d.address === "string")
);
}
// What a decoded line with an address shows: the address, under the token's
// symbol when the line names a token.
function decodedAddressHtml(d, chainId) {
if (!d.isToken) return toAddressHtml(d.address, chainId);
const sym = tokenLabel(d.address, chainId) || "Unknown token";
return (
`
${escapeHtml(sym)}
` +
blockscoutOffTokenNoteHtml(d.address, chainId) +
toAddressHtml(d.address, chainId)
);
}
// Each `Moved from the sender`, `Permit2 allowance` and `Allowance token` line
// of a decoded swap, for the wait and error screens to show under the Amount
// figure. That figure can leave these amounts out, so alone it can understate
// what can leave the wallet. The token line says which token an allowance
// covers, which `Unlimited` alone does not.
function movedOrAllowedHtml(decoded, chainId) {
if (!decoded || !Array.isArray(decoded.details)) return "";
let html = "";
for (const d of decoded.details) {
if (
!isReadableLine(d) ||
(d.label !== "Moved from the sender" &&
d.label !== "Permit2 allowance" &&
d.label !== "Allowance token")
)
continue;
html += ``;
html += `
${escapeHtml(d.label)}
`;
if (d.address) {
html += `
${decodedAddressHtml(d, chainId)}
`;
} else {
html += `
${escapeHtml(d.value)}
`;
}
html += `
`;
}
return html;
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function blockNumberHtml(blockNumber, chainId) {
const num = String(blockNumber);
const link = explorerUrl("block", num, chainId);
return copyableHtml(num) + etherscanLinkHtml(link);
}
// Render the wait view and start polling for the receipt. broadcastTime is
// when the transaction was broadcast, which is what the elapsed counter and
// the timeout deadline are both measured from; pollNow runs one lookup
// immediately instead of waiting a full poll interval.
function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
const id = waitId;
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. The links on all three
// screens open that network's explorer, and this screen's receipt is
// asked of that network's node, for the same reason. A null token is a
// swap whose amount was not decoded (see showTxApproval() in
// approval.js): the amount says so, and no symbol follows it.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
movedOrAllowedHtml(txInfo.decoded, txInfo.chainId);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait
// instead of silently abandoning it.
state.viewData = {
pendingWait: {
txInfo: txInfo,
hash: txHash,
broadcastTime: broadcastTime,
},
};
function renderElapsed() {
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
$("wait-tx-status").textContent =
"Waiting for confirmation... " + elapsed + "s";
}
renderElapsed();
elapsedTimer = setInterval(() => {
if (id !== waitId) return;
renderElapsed();
}, 1000);
const provider = reviewedNetworkProvider(txInfo);
let consecutiveFailures = 0;
async function poll() {
if (id !== waitId) return;
let receipt = null;
let answered = true;
try {
receipt = await provider.getTransactionReceipt(txHash);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
// A thrown lookup means "no answer this tick", not "no
// receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as
// failed — which matters most on a resumed wait, where the
// first poll is already past the deadline.
answered = false;
log.errorf("poll receipt failed:", e.shortMessage || e.message);
}
// The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view.
if (id !== waitId) return;
// Exactly one outcome per wait. A receipt wins even on the tick
// that crosses the deadline, because the transaction did confirm.
if (receipt) {
showSuccess(txInfo, txHash, receipt.blockNumber);
return;
}
if (!answered) {
consecutiveFailures++;
// The failure is the user's news, and it is a different fact
// from "the transaction did not confirm" — the chain was never
// asked. Ending the wait here is what keeps it bounded and
// gives the user a Done button to leave by.
if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) {
showError(
txInfo,
txHash,
"The network could not be reached to check this transaction — " +
MAX_CONSECUTIVE_LOOKUP_FAILURES +
" lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
);
}
// Otherwise keep polling: the next tick may answer.
return;
}
consecutiveFailures = 0;
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
showError(
txInfo,
txHash,
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
);
}
}
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
showView("wait-tx");
if (pollNow) poll();
}
function showWait(txInfo, txHash) {
startWait(txInfo, txHash, Date.now(), false);
}
// Resume a wait persisted by a previous popup session. The deadline still
// runs from the original broadcast, so a wait that has already outlived it
// resolves on the immediate first poll rather than restarting the clock.
// Returns false when there is nothing resumable to resume. Every field
// startWait() goes on to use is validated, not just the presence of the
// containers: txInfo.to reaches addressTitle(), which calls
// address.toLowerCase(), and txInfo.amount is rendered into the summary, so
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
// Everything else only compares and coalesces them, and tokenSymbol is null for
// ETH. txInfo.decoded is not checked either: see isReadableLine().
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
const w = d.pendingWait;
if (!w.hash) return false;
// typeof [] is "object", so an array passes an object check.
const info = w.txInfo;
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), an empty `to` renders as a contract creation and an empty
// amount renders harmlessly, so refusing it would abandon a wait the live
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
startWait(w.txInfo, w.hash, w.broadcastTime, true);
return true;
}
function showSuccess(txInfo, txHash, blockNumber) {
endWait();
// As on the wait screen, a null token has no symbol.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function readableLines(decoded) {
if (!decoded || !Array.isArray(decoded.details)) return [];
return decoded.details.filter(isReadableLine);
}
// The decoded action, or "" when no line of it can be read.
function decodedDetailsHtml(decoded, chainId) {
const lines = readableLines(decoded);
if (lines.length === 0) return "";
let html = ``;
if (decoded.name && typeof decoded.name === "string") {
html += `
Action
`;
html += `
${escapeHtml(decoded.name)}
`;
}
if (decoded.description && typeof decoded.description === "string") {
html += `
Description
`;
html += `
${escapeHtml(decoded.description)}
`;
}
for (const d of lines) {
html += `
`;
html += `
${escapeHtml(d.label)}
`;
if (d.address) {
html += decodedAddressHtml(d, chainId);
} else {
html += `
${escapeHtml(d.value)}
`;
}
html += `
`;
}
html += `
`;
return html;
}
function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
const decodedHtml = decodedDetailsHtml(d.decoded, d.chainId);
// Amount and To are hidden only when a readable decoded line shows the same
// thing: an `Amount` line, or a line with the address in `to`. Some swaps
// have no `Amount` line, and a saved record can have unreadable lines. An
// empty `to` is a contract creation, which no decoded line shows.
const lines = readableLines(d.decoded);
$("success-tx-summary").parentElement.classList.toggle(
"hidden",
lines.some((line) => line.label === "Amount"),
);
$("success-tx-to").parentElement.classList.toggle(
"hidden",
lines.some((line) => line.address && line.address === d.to),
);
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && decodedHtml) {
decodedEl.innerHTML = decodedHtml;
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
}
attachCopyHandlers("view-success-tx");
showView("success-tx");
}
function showError(txInfo, txHash, message) {
endWait();
// As on the wait screen, a null token has no symbol.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
token: txInfo.token,
to: txInfo.to,
hash: txHash || null,
message: message,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderError();
}
function renderError() {
const d = state.viewData;
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
movedOrAllowedHtml(d.decoded, d.chainId);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash-section").classList.remove("hidden");
} else {
$("error-tx-hash-section").classList.add("hidden");
}
attachCopyHandlers("view-error-tx");
showView("error-tx");
}
function isApprovalPopup() {
return new URLSearchParams(window.location.search).has("approval");
}
function navigateBack() {
// Nothing should still be polling by now, but leaving a view is the
// point at which its timers must be gone.
endWait();
if (isApprovalPopup()) {
window.close();
return;
}
// After a completed transaction, reset the navigation stack
// and go directly to the address view (token or detail).
// Use require() lazily to call show() without the ctx push wrapper.
clearViewStack();
state.viewStack.push("main");
if (state.selectedToken) {
state.viewStack.push("address");
require("./addressToken").show();
} else {
require("./addressDetail").show();
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-success-tx-done").addEventListener("click", navigateBack);
$("btn-error-tx-done").addEventListener("click", navigateBack);
}
module.exports = {
init,
showWait,
restoreWait,
endWait,
showError,
renderSuccess,
renderError,
};