With debug mode on, debugFetch logged every request's full URL and body, so an RPC endpoint with an API key in its path or query string printed that key to the console on every request. It now logs the HTTP method, the URL's origin and, for a JSON-RPC body, the method name. The balance refresh and token lookup log the RPC endpoint by its origin too. Failed RPC calls print ethers' short message, since its full message for an HTTP error carries the request URL. A failed endpoint check in settings prints the endpoint's origin, since fetch's error for a URL with a user name and password carries the whole URL. The README's DEBUG Mode Policy says what debug mode logs. Model: opus-5-5
394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
// Post-broadcast transaction status views: wait, success, error.
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
addressTitle,
|
|
CONTRACT_CREATION_TEXT,
|
|
escapeHtml,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
copyableHtml,
|
|
etherscanLinkHtml,
|
|
explorerUrl,
|
|
displaySymbol,
|
|
clearViewStack,
|
|
} = require("./helpers");
|
|
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
|
const { state } = 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;
|
|
}
|
|
}
|
|
|
|
// A contract creation reaches these screens with `to` as "" (approval.js
|
|
// writes `to: toAddr || ""`).
|
|
function toAddressHtml(address) {
|
|
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
|
const title = addressTitle(address, state.wallets);
|
|
return renderAddressHtml(address, { title });
|
|
}
|
|
|
|
function txHashHtml(hash) {
|
|
const link = explorerUrl("tx", hash);
|
|
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
|
}
|
|
|
|
function blockNumberHtml(blockNumber) {
|
|
const num = String(blockNumber);
|
|
const link = 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"
|
|
: displaySymbol(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, state.networkId);
|
|
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.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.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
|
|
// || ""`), 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 (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"
|
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
|
state.viewData = {
|
|
amount: txInfo.amount,
|
|
symbol: symbol,
|
|
to: txInfo.to,
|
|
hash: txHash,
|
|
blockNumber: blockNumber,
|
|
decoded: txInfo.decoded || null,
|
|
};
|
|
renderSuccess();
|
|
ctx.doRefreshAndRender();
|
|
}
|
|
|
|
// The symbol shown for a decoded token line, resolved from the bundled list,
|
|
// the tokens the user tracks, and the explorer's report — the same chain the
|
|
// approval screen uses. Null when no source names one, so the line keeps
|
|
// saying `Unknown token`.
|
|
function tokenLabel(address) {
|
|
return resolveTokenSymbol(address, {
|
|
trackedTokens: state.trackedTokens,
|
|
wallets: state.wallets,
|
|
});
|
|
}
|
|
|
|
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"
|
|
: displaySymbol(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,
|
|
};
|