// Post-broadcast transaction status views: wait, success, error. const { $, showView, addressTitle, escapeHtml, renderAddressHtml, attachCopyHandlers, copyableHtml, etherscanLinkHtml, clearViewStack, } = require("./helpers"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { getProvider } = require("../../shared/balances"); 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; } } function toAddressHtml(address) { const title = addressTitle(address, state.wallets); return renderAddressHtml(address, { title }); } function txHashHtml(hash) { const link = `${currentNetwork().explorerUrl}/tx/${hash}`; return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); } function blockNumberHtml(blockNumber) { const num = String(blockNumber); const link = `${currentNetwork().explorerUrl}/block/${num}`; 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; 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"); // 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 = getProvider(state.rpcUrl); let consecutiveFailures = 0; async function poll() { if (id !== waitId) return; let receipt = null; let answered = true; try { receipt = await provider.getTransactionReceipt(txHash); } catch (e) { // 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.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.token and // txInfo.tokenSymbol are deliberately unchecked: they are compared and // coalesced rather than dereferenced, and tokenSymbol is null for ETH. 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 // || ""`), and both fields render harmlessly when empty, 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 (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(); 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 `${currentNetwork().explorerUrl}/token/${address}`; } function decodedDetailsHtml(decoded) { if (!decoded || !decoded.details) return ""; let html = `
`; if (decoded.name) { html += `
Action
`; html += `
${escapeHtml(decoded.name)}
`; } if (decoded.description) { html += `
Description
`; html += `
${escapeHtml(decoded.description)}
`; } for (const d of decoded.details) { html += `
`; html += `
${escapeHtml(d.label)}
`; if (d.address) { if (d.isToken) { const sym = tokenLabel(d.address) || "Unknown token"; html += `
${escapeHtml(sym)}
`; html += toAddressHtml(d.address); } else { html += toAddressHtml(d.address); } } else { html += `
${escapeHtml(d.value)}
`; } html += `
`; } html += `
`; 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) { endWait(); 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() { // 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, };