2 Commits
Author SHA1 Message Date
clawbot 9e3d56ef3f 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 before Blockscout answered, the first answer filled the second screen with its block, network details, decoded details and raw data. Now the answer is dropped once TransactionDetail shows another transaction. This check sits beside the "Use Blockscout" check.

Model: opus-5-5
2026-10-27 13:39:04 +00:00
clawbot cd5951a4f8 fix: Settings never shows "Saved." for a URL the input no longer shows (closes #695)
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 store nothing when the input no longer shows the URL that was checked. When another page changes the network, "Use Blockscout" or "Check recipients on Etherscan", this page draws Settings again, and a redraw during the check puts this page's URL back in the input. Before this change, the typed URL was then stored and "Saved." appeared. A save refused because the input changed shows "The URL was not saved because it was changed during the check." A user's own edit during the check refuses the save too.

Model: opus-5-5
2026-10-27 14:34:50 +01:00
5 changed files with 234 additions and 73 deletions
+3
View File
@@ -2279,6 +2279,9 @@ view would leave a wallet one click from deletion.
no user data and is made whether or not "Use Blockscout" is ticked. no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not - If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
+18 -4
View File
@@ -339,17 +339,31 @@ function showNewSettings() {
// Another page may have stored a different URL since then. So `url` is set in // 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 // the stored record too. "Saved." then means that the stored record holds
// `url`. // `url`.
async function saveEndpoint(field, url, networkId) { //
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
let refused = false; let refused = false;
let inputChanged = false;
await saveState((stored) => { await saveState((stored) => {
refused = refused =
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false; inputChanged = $(input).value.trim() !== url;
if (refused || inputChanged) return false;
state[field] = url; state[field] = url;
stored[field] = url; stored[field] = url;
}); });
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else { } else {
showFlash("Saved."); showFlash("Saved.");
} }
@@ -401,7 +415,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", url, net.id); await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -425,7 +439,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("blockscoutUrl", url, net.id); await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
+9 -15
View File
@@ -311,15 +311,12 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Whether TransactionDetail is on screen for the transaction with `txHash` on // Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. // `chainId`. Opening another transaction replaces the one TransactionDetail
function isOnScreen(txHash, chainId) { // holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx; const tx = state.viewData.tx;
return ( return tx?.hash === txHash && tx?.chainId === chainId;
state.currentView === "transaction" &&
tx?.hash === txHash &&
tx?.chainId === chainId
);
} }
// Reads the block, network details and calldata from Blockscout, and decodes // Reads the block, network details and calldata from Blockscout, and decodes
@@ -327,9 +324,8 @@ function isOnScreen(txHash, chainId) {
// asked. An answer that lands after the setting went off is dropped // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown. // (src/shared/storedSettings.js). Either way none of them is shown.
// //
// Blockscout can answer after the user has left this screen or opened another // Blockscout can answer after the user has opened another transaction. That
// transaction. That answer is dropped. If that request fails, its error is not // answer is dropped.
// logged.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
@@ -348,7 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!isOnScreen(txHash, chainId)) return; if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
@@ -415,9 +411,7 @@ async function loadFullTxDetails(txHash, chainId) {
attachCopyHandlers(container); attachCopyHandlers(container);
} }
} catch (e) { } catch (e) {
if (isOnScreen(txHash, chainId)) { log.errorf("loadCalldata failed:", e.message);
log.errorf("loadCalldata failed:", e.message);
}
} }
} }
+120
View File
@@ -47,3 +47,123 @@ test.each([
expect(page.text("flash-msg")).toBe("Saved."); expect(page.text("flash-msg")).toBe("Saved.");
}, },
); );
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
+84 -54
View File
@@ -2,10 +2,10 @@
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675). // transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
// //
// The screen asks Blockscout for its transaction and does not wait for the // 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 // answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on // 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 // the second transaction's screen. An answer that lands while Settings is up
// error must not be logged. // must still fill TransactionDetail.
// //
// Driven through the real popup over a stored profile // Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen // (tests/support/popupBoot.js). Each transaction is opened as a history screen
@@ -21,50 +21,68 @@ const {
ADDRESS, ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222"; const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 ETH from ADDRESS to RECIPIENT on mainnet. // A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
function historyEntry(hash) { // network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return { return {
hash, hash,
from: ADDRESS, from: ADDRESS,
to: RECIPIENT, to: RECIPIENT,
value: "1.0000", value: "1.0000",
exactValue: "1.0", exactValue: "1.0",
symbol: "ETH", symbol,
timestamp: 1790000000, timestamp: 1790000000,
isError: false, isError: false,
direction: "sent", direction: "sent",
directionLabel: "Sent", directionLabel: "Sent",
contractAddress: null, contractAddress: null,
chainId: "0x1", chainId,
}; };
} }
const FIRST = historyEntry("0x" + "aa".repeat(32)); const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32)); const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// What Blockscout reports for each transaction. // The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = { const ANSWERS = {
[FIRST.hash]: { [requestUrl(FIRST)]: {
block_number: 21000001, block_number: 21000001,
nonce: 4441, nonce: 4441,
fee: { value: "21000000000000" }, fee: { value: "21000000000000" },
raw_input: "0x11111111", raw_input: "0x11111111",
method: "firstMethod", method: "firstMethod",
}, },
[SECOND.hash]: { [requestUrl(SECOND)]: {
block_number: 21000002, block_number: 21000002,
nonce: 4442, nonce: 4442,
fee: { value: "42000000000000" }, fee: { value: "42000000000000" },
raw_input: "0x22222222", raw_input: "0x22222222",
method: "secondMethod", method: "secondMethod",
}, },
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
}; };
// The text each answer puts on screen: its block, nonce, fee, method name and // 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. // raw data. No text from one answer appears in another.
const FIRST_SHOWN = [ const FIRST_SHOWN = [
"21000001", "21000001",
"4441", "4441",
@@ -79,9 +97,13 @@ const SECOND_SHOWN = [
"secondMethod", "secondMethod",
"0x22222222", "0x22222222",
]; ];
const FIRST_ON_SEPOLIA_SHOWN = [
// The error the first request fails with in the second test. "21000003",
const FIRST_FAILURE = "Blockscout did not answer the first request"; "4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills. // Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) { function drawnDetails(page) {
@@ -102,71 +124,79 @@ function open(entry) {
require("../src/popup/views/transactionDetail").show(entry); require("../src/popup/views/transactionDetail").show(entry);
} }
// Boots the popup on Home with "Use Blockscout" on. The user opens FIRST, // The URLs of every request to Blockscout so far, in order.
// whose request to Blockscout is held. Then the user presses Back and opens function requestedUrls() {
// SECOND, whose request is answered at once. Returns the page and the held return globalThis.fetch.mock.calls.map(([url]) => url);
// request's two outcomes: answer() lets FIRST's answer land, and fail() makes }
// the request fail.
async function secondOpenedWhileFirstIsOut() { // Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup( const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }), unversionedValidProfile({ useBlockscout: true }),
); );
let answer; let answer;
let fail; const heldAnswer = new Promise((resolve) => {
const firstHeld = new Promise((resolve, reject) => {
answer = resolve; answer = resolve;
fail = reject;
}); });
globalThis.fetch = jest.fn(async (url) => { globalThis.fetch = jest.fn(async (url) => {
const hash = String(url).slice((BLOCKSCOUT + "/transactions/").length); if (url === requestUrl(held)) await heldAnswer;
if (hash === FIRST.hash) await firstHeld; return { ok: true, status: 200, json: async () => ANSWERS[url] };
return { ok: true, status: 200, json: async () => ANSWERS[hash] };
}); });
return { page, answer: () => answer() };
}
open(FIRST); // The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle(); await page.settle();
await page.click("btn-tx-back"); await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]); expect(page.visibleViews()).toEqual(["main"]);
open(SECOND); open(second);
await page.settle(); await page.settle();
expect(globalThis.fetch.mock.calls.map(([url]) => url)).toEqual([ expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
BLOCKSCOUT + "/transactions/" + FIRST.hash, answer();
BLOCKSCOUT + "/transactions/" + SECOND.hash, await page.settle();
]); expect(page.visibleViews()).toEqual(["transaction"]);
return { return page;
page,
answer: () => answer(),
fail: () => fail(new Error(FIRST_FAILURE)),
};
} }
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => { afterEach(() => {
cleanupPopup(); cleanupPopup();
jest.restoreAllMocks();
}); });
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => { test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const { page, answer } = await secondOpenedWhileFirstIsOut(); const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
const drawn = drawnDetails(page); const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text); for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.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 () => { test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const { page, fail } = await secondOpenedWhileFirstIsOut(); const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
fail();
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle(); await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]); expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page); const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text); for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
expect(console.error.mock.calls.flat()).not.toContain(FIRST_FAILURE);
}); });