376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
// 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 = `<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) {
|
|
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,
|
|
};
|