// 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, };