1 Commits
Author SHA1 Message Date
sneak 1958337bae fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the contract lookup on AddToken answered, the popup always went to AddressDetail, even after the user had opened another screen. The token is still added when the network has not changed. The popup now goes to AddressDetail only while AddToken is the screen showing. It tells this from state.currentView, as Send does. The status line is cleared once the token is added, so Back onto AddToken no longer shows "Looking up token...".

Model: opus-5-5
2026-10-27 20:35:15 +00:00
17 changed files with 30 additions and 182 deletions
-1
View File
@@ -10,7 +10,6 @@ WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
RUN script/bootstrap
COPY . .
+11 -17
View File
@@ -2053,21 +2053,15 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
- "Sign & Send" (correct password) when the gas limit or a fee is over its
bound → nothing is signed; "The gas limit or fee the RPC endpoint quoted
for this transaction is more than this wallet will sign for, so it was not
sent. Please go back and try again." in its own box, no screen change. For
an ETH amount that Send's "Max" filled in, the gas limit and fees are
those of this screen's own fee estimate (see Amount above). For any other
send, the RPC endpoint fills in the gas limit and fees at signing. Just
before signing, `assertWithinCeilings()` (`src/shared/approvalVerify.js`)
refuses the send when any of these is over its bound:
- the gas limit
- each fee per gas the transaction sets
- the total fee: the gas limit times `maxFeePerGas`, or times `gasPrice`
when the transaction has no `maxFeePerGas`
- "Sign & Send" (wrong password) → "That password is incorrect. Please try
again." on the password error line, no screen change
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
@@ -2239,8 +2233,8 @@ view would leave a wallet one click from deletion.
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change
- "Add", then leaving AddToken before the contract lookup answers → tracks
the token, no screen change
- "Add", then a network switch in Settings before the contract lookup
answers → nothing is tracked; "The active network changed before the
lookup finished, so the token was not added." flash message, no screen
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --maxWorkers=3",
"test:verbose": "jest --maxWorkers=3 --verbose",
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
-1
View File
@@ -118,7 +118,6 @@ ensure_yarn() {
fi
}
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+1 -12
View File
@@ -5,12 +5,7 @@ const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() {
addTokenScreenOpenings++;
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
@@ -60,7 +55,6 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -86,15 +80,11 @@ function init(ctx) {
});
await saveState();
ctx.doRefreshAndRender();
// If the lookup answers after AddToken was opened again, the
// token is still added, but the screen and the status line stay
// as they are.
if (addTokenScreenOpenings !== opening) return;
// The lookup is over, so a later Back onto AddToken shows no
// status.
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
// If the lookup answers while another screen shows, the token is
// If the user left AddToken while the lookup ran, the token is
// still added but the screen does not change.
if (state.currentView !== "add-token") return;
// Pop the stack (back to address detail) and re-render it
@@ -117,7 +107,6 @@ function init(ctx) {
? detail
: "Could not add the token.",
);
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+1 -42
View File
@@ -18,9 +18,6 @@ const TOKEN = {
name: "Test Token",
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
@@ -75,7 +72,7 @@ test("an answer after the user opened Settings adds the token and Settings stays
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
test("Back onto AddToken after that answer shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
@@ -88,44 +85,6 @@ test("Back from Settings onto AddToken after the lookup answered shows no lookup
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
});
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
+1 -6
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// ------------------------------------------------------------ harness
@@ -188,11 +188,7 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
@@ -243,7 +239,6 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
+12 -11
View File
@@ -537,18 +537,7 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve));
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
@@ -1894,6 +1883,18 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1];
}
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
-11
View File
@@ -103,18 +103,7 @@ function deferred() {
return { promise, resolve };
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
-11
View File
@@ -56,18 +56,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
-11
View File
@@ -64,18 +64,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
-11
View File
@@ -76,18 +76,7 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
-11
View File
@@ -57,18 +57,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
-11
View File
@@ -102,17 +102,6 @@ function makeDocument() {
// ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) {
return {
type: "hd",
+1 -9
View File
@@ -131,14 +131,10 @@ async function review(amount, token) {
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
state.wallets = [
{
@@ -171,10 +167,6 @@ beforeEach(() => {
state.currentView = null;
});
afterEach(() => {
jest.useRealTimers();
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
+1 -9
View File
@@ -277,18 +277,14 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
log.errorf.mockClear();
pageClosed = new AbortController();
@@ -301,10 +297,6 @@ beforeEach(() => {
mockNode.broadcast = [];
});
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
-6
View File
@@ -357,11 +357,6 @@ describe("every way to a signature or the private key refuses a defective wallet
});
beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
@@ -403,7 +398,6 @@ describe("every way to a signature or the private key refuses a defective wallet
});
afterEach(() => {
jest.useRealTimers();
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;