From 5376723c414763e10407987329d3fe245dae5b38 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Tue, 20 Oct 2026 00:49:05 +0200
Subject: [PATCH] fix: SuccessTx and ErrorTx link to the explorer of the
network the transaction was sent on (closes #622)
SuccessTx and ErrorTx built their recipient, block, hash and decoded-action
address links for the active network. After a network switch, WaitTx now finds
a transaction on the network it was sent on and moves on to these screens, whose
links then opened an explorer where the transaction does not exist. Both
screens now keep the transaction's chain id with their view data and build
every link for that network, as WaitTx does. A stored SuccessTx or ErrorTx
whose chain id names no network is refused on restore, as a stored transaction
already is.
Model: opus-5-5
---
README.md | 8 ++-
src/popup/viewRouter.js | 8 +++
src/popup/views/txStatus.js | 38 ++++++-------
tests/persistedFieldContract.test.js | 11 ++++
tests/txStatus.test.js | 79 ++++++++++++++++++++++++++++
5 files changed, 125 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 05bdf8b..1fd1ff2 100644
--- a/README.md
+++ b/README.md
@@ -1922,9 +1922,12 @@ view would leave a wallet one click from deletion.
- Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- - Block number
+ - Block number + etherscan link
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
+- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
+ explorer of the network the transaction was sent on, whichever network is
+ active.
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1944,6 +1947,9 @@ view would leave a wallet one click from deletion.
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
- "Done" button
+- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
+ explorer of the network the transaction was sent on, whichever network is
+ active.
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
diff --git a/src/popup/viewRouter.js b/src/popup/viewRouter.js
index aa2b8a1..caefb26 100644
--- a/src/popup/viewRouter.js
+++ b/src/popup/viewRouter.js
@@ -205,11 +205,19 @@ function renderView(view, state, views) {
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
+ // `chainId` picks the network whose explorer the links open, so
+ // when it is there it has to name one. Likewise on error-tx.
+ if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
+ return false;
+ }
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
+ if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
+ return false;
+ }
views.txStatus.renderError();
return true;
default:
diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js
index 005c6c7..f6fcfb3 100644
--- a/src/popup/views/txStatus.js
+++ b/src/popup/views/txStatus.js
@@ -64,8 +64,8 @@ function endWait() {
}
// A contract creation reaches these screens with `to` as "" (approval.js
-// writes `to: toAddr || ""`). The link opens the explorer of the network with
-// `chainId`, or of the active network when none is given.
+// 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);
@@ -77,9 +77,9 @@ function txHashHtml(hash, chainId) {
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
-function blockNumberHtml(blockNumber) {
+function blockNumberHtml(blockNumber, chainId) {
const num = String(blockNumber);
- const link = explorerUrl("block", num);
+ const link = explorerUrl("block", num, chainId);
return copyableHtml(num) + etherscanLinkHtml(link);
}
@@ -95,11 +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. 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.
+ // 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);
@@ -258,12 +258,13 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
+ chainId: txInfo.chainId,
};
renderSuccess();
ctx.doRefreshAndRender();
}
-function decodedDetailsHtml(decoded) {
+function decodedDetailsHtml(decoded, chainId) {
if (!decoded || !decoded.details) return "";
let html = `
`;
if (decoded.name) {
@@ -281,9 +282,9 @@ function decodedDetailsHtml(decoded) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `
${escapeHtml(sym)}
`;
- html += toAddressHtml(d.address);
+ html += toAddressHtml(d.address, chainId);
} else {
- html += toAddressHtml(d.address);
+ html += toAddressHtml(d.address, chainId);
}
} else {
html += `
${escapeHtml(d.value)}
`;
@@ -312,16 +313,16 @@ function renderSuccess() {
toSection.classList.remove("hidden");
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
- $("success-tx-to").innerHTML = toAddressHtml(d.to);
+ $("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
}
- $("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
- $("success-tx-hash").innerHTML = txHashHtml(d.hash);
+ $("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 && hasDecoded) {
- decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
+ decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -347,6 +348,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
+ chainId: txInfo.chainId,
};
renderError();
}
@@ -356,11 +358,11 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
- $("error-tx-to").innerHTML = toAddressHtml(d.to);
+ $("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);
+ $("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx");
} else {
diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js
index 995a57b..b2f0c06 100644
--- a/tests/persistedFieldContract.test.js
+++ b/tests/persistedFieldContract.test.js
@@ -441,6 +441,17 @@ const CONTRACT = [
views: ["error-tx"],
restored: false,
},
+ // A chain id naming no network has no explorer to link to.
+ {
+ value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
+ views: ["success-tx"],
+ restored: false,
+ },
+ {
+ value: { message: "boom", to: ADDRESS, chainId: "0x5" },
+ views: ["error-tx"],
+ restored: false,
+ },
// transaction passes on `data.tx`.
{
value: { tx: { hash: "0x1" } },
diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js
index 8880c7c..a78e9b6 100644
--- a/tests/txStatus.test.js
+++ b/tests/txStatus.test.js
@@ -566,6 +566,85 @@ describe("WaitTx for a Sepolia transaction with mainnet active", () => {
});
});
+// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
+// their links went to the active network's explorer
+// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
+// screen again after rendering it from its stored view data alone, as a
+// reopened popup does.
+describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
+ const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
+ const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
+ const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
+
+ test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
+ function expectSepoliaLinks() {
+ expect(getElement("success-tx-to").innerHTML).toContain(
+ `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
+ );
+ expect(getElement("success-tx-block").innerHTML).toContain(
+ `href="${SEPOLIA_EXPLORER}/block/21000000"`,
+ );
+ expect(getElement("success-tx-hash").innerHTML).toContain(
+ `href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
+ );
+ }
+ mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
+ txStatus.showWait(sepoliaTx, TX_HASH);
+ await jest.advanceTimersByTimeAsync(10000);
+ expect(state.currentView).toBe("success-tx");
+ expectSepoliaLinks();
+
+ elements.clear();
+ txStatus.renderSuccess();
+ expectSepoliaLinks();
+ });
+
+ test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
+ function expectSepoliaLinks() {
+ const html = getElement("success-tx-decoded").innerHTML;
+ expect(html).toContain(
+ `href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
+ );
+ expect(html).toContain(
+ `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
+ );
+ }
+ const decoded = {
+ name: "Transfer",
+ details: [
+ { label: "Token", address: TOKEN, isToken: true },
+ { label: "Recipient", address: RECIPIENT },
+ ],
+ };
+ mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
+ txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
+ await jest.advanceTimersByTimeAsync(10000);
+ expect(state.currentView).toBe("success-tx");
+ expectSepoliaLinks();
+
+ elements.clear();
+ txStatus.renderSuccess();
+ expectSepoliaLinks();
+ });
+
+ test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
+ function expectSepoliaLinks() {
+ expect(getElement("error-tx-to").innerHTML).toContain(
+ `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
+ );
+ expect(getElement("error-tx-hash").innerHTML).toContain(
+ `href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
+ );
+ }
+ txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
+ expectSepoliaLinks();
+
+ elements.clear();
+ txStatus.renderError();
+ expectSepoliaLinks();
+ });
+});
+
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills