fix: WaitTx and TransactionDetail use the network the transaction was sent on (closes #584)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

WaitTx asked the active network's node for the receipt, so after a network
switch a transaction sent on the other network was never found and was
reported as timed out. It now asks the node of the network its chain id
names, through reviewedNetworkProvider(), moved from ConfirmTx into the
shared view helpers so both screens use one function. WaitTx and
TransactionDetail build their explorer links for that network too. A stored
wait or transaction whose chain id names no network is refused on restore,
as a stored ConfirmTx transaction already is.

Model: opus-5-5
This commit was merged in pull request #621.
This commit is contained in:
2026-10-19 23:38:10 +02:00
parent 96446f4661
commit e46d894527
11 changed files with 220 additions and 66 deletions
+4 -1
View File
@@ -145,12 +145,15 @@ function isRenderablePendingTx(value) {
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open, so when it is
// there it has to name one.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress))
(!value.contractAddress || isAddressText(value.contractAddress)) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
+2 -18
View File
@@ -23,12 +23,10 @@ const {
goBack,
onViewLeave,
formatFee,
reviewedNetworkProvider,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -458,20 +456,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
+16
View File
@@ -19,6 +19,7 @@ const {
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -607,6 +608,20 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
// The node of the network a transaction was reviewed for, the one its chain id
// names, which need not be the active one: the popup can reopen onto ConfirmTx
// or WaitTx after Settings switched the network. Its RPC URL is the one
// Settings has for that network: the active network's is state.rpcUrl, any
// other's the one remembered for it (see applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
@@ -762,6 +777,7 @@ module.exports = {
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
+24 -14
View File
@@ -48,16 +48,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function txAddressHtml(address, ensName, title) {
function txAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -83,10 +83,10 @@ function show(tx) {
method: tx.method || null,
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
// the fee are in its native currency, not the active network's,
// and the links open its explorer: the active network can change,
// in Settings or when the user approves a site's request, before
// a later popup shows this screen again.
chainId: tx.chainId,
},
};
@@ -96,18 +96,24 @@ function show(tx) {
function render() {
const tx = state.viewData.tx;
if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
tx.chainId,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
$("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
@@ -146,7 +152,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
@@ -204,7 +210,11 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number));
const blockLink = explorerUrl(
"block",
String(txData.block_number),
chainId,
);
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -340,9 +350,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else if (d.address) {
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+25 -17
View File
@@ -14,10 +14,13 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
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
@@ -61,15 +64,16 @@ function endWait() {
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
// writes `to: toAddr || ""`). The link opens 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 });
return renderAddressHtml(address, { title, chainId });
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
@@ -91,9 +95,11 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// 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. 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.
// is open or before a later popup resumes it. This screen's links open
// that network's explorer, and its 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);
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
$("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
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
let consecutiveFailures = 0;
async function poll() {
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
// 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,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
// 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 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;
@@ -226,6 +233,7 @@ function restoreWait() {
// 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;
}