fix: the ETH line reads unknown at an address no refresh has read (closes #736)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

An address's ETH balance is now null until a refresh has read it. That covers
a new address, a new wallet, addresses a wallet scan finds, and every address
after a network switch.

Its line reads `quantity unknown`, Home's balance `unknown (ETH)`, and Send
`Current balance: unknown (ETH)`. Max says the balance is unknown. Send is no
longer refused as zero. The address has no total, and DeleteAddress keeps its
warning.

A refresh also deletes `balanceNotFetched` only when it read every address in
the stored record.

Model: opus-5-5
This commit is contained in:
2026-11-03 09:14:43 +00:00
parent 448afdb5ce
commit 11c9481556
20 changed files with 447 additions and 63 deletions
+301 -5
View File
@@ -15,6 +15,10 @@
// 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).
//
// An address's ETH balance is null until a refresh has read that address, so
// its ETH line reads the same, and Send states it as unknown
// (https://git.eeqj.de/sneak/AutistMask/issues/736).
//
// 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.
@@ -125,10 +129,10 @@ function bootOnDerivingWallet() {
}
// 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.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
// with every address's ETH balance at 2 and `tokenBalances` as the first
// address's token entries, having read the balance of every token tracked when
// it started. Given null, it finishes having read no token's balance, as when
// every balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
@@ -138,7 +142,9 @@ function holdNextRefresh(page) {
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = "2.0";
}
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
@@ -244,6 +250,22 @@ function newTokenRowsOnHome(page) {
);
}
// Every ETH row in the balance list in element `id`, as markup, in the order
// the list holds them.
function ethRows(page, id) {
return page
.node(id)
.innerHTML.split("<div ")
.filter((row) => row.includes('data-token="ETH"'));
}
// A stored profile whose one address no refresh has read.
function profileNotRead(extra) {
const profile = unversionedValidProfile(extra);
profile.wallets[0].addresses[0].balance = null;
return profile;
}
// 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.
@@ -760,3 +782,277 @@ test("after a switch saved by the background, a tracked token reads quantity unk
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});
test("a refresh deletes no balanceNotFetched while the stored record holds an address it did not read", async () => {
const page = await bootOnAddress({
trackedTokens: [{ ...NEW_TRACKED, balanceNotFetched: true }],
});
// Another popup page adds an address. This page is not told of the write.
const record = page.storage.read("autistmask");
record.wallets[0].addresses.push({
address: "0x" + "dd".repeat(20),
balance: null,
tokenBalances: [],
});
page.storage.write("autistmask", record);
await refreshOnce(page, []);
expect(
page.storage.read("autistmask").trackedTokens[0].balanceNotFetched,
).toBe(true);
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("an address just added reads quantity unknown for ETH until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, []);
const finish = holdNextRefresh(page);
await plus.press();
await page.settle();
const [held, added] = ethRows(page, "wallet-list");
expect(held).toContain("2.0000");
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
expect(
page.storage.read("autistmask").wallets[0].addresses[1].balance,
).toBeNull();
await finish([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
});
test('an address "+" adds during a refresh reads quantity unknown for ETH when that refresh returns', async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, []);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await plus.press();
await page.settle();
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const added = ethRows(page, "wallet-list")[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
await finishNext([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
});
test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
"a wallet just added from $tab reads quantity unknown for ETH until a refresh has read it",
async (source) => {
const page = await bootPopup(unversionedValidProfile());
const finish = holdNextRefresh(page);
await addWallet(page, source);
const added = ethRows(page, "wallet-list")[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
await finish([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
},
);
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown for ETH until a refresh has read them",
async (source) => {
const page = await bootPopup(unversionedValidProfile());
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] = ethRows(page, "wallet-list");
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
await finish([]);
expect(ethRows(page, "wallet-list")[2]).toContain("2.0000");
},
);
test("after a network switch in Settings, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
expect(
page.storage.read("autistmask").wallets[0].addresses[0].balance,
).toBeNull();
await refreshOnce(page, []);
expect(ethRows(page, "address-balances")[0]).toContain("2.0000");
});
test("after a switch saved by the background, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnAddress();
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 ETH balance.
await page.tick();
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await refreshOnce(page, []);
expect(ethRows(page, "address-balances")[0]).toContain("2.0000");
});
test("a switch taken from the stored record leaves an address the record does not have with no ETH balance", () => {
const {
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const taker = {
wallets: [
{
addresses: [
{ address: "0x1", balance: "1.5", tokenBalances: [] },
],
},
],
};
copyChainSwitchFields(taker, { wallets: [] });
expect(taker.wallets[0].addresses[0].balance).toBeNull();
});
test("closed and opened again before any refresh has read it, the ETH line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
await closed.click("btn-settings");
await chooseNetwork(closed, "sepolia");
await closed.click("btn-settings-back");
cleanupPopup();
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});
test("Home's ETH balance reads unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootPopup(profileNotRead());
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("unknown (ETH)");
await refreshOnce(page, []);
expect(page.text("total-value")).toBe("2.0000 ETH");
});
test("AddressToken for ETH reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootPopup(
profileNotRead({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await refreshOnce(page, []);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test.each([
["main", {}, "btn-main-send"],
[
"address",
{ currentView: "address", selectedWallet: 0, selectedAddress: 0 },
"btn-send",
],
[
"address-token",
{
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
},
"btn-address-token-send",
],
])(
"Send from %s reads an unread ETH balance as unknown, Max says so, and only a balance read as zero is refused",
async (view, extra, sendButton) => {
const page = await bootPopup(profileNotRead(extra));
expect(page.visibleViews()).toEqual([view]);
// The dropdown's first option, which a browser selects by itself.
page.node("send-token").value = "ETH";
await page.click(sendButton);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe(
"Current balance: unknown (ETH)",
);
await page.click("btn-send-max");
expect(page.text("flash-msg")).toBe("This token's balance is unknown.");
await page.click("btn-send-back");
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementationOnce(async (wallets) => {
wallets[0].addresses[0].balance = "0.0";
return [];
});
await page.tick();
await page.click(sendButton);
expect(page.visibleViews()).toEqual([view]);
expect(page.text("flash-msg")).toBe("Cannot send — zero balance.");
},
);
+8
View File
@@ -125,6 +125,14 @@ describe("the value of an address, and whether it is the whole value", () => {
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
});
// No total can include an ETH balance no refresh has read, and "unpriced
// tokens only" would be untrue of an address with nothing known at all.
test("an ETH balance no refresh has read means no value", () => {
const addr = { address: "0x1", balance: null, tokenBalances: [] };
expect(getAddressValue(addr)).toEqual({ usd: null, partial: false });
expect(formatAddressTotal(getAddressValue(addr))).toBe("");
});
// Before the first price fetch, and on testnet, nothing is knowable: that
// is a third state, and it stays distinct from both of the others.
test("no prices at all means no value, not an incomplete one", () => {
+1 -1
View File
@@ -445,7 +445,7 @@ describe("a balance refresh under a network switch", () => {
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.wallets[0].addresses[0].balance).toBeNull();
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
+6 -6
View File
@@ -70,7 +70,7 @@ async function chooseNetwork(page, networkId) {
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].balance).toBeNull();
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
@@ -86,13 +86,13 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].balance).toBeNull();
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
await page.tick();
@@ -138,7 +138,7 @@ test("a refresh whose save is reading the stored record when the user switches i
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].balance).toBeNull();
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
@@ -166,11 +166,11 @@ test("a refresh that returns after the background switched the network writes no
});
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// in Settings waits for the lock. This page last saved a balance of null and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
profile.wallets[0].addresses[0].balance = null;
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
+1 -1
View File
@@ -328,7 +328,7 @@ describe("only the user switches the network", () => {
const after = bg.walletState();
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.wallets[0].addresses[0].balance).toBeNull();
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
+1 -1
View File
@@ -39,7 +39,7 @@ function walletFixture() {
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }],
},
];
}
+13
View File
@@ -33,6 +33,7 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const NOT_READ = { address: "0x1", balance: null, tokenBalances: [] };
const TOKEN_ONLY = {
address: "0x1",
balance: "0.0000",
@@ -106,6 +107,11 @@ describe("whether an address holds anything", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
// No refresh has read it, so the address may hold funds.
test("an ETH balance no refresh has read counts", () => {
expect(addressHoldsFunds(NOT_READ)).toBe(true);
});
test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
@@ -143,6 +149,13 @@ describe("the balance warning on the removal confirmation", () => {
// The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
test("an ETH balance no refresh has read is warned about, and reads as unknown", () => {
const html = balanceWarningHtml(NOT_READ);
expect(html).toContain("This address holds a balance");
expect(html).toContain("quantity unknown");
expect(html).not.toContain("0.0000");
});
test("the warning sentence asserts no rounded amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch(
+1 -1
View File
@@ -134,7 +134,7 @@ test.each([
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
});
// A request on Ethereum Mainnet fails, so Home's list holds the failure message.
+21
View File
@@ -432,6 +432,27 @@ describe("Max on an ETH send", () => {
},
);
test("says so when no refresh has read the ETH balance", async () => {
await refreshWith(BALANCE_WEI);
state.wallets[0].addresses[0].balance = null;
openSend();
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is unknown.");
});
test("fills nothing in and says nothing when a network switch cleared the balance while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
state.wallets[0].addresses[0].balance = null;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
// 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown.