Author SHA1 Message Date
sneak 5a00bb4f8e fix: two pages switching networks no longer lose tracked tokens (closes #701)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A page that had not yet taken another page's network switch could lose
tracked tokens.

- Its save of an added or removed token wrote its own network's list as
  the new network's list. saveState() now keeps the stored list. It
  merges the add or removal into the stored list of the page's own
  network in networkTrackedTokens.
- Its own switch in Settings wrote its old lists of tracked tokens per
  network over the stored ones. onChainSwitch() now first takes the
  stored switch with takeStoredNetwork(), and then switches from there.
- takeStoredNetwork() replaced the page's tracked tokens with the stored
  ones. It now keeps the page's unsaved change in the list of the page's
  network. This also fixes
  #706.

Model: opus-5-5
2026-10-27 22:32:18 +00:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
5 changed files with 337 additions and 7 deletions
+2
View File
@@ -2233,6 +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 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
+18
View File
@@ -5,7 +5,12 @@ 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";
@@ -55,6 +60,7 @@ 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,
@@ -80,6 +86,17 @@ 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
// 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
// so the newly added token is visible immediately.
if (state.viewStack.length > 0) {
@@ -100,6 +117,7 @@ function init(ctx) {
? detail
: "Could not add the token.",
);
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+29 -7
View File
@@ -455,6 +455,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// One exception: when the stored record is on another network than this
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
// into the list in `networkTrackedTokens` for this page's network, by contract
// address.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
@@ -627,16 +632,33 @@ function saveState(beforeWrite) {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followStoredSettings() in src/popup/index.js and
// onChainSwitch() in src/shared/chainSwitch.js call this from `beforeWrite`),
// in `state` and in `baseline` alike, as loadState() does with a whole
// record. The switch is then not this page's own change, even when that save
// fails to write: a later switch back to the previous network still differs
// from `baseline`, and is stored.
// Give this page the network switch held by `stored`, in `state` and in
// `baseline` alike, as loadState() does with a whole record. `stored` is the
// record that a save of this page has just read. followStoredSettings() in
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
// from `beforeWrite`. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
//
// A token that this page tracked or removed since its last save was tracked or
// removed on this page's network before the switch. `state` keeps that change
// in the list in `networkTrackedTokens` for that network, merged with the
// stored list by contract address. `baseline` takes the stored list, so the
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
function takeStoredNetwork(stored) {
const networkTrackedTokens = {
...stored.networkTrackedTokens,
[rawState.networkId]: mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.networkTrackedTokens[rawState.networkId],
(token) => token.address.toLowerCase(),
(base, ours) => ours,
),
};
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
rawState.networkTrackedTokens = networkTrackedTokens;
}
// Give this page the tracked tokens of `stored`, a record on this page's
+137
View File
@@ -0,0 +1,137 @@
// A contract lookup on AddToken can answer after the user has left AddToken.
// The token is still added, and the screen the user is on does not change
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
// still shows goes to AddressDetail. Driven through the real popup over a
// stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, then AddToken opened from it with
// TOKEN's contract address typed in.
async function openAddToken() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = TOKEN.address;
return boot;
}
// Presses Add with a contract lookup that answers only when the function
// returned here is called. That function waits for Add to finish.
function addWithPendingLookup(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click("btn-add-token-confirm");
return async () => {
answer({
symbol: TOKEN.symbol,
decimals: TOKEN.decimals,
name: TOKEN.name,
});
await adding;
};
}
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await answerLookup();
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
await boot.click("btn-settings");
await answerLookup();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("");
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);
await answerLookup();
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).toContain("TST");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
+151
View File
@@ -311,6 +311,43 @@ test("a token added before this page takes another page's switch is tracked on E
]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. This page does not track DAI. Before taking that switch, this page
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
// token on Ethereum Mainnet's list.
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
// on a tracked token in Settings does. The removal's save drops DAI from
@@ -436,6 +473,120 @@ test("a token added while a switch in Settings waits for the lock stays tracked
]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
// this page's switch to Sepolia waits for the lock. Another popup page holds
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
// list.
test("a token added while a switch in Settings waits for another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
// tracked on Ethereum Mainnet. This page waits for the lock to take that
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
// stay on Ethereum Mainnet's list.
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.settle();
releaseLock();
await lockHeld;
await adding;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");