fix: a tracked token reads quantity unknown at an address no refresh has read (closes #720)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A new address, a new wallet and a network switch each leave tracked tokens
with no balance entry, so their lines read 0.0000 before any refresh had read
them.

Each of these now sets `balanceNotFetched` on the tracked tokens, and a refresh
already clears it. Their lines read `quantity unknown` until then. Addresses
that the wallet scan finds get the same. A refresh that did not read every
address the page now holds leaves the field set.

Model: opus-5-5
This commit was merged in pull request #735.
This commit is contained in:
2026-11-03 08:39:40 +01:00
parent f79390cad5
commit 4531e8bce5
13 changed files with 392 additions and 26 deletions
+304
View File
@@ -11,14 +11,44 @@
// switch keeps each network's records apart. A refresh takes the field off the
// stored record's tokens and keeps another page's changes to the stored list.
//
// Every tracked token carries that field again once an address or a wallet is
// added, and after a network switch, so it reads the same until a refresh has
// read it (https://git.eeqj.de/sneak/AutistMask/issues/720).
//
// Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default.
const { HDNodeWallet, Mnemonic } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { hdWalletFromMnemonic } = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// What AddWallet's tabs take. The first and last scan for more addresses.
const FROM_PHRASE = {
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
};
const FROM_PRIVATE_KEY = {
tab: "tab-privkey",
field: "import-private-key",
secret: "0x" + "11".repeat(32),
};
const FROM_XPRV = {
tab: "tab-xprv",
field: "import-xprv-key",
secret: HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
.extendedKey,
};
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
@@ -66,6 +96,34 @@ function bootOnAddress(extra) {
);
}
// The popup over one wallet that "+" can derive an address from. Its one
// address holds a million NEW_TOKEN, and NEW_TOKEN is tracked.
function bootOnDerivingWallet() {
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
return bootPopup(
unversionedValidProfile({
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub,
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{
address: firstAddress,
balance: "1.5",
tokenBalances: [MILLION],
},
],
},
],
activeAddress: firstAddress,
trackedTokens: [NEW_TRACKED],
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
@@ -175,6 +233,47 @@ function newTokenRow(page, id = "address-balances") {
);
}
// Every address's row for NEW_TOKEN on Home, as markup, in the order Home lists
// the addresses.
function newTokenRowsOnHome(page) {
return page
.node("wallet-list")
.innerHTML.split("<div ")
.filter((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// The "+" button Home draws for the first wallet. As in firstRemoveButton(),
// the list hands Home a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Home next draws its list.
function plusButton(page) {
const button = { press: null };
page.node("wallet-list").querySelectorAll = (selector) =>
selector !== ".btn-add-address"
? []
: [
{
dataset: { wallet: "0" },
addEventListener: (name, fn) => {
button.press = () =>
fn({ stopPropagation: () => {} });
},
},
];
return button;
}
// "Add wallet" on Home, filled in from `source`, and "Import".
async function addWallet(page, { tab, field, secret }) {
await page.click("btn-add-wallet-bottom");
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
@@ -456,3 +555,208 @@ test("a refresh does not bring back a token that another page removed after this
NEW_TRACKED,
]);
});
test("an address just added reads quantity unknown for a tracked token until a refresh has read it", async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finish = holdNextRefresh(page);
await plus.press();
await page.settle();
const [held, added] = newTokenRowsOnHome(page);
expect(held).toContain("1000000.0000");
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
expect(
page.storage.read("autistmask").trackedTokens[0].balanceNotFetched,
).toBe(true);
// It reads the new address's balance, which is zero.
await finish([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test('an address "+" adds during a refresh reads quantity unknown when that refresh returns', async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await plus.press();
await page.settle();
const finishNext = holdNextRefresh(page);
await finishRunning([MILLION]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
"a wallet just added from $tab reads quantity unknown for a tracked token until a refresh has read it",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finish = holdNextRefresh(page);
await addWallet(page, source);
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finish([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
},
);
test("a wallet from a private key added during a refresh reads quantity unknown when that refresh returns", async () => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addWallet(page, FROM_PRIVATE_KEY);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown, even after a refresh returned during the scan",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finish;
scanForAddresses.mockImplementationOnce(async () => {
await refreshOnce(page, []);
finish = holdNextRefresh(page);
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
await finish([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown when a refresh running as the scan ends returns",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finishRunning;
let ticking;
scanForAddresses.mockImplementationOnce(async () => {
finishRunning = holdNextRefresh(page);
ticking = page.tick();
await page.settle();
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
// It reads the new addresses' balances, which are zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test("after a network switch in Settings, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
trackedTokens: [OTHER_TOKEN],
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
// It reads the balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("after a switch saved by the background, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
const {
applyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
// The popup takes the switch, and this refresh reads no token's balance.
await page.tick();
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});