Author SHA1 Message Date
clawbot 8c5bd73f30 fix: TransactionDetail drops a Blockscout answer for another transaction (closes #675)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
TransactionDetail asks Blockscout for its transaction and does not wait for the answer. If the user pressed Back and opened another transaction first, the first answer filled the second screen with its block, network details, decoded details and raw data. The answer is now dropped unless TransactionDetail is still on screen for the same transaction, beside the check of "Use Blockscout". A request for another transaction that fails no longer logs its error.

Model: opus-5-5
2026-10-27 13:05:35 +00:00
clawbot bcdffa56a4 fix: Settings stores the URL on screen when another page stored a different one (closes #688)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The RPC and Blockscout "Save" buttons in Settings now set the URL in the stored record, inside the save. Before this change, the save wrote only fields that differed from what the page last saved. If another page stored a different URL for the same network, a "Save" of the URL this page loaded left the other page's URL stored, and the flash still read "Saved.". The save still refuses a URL when the active network is no longer the one Settings showed.

Model: opus-5-5
2026-10-27 12:36:48 +01:00
4 changed files with 246 additions and 1 deletions
+6
View File
@@ -334,6 +334,11 @@ function showNewSettings() {
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
async function saveEndpoint(field, url, networkId) {
let refused = false;
await saveState((stored) => {
@@ -341,6 +346,7 @@ async function saveEndpoint(field, url, networkId) {
state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
+19 -1
View File
@@ -311,10 +311,25 @@ function populateOnChainDetails(txData, chainId) {
}
}
// Whether TransactionDetail is on screen for the transaction with `txHash` on
// `chainId`.
function isOnScreen(txHash, chainId) {
const tx = state.viewData.tx;
return (
state.currentView === "transaction" &&
tx?.hash === txHash &&
tx?.chainId === chainId
);
}
// Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has left this screen or opened another
// transaction. That answer is dropped. If that request fails, its error is not
// logged.
async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section");
@@ -333,6 +348,7 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return;
const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!isOnScreen(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId);
@@ -399,7 +415,9 @@ async function loadFullTxDetails(txHash, chainId) {
attachCopyHandlers(container);
}
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
if (isOnScreen(txHash, chainId)) {
log.errorf("loadCalldata failed:", e.message);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
+172
View File
@@ -0,0 +1,172 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open a second transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. If the first request fails instead, its
// error must not be logged.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 ETH from ADDRESS to RECIPIENT on mainnet.
function historyEntry(hash) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId: "0x1",
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32));
const SECOND = historyEntry("0x" + "bb".repeat(32));
// What Blockscout reports for each transaction.
const ANSWERS = {
[FIRST.hash]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[SECOND.hash]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in the other.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
// The error the first request fails with in the second test.
const FIRST_FAILURE = "Blockscout did not answer the first request";
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// Boots the popup on Home with "Use Blockscout" on. The user opens FIRST,
// whose request to Blockscout is held. Then the user presses Back and opens
// SECOND, whose request is answered at once. Returns the page and the held
// request's two outcomes: answer() lets FIRST's answer land, and fail() makes
// the request fail.
async function secondOpenedWhileFirstIsOut() {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
let fail;
const firstHeld = new Promise((resolve, reject) => {
answer = resolve;
fail = reject;
});
globalThis.fetch = jest.fn(async (url) => {
const hash = String(url).slice((BLOCKSCOUT + "/transactions/").length);
if (hash === FIRST.hash) await firstHeld;
return { ok: true, status: 200, json: async () => ANSWERS[hash] };
});
open(FIRST);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(SECOND);
await page.settle();
expect(globalThis.fetch.mock.calls.map(([url]) => url)).toEqual([
BLOCKSCOUT + "/transactions/" + FIRST.hash,
BLOCKSCOUT + "/transactions/" + SECOND.hash,
]);
return {
page,
answer: () => answer(),
fail: () => fail(new Error(FIRST_FAILURE)),
};
}
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const { page, answer } = await secondOpenedWhileFirstIsOut();
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("a request for the first transaction that fails on the second one's screen logs nothing", async () => {
const { page, fail } = await secondOpenedWhileFirstIsOut();
fail();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
expect(console.error.mock.calls.flat()).not.toContain(FIRST_FAILURE);
});