fix: the ETH line reads unknown at an address no refresh has read (closes #736)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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
+19 -5
View File
@@ -1025,6 +1025,7 @@ removing the address only stops the wallet tracking it. It explains the way
back: delete the whole wallet in Settings and import its backup again. The scan
after an import finds only addresses with on-chain activity. When the address
holds anything, the screen lists the holdings and the USD total under a warning.
An ETH balance that no refresh has read counts as holding something.
"Remove Address" removes the address and its site permissions. It returns to
Home with the flash message "Address removed." The wallet's `nextIndex` is not
@@ -1212,13 +1213,25 @@ there in place of a zero. It does so after each of these:
- an address or a wallet is added;
- the network is switched.
This lasts until a refresh has read the token's balance at every address.
This lasts until a refresh in the popup has read the token's balance at every
address.
An address's ETH balance is `null` until a refresh has read that address. This
holds for a new address, and for every address after a network switch. While it
is `null`:
- the balance lists show "quantity unknown";
- Home's ETH balance reads `unknown (ETH)`;
- Send reads `Current balance: unknown (ETH)`, and "Max" fills in nothing;
- "Send" is not refused for a zero balance;
- the address has no total;
- DeleteAddress shows its warning.
Only a whole number from 0 to 80 counts as a token's decimals. A token's
`decimals` is stored only as a source reported it, never guessed. A `null`
balance means the decimals are unknown, never that the balance is zero. While
"Use Blockscout" is off, the bundled list and the tracked tokens may lack a
token's symbol or decimals. The approval and status screens then add a line
`decimals` is stored only as a source reported it, never guessed. A token's
`null` balance means the decimals are unknown, never that the balance is zero.
While "Use Blockscout" is off, the bundled list and the tracked tokens may lack
a token's symbol or decimals. The approval and status screens then add a line
under that token saying that Blockscout's report is not used.
The wallet's own ERC-20 send is encoded with the decimals the screen showed. At
@@ -1254,6 +1267,7 @@ unpriced holding was left out. `formatAddressTotal()` renders that result the
same way on every screen:
- on Sepolia, or before the first price fetch: no total;
- while the address's ETH balance is unknown: no total;
- everything priced: `Total: $5,500.00`;
- part priced: `Total: $3,000.00 plus unpriced tokens`;
- nothing priced but something held: `Total: unpriced tokens only`.
+10 -7
View File
@@ -99,12 +99,12 @@ let refreshTimer = null;
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. A refresh that did not read every
// address this page now holds deletes no `balanceNotFetched`. Adding a token
// sets that field (trackToken() in src/popup/views/helpers.js). So does adding
// an address, and so does a network switch. The save takes the field off the
// matching tokens of the stored record (clearBalanceNotFetched() in
// src/shared/state.js). The rest of the stored tracked token list stays as it
// is.
// address this page now holds, and every address in the stored record, deletes
// no `balanceNotFetched`. Adding a token sets that field (trackToken() in
// src/popup/views/helpers.js). So does adding an address, and so does a network
// switch. The save takes the field off the matching tokens of the stored record
// (clearBalanceNotFetched() in src/shared/state.js). The rest of the stored
// tracked token list stays as it is.
//
// Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. No other screen is
@@ -151,7 +151,10 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
const readEveryAddress = state.wallets.every((wallet) =>
const readEveryAddress = [
...state.wallets,
...stored.wallets,
].every((wallet) =>
wallet.addresses.every((addr) => refreshed.has(addr.address)),
);
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
+5 -5
View File
@@ -190,7 +190,7 @@ async function importMnemonic(ctx) {
{
address: firstAddress,
index: 0,
balance: "0.0000",
balance: null,
tokenBalances: [],
},
],
@@ -218,7 +218,7 @@ async function importMnemonic(ctx) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
balance: null,
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
@@ -267,7 +267,7 @@ async function importPrivateKey(ctx) {
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
addresses: [{ address: addr, balance: null, tokenBalances: [] }],
});
markTrackedTokensNotFetched();
state.hasWallet = true;
@@ -328,7 +328,7 @@ async function importXprvKey(ctx) {
{
address: firstAddress,
index: 0,
balance: "0.0000",
balance: null,
tokenBalances: [],
},
],
@@ -356,7 +356,7 @@ async function importXprvKey(ctx) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
balance: null,
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
+2 -1
View File
@@ -273,7 +273,8 @@ function init(_ctx) {
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
];
if (!addr.balance || parseFloat(addr.balance) === 0) {
// A null balance is one no refresh has read, so it is not refused.
if (parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
return;
}
+4 -2
View File
@@ -161,7 +161,8 @@ function renderBalance() {
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
// null when no refresh has read the balance.
amount = unknownableAmount(addr.balance);
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
@@ -372,7 +373,8 @@ function init(_ctx) {
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
];
if (!addr.balance || parseFloat(addr.balance) === 0) {
// A null balance is one no refresh has read, so it is not refused.
if (parseFloat(addr.balance) === 0) {
if (state.selectedToken === "ETH") {
showFlash("Cannot send \u2014 zero balance.");
return;
+13 -9
View File
@@ -283,9 +283,10 @@ function showFlash(msg, duration = 2000) {
flashTimer = setTimeout(clearFlash, duration);
}
// A stored token balance as a number, or null when there is no number in it.
// balances.js writes null for a holding whose scale nothing knows, and this
// keeps that null from becoming a zero one dereference later.
// A stored balance as a number, or null when there is no number in it.
// balances.js writes null for a holding whose scale nothing knows, and an
// address's ETH balance is null until a refresh has read it. This keeps that
// null from becoming a zero one dereference later.
function unknownableAmount(balance) {
if (balance == null) return null;
const n = parseFloat(balance);
@@ -394,10 +395,10 @@ function formatFee(wei, symbol, chainId) {
// holding renders through here.
//
// `amount` is null for a holding whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
// print for it and no fiat value to derive from one, and printing 0.0000 for
// a real holding is the failure this whole rule exists to prevent, so the row
// says so instead.
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
// refresh has read. There is no quantity to print for it and no fiat value to
// derive from one, and printing 0.0000 for a real holding is the failure this
// whole rule exists to prevent, so the row says so instead.
function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
@@ -440,7 +441,7 @@ function markTrackedTokensNotFetched() {
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
parseFloat(addr.balance || "0"),
unknownableAmount(addr.balance),
getPrice("ETH"),
"ETH",
);
@@ -493,7 +494,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
// about holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
// A null ETH balance is one no refresh has read, so the address may hold
// funds. Warning about funds must err towards warning.
if (addr.balance === null) return true;
if (parseFloat(addr.balance) > 0) return true;
for (const t of addr.tokenBalances || []) {
if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding whose amount could not be stated —
+14 -6
View File
@@ -3,6 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched,
isoDate,
timeAgo,
@@ -76,10 +77,16 @@ function renderTotalValue() {
if (subEl) subEl.innerHTML = " ";
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd;
const ethBal = unknownableAmount(addr.balance);
if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")";
} else {
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")"
: "";
el.textContent = ethStr + ethUsd;
}
if (subEl) {
subEl.innerHTML =
@@ -411,7 +418,7 @@ function render(ctx) {
wallet.addresses.push({
address: newAddr,
index: wallet.nextIndex,
balance: "0.0000",
balance: null,
tokenBalances: [],
});
wallet.nextIndex++;
@@ -498,7 +505,8 @@ function init(ctx) {
return;
}
const addr = currentAddress();
if (!addr.balance || parseFloat(addr.balance) === 0) {
// A null balance is one no refresh has read, so it is not refused.
if (parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
return;
}
+17 -6
View File
@@ -230,11 +230,14 @@ function updateSendBalance() {
attachCopyHandlers($("send-from"));
const token = state.selectedToken || $("send-token").value;
if (token === "ETH") {
// A null balance is one no refresh has read.
$("send-balance").textContent =
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" " +
nativeCurrency();
addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " +
truncateAmountNeverZero(addr.balance) +
" " +
nativeCurrency();
} else {
const symbol = resolveSymbol(
token,
@@ -289,6 +292,12 @@ async function fillMaxAmount() {
return;
}
// A null balance is one no refresh has read.
if (addr.balance == null) {
showFlash("This token's balance is unknown.");
return;
}
// The fee is estimated for this recipient, as the confirmation screen
// estimates it: sending to a contract can cost more gas.
const to = $("send-to").value.trim();
@@ -306,7 +315,7 @@ async function fillMaxAmount() {
provider.estimateGas({
from: addr.address,
to,
value: parseEther(addr.balance || "0"),
value: parseEther(addr.balance),
}),
]);
feeWei = feeReserveWei(gasLimit, feeData);
@@ -321,8 +330,10 @@ async function fillMaxAmount() {
}
// While the estimate was in flight the user left the screen (and perhaps
// opened it again), typed an amount, or changed the address, the holding
// or the recipient: what they did wins.
// or the recipient: what they did wins. A network switch in that time
// leaves no balance to fill in from.
if (
addr.balance == null ||
state.currentView !== "send" ||
sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address ||
+2 -2
View File
@@ -49,7 +49,7 @@ function clearPopupCaches() {
// follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// When this page last saved a balance of null, clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
@@ -100,7 +100,7 @@ async function onChainSwitch(newNetworkId) {
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.balance = null;
addr.tokenBalances = [];
}
}
+4 -3
View File
@@ -68,10 +68,11 @@ function applyChainSwitchFields(s, newNetworkId) {
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
// refresh on the new chain is in flight. A balance of null is one no
// refresh has read: it reads as unknown until one has, not as 0.0000.
for (const wallet of s.wallets || []) {
for (const addr of wallet.addresses || []) {
addr.balance = "0";
addr.balance = null;
addr.tokenBalances = [];
}
}
@@ -108,7 +109,7 @@ function copyChainSwitchFields(s, switched) {
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.balance = got ? got.balance : null;
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
+4 -2
View File
@@ -146,7 +146,8 @@ function formatUsd(amount) {
// way an absent holders_count is not a count of zero.
//
// usd: the value of the holdings a price is known for, or null when
// nothing is knowable at all — testnet, or before the first fetch.
// nothing is knowable at all — testnet, before the first fetch, or
// an ETH balance no refresh has read.
// partial: the address also holds a token with no price, so usd is a floor
// and not the total.
//
@@ -155,7 +156,8 @@ function getAddressValue(addr) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) return { usd: null, partial: false };
let usd = parseFloat(addr.balance || "0") * prices.ETH;
if (addr.balance == null) return { usd: null, partial: false };
let usd = parseFloat(addr.balance) * prices.ETH;
let partial = false;
for (const token of addr.tokenBalances || []) {
// A holding below 0.000001 is left out, as the balance lists leave it
+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.