fix: the ETH line reads unknown at an address no refresh has read (closes #736) #741

Merged
clawbot merged 1 commits from issue-736-unknown-eth-balance into next 2026-11-03 11:24:19 +01:00
21 changed files with 470 additions and 67 deletions
+20 -5
View File
@@ -1025,6 +1025,8 @@ 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 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 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. 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. The warning
then reads "This address may hold a balance."
"Remove Address" removes the address and its site permissions. It returns to "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 Home with the flash message "Address removed." The wallet's `nextIndex` is not
@@ -1212,13 +1214,25 @@ there in place of a zero. It does so after each of these:
- an address or a wallet is added; - an address or a wallet is added;
- the network is switched. - 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 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` `decimals` is stored only as a source reported it, never guessed. A token's
balance means the decimals are unknown, never that the balance is zero. While `null` balance means the decimals are unknown, never that the balance is zero.
"Use Blockscout" is off, the bundled list and the tracked tokens may lack a While "Use Blockscout" is off, the bundled list and the tracked tokens may lack
token's symbol or decimals. The approval and status screens then add a line 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. 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 The wallet's own ERC-20 send is encoded with the decimals the screen showed. At
@@ -1254,6 +1268,7 @@ unpriced holding was left out. `formatAddressTotal()` renders that result the
same way on every screen: same way on every screen:
- on Sepolia, or before the first price fetch: no total; - 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`; - everything priced: `Total: $5,500.00`;
- part priced: `Total: $3,000.00 plus unpriced tokens`; - part priced: `Total: $3,000.00 plus unpriced tokens`;
- nothing priced but something held: `Total: unpriced tokens only`. - 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 // The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. A refresh that did not read every // 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 // address this page now holds, and every address in the stored record, deletes
// sets that field (trackToken() in src/popup/views/helpers.js). So does adding // no `balanceNotFetched`. Adding a token sets that field (trackToken() in
// an address, and so does a network switch. The save takes the field off the // src/popup/views/helpers.js). So does adding an address, and so does a network
// matching tokens of the stored record (clearBalanceNotFetched() in // switch. The save takes the field off the matching tokens of the stored record
// src/shared/state.js). The rest of the stored tracked token list stays as it // (clearBalanceNotFetched() in src/shared/state.js). The rest of the stored
// is. // tracked token list stays as it is.
// //
// Once the save is done, the wallet list is drawn again. So are the balances // 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 // on AddressDetail or AddressToken when that screen is up. No other screen is
@@ -151,7 +151,10 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) { if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens); 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)), wallet.addresses.every((addr) => refreshed.has(addr.address)),
); );
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens); if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
+5 -5
View File
@@ -190,7 +190,7 @@ async function importMnemonic(ctx) {
{ {
address: firstAddress, address: firstAddress,
index: 0, index: 0,
balance: "0.0000", balance: null,
tokenBalances: [], tokenBalances: [],
}, },
], ],
@@ -218,7 +218,7 @@ async function importMnemonic(ctx) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
index: a.index, index: a.index,
balance: "0.0000", balance: null,
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; wallet.nextIndex = scan.nextIndex;
@@ -267,7 +267,7 @@ async function importPrivateKey(ctx) {
type: "key", type: "key",
name: "Wallet " + walletNum, name: "Wallet " + walletNum,
encryptedSecret: encrypted, encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }], addresses: [{ address: addr, balance: null, tokenBalances: [] }],
}); });
markTrackedTokensNotFetched(); markTrackedTokensNotFetched();
state.hasWallet = true; state.hasWallet = true;
@@ -328,7 +328,7 @@ async function importXprvKey(ctx) {
{ {
address: firstAddress, address: firstAddress,
index: 0, index: 0,
balance: "0.0000", balance: null,
tokenBalances: [], tokenBalances: [],
}, },
], ],
@@ -356,7 +356,7 @@ async function importXprvKey(ctx) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
index: a.index, index: a.index,
balance: "0.0000", balance: null,
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; wallet.nextIndex = scan.nextIndex;
+2 -1
View File
@@ -273,7 +273,8 @@ function init(_ctx) {
state.wallets[state.selectedWallet].addresses[ state.wallets[state.selectedWallet].addresses[
state.selectedAddress 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."); showFlash("Cannot send \u2014 zero balance.");
return; return;
} }
+4 -2
View File
@@ -161,7 +161,8 @@ function renderBalance() {
const tokenId = state.selectedToken; const tokenId = state.selectedToken;
let amount, price; let amount, price;
if (tokenId === "ETH") { if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0"); // null when no refresh has read the balance.
amount = unknownableAmount(addr.balance);
price = getPrice("ETH"); price = getPrice("ETH");
} else { } else {
const id = tokenId.toLowerCase(); const id = tokenId.toLowerCase();
@@ -372,7 +373,8 @@ function init(_ctx) {
state.wallets[state.selectedWallet].addresses[ state.wallets[state.selectedWallet].addresses[
state.selectedAddress 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") { if (state.selectedToken === "ETH") {
showFlash("Cannot send \u2014 zero balance."); showFlash("Cannot send \u2014 zero balance.");
return; return;
+9 -2
View File
@@ -87,15 +87,22 @@ function recoveryPathText(wallet) {
// when every holding here is one with no price, since "$0.00" directly under // when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction. A token holding below // "This address holds a balance." is a contradiction. A token holding below
// 0.000001 does not count, as the lines below leave it out. // 0.000001 does not count, as the lines below leave it out.
//
// While no refresh has read the ETH balance the address may be empty, so the
// sentence says "may hold".
function balanceWarningHtml(addr) { function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return " "; if (!addressHoldsFunds(addr)) return " ";
const line = formatAddressTotal(getAddressValue(addr)); const line = formatAddressTotal(getAddressValue(addr));
const total = line const total = line
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>` ? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
: ""; : "";
const sentence =
addr.balance == null
? "This address may hold a balance."
: "This address holds a balance.";
return ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<p class="mb-1">${sentence} Removing it does not move or spend ` +
`move or spend anything; the balance stays at the address.</p>` + `anything; the balance stays at the address.</p>` +
balanceLinesForAddress(addr, state.trackedTokens, false) + balanceLinesForAddress(addr, state.trackedTokens, false) +
total total
); );
+13 -9
View File
@@ -283,9 +283,10 @@ function showFlash(msg, duration = 2000) {
flashTimer = setTimeout(clearFlash, duration); flashTimer = setTimeout(clearFlash, duration);
} }
// A stored token balance as a number, or null when there is no number in it. // 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 this // balances.js writes null for a holding whose scale nothing knows, and an
// keeps that null from becoming a zero one dereference later. // 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) { function unknownableAmount(balance) {
if (balance == null) return null; if (balance == null) return null;
const n = parseFloat(balance); const n = parseFloat(balance);
@@ -394,10 +395,10 @@ function formatFee(wei, symbol, chainId) {
// holding renders through here. // holding renders through here.
// //
// `amount` is null for a holding whose scale nothing knows // `amount` is null for a holding whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to // (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
// print for it and no fiat value to derive from one, and printing 0.0000 for // refresh has read. There is no quantity to print for it and no fiat value to
// a real holding is the failure this whole rule exists to prevent, so the row // derive from one, and printing 0.0000 for a real holding is the failure this
// says so instead. // whole rule exists to prevent, so the row says so instead.
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd = const usd =
@@ -440,7 +441,7 @@ function markTrackedTokensNotFetched() {
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), nativeCurrency(),
parseFloat(addr.balance || "0"), unknownableAmount(addr.balance),
getPrice("ETH"), getPrice("ETH"),
"ETH", "ETH",
); );
@@ -493,7 +494,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
// about holdings must ask this, not the rendered figure. // about holdings must ask this, not the rendered figure.
function addressHoldsFunds(addr) { function addressHoldsFunds(addr) {
if (!addr) return false; if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true; // A null or absent ETH balance reads as unknown, 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 || []) { for (const t of addr.tokenBalances || []) {
if (isBelowOneMillionth(t.balance)) continue; if (isBelowOneMillionth(t.balance)) continue;
// A null balance is a holding whose amount could not be stated — // A null balance is a holding whose amount could not be stated —
+12 -4
View File
@@ -3,6 +3,7 @@ const {
showView, showView,
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched, markTrackedTokensNotFetched,
isoDate, isoDate,
timeAgo, timeAgo,
@@ -76,10 +77,16 @@ function renderTotalValue() {
if (subEl) subEl.innerHTML = "&nbsp;"; if (subEl) subEl.innerHTML = "&nbsp;";
return; return;
} }
const ethBal = parseFloat(addr.balance || "0"); const ethBal = unknownableAmount(addr.balance);
if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")";
} else {
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency(); const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : ""; const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")"
: "";
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
}
if (subEl) { if (subEl) {
subEl.innerHTML = subEl.innerHTML =
@@ -411,7 +418,7 @@ function render(ctx) {
wallet.addresses.push({ wallet.addresses.push({
address: newAddr, address: newAddr,
index: wallet.nextIndex, index: wallet.nextIndex,
balance: "0.0000", balance: null,
tokenBalances: [], tokenBalances: [],
}); });
wallet.nextIndex++; wallet.nextIndex++;
@@ -498,7 +505,8 @@ function init(ctx) {
return; return;
} }
const addr = currentAddress(); 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."); showFlash("Cannot send \u2014 zero balance.");
return; return;
} }
+15 -4
View File
@@ -230,9 +230,12 @@ function updateSendBalance() {
attachCopyHandlers($("send-from")); attachCopyHandlers($("send-from"));
const token = state.selectedToken || $("send-token").value; const token = state.selectedToken || $("send-token").value;
if (token === "ETH") { if (token === "ETH") {
// A null balance is one no refresh has read.
$("send-balance").textContent = $("send-balance").textContent =
"Current balance: " + addr.balance == null
truncateAmountNeverZero(addr.balance || "0") + ? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " +
truncateAmountNeverZero(addr.balance) +
" " + " " +
nativeCurrency(); nativeCurrency();
} else { } else {
@@ -289,6 +292,12 @@ async function fillMaxAmount() {
return; 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 // The fee is estimated for this recipient, as the confirmation screen
// estimates it: sending to a contract can cost more gas. // estimates it: sending to a contract can cost more gas.
const to = $("send-to").value.trim(); const to = $("send-to").value.trim();
@@ -306,7 +315,7 @@ async function fillMaxAmount() {
provider.estimateGas({ provider.estimateGas({
from: addr.address, from: addr.address,
to, to,
value: parseEther(addr.balance || "0"), value: parseEther(addr.balance),
}), }),
]); ]);
feeWei = feeReserveWei(gasLimit, feeData); feeWei = feeReserveWei(gasLimit, feeData);
@@ -321,8 +330,10 @@ async function fillMaxAmount() {
} }
// While the estimate was in flight the user left the screen (and perhaps // 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 // 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 ( if (
addr.balance == null ||
state.currentView !== "send" || state.currentView !== "send" ||
sendScreenOpenings !== opening || sendScreenOpenings !== opening ||
currentAddress()?.address !== addr.address || currentAddress()?.address !== addr.address ||
+2 -2
View File
@@ -49,7 +49,7 @@ function clearPopupCaches() {
// follow runs first, and this switch is made after it. // follow runs first, and this switch is made after it.
// //
// The save writes only the fields that differ from what this page last saved. // 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: // change. So the switch also resets these fields in the stored record:
// //
// - every address's balance and token balances; // - every address's balance and token balances;
@@ -100,7 +100,7 @@ async function onChainSwitch(newNetworkId) {
stored.fraudContracts = []; stored.fraudContracts = [];
for (const wallet of stored.wallets) { for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
addr.balance = "0"; addr.balance = null;
addr.tokenBalances = []; addr.tokenBalances = [];
} }
} }
+4 -3
View File
@@ -68,10 +68,11 @@ function applyChainSwitchFields(s, newNetworkId) {
// Clear per-address balances and token balances so stale data // Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first // 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 wallet of s.wallets || []) {
for (const addr of wallet.addresses || []) { for (const addr of wallet.addresses || []) {
addr.balance = "0"; addr.balance = null;
addr.tokenBalances = []; addr.tokenBalances = [];
} }
} }
@@ -108,7 +109,7 @@ function copyChainSwitchFields(s, switched) {
for (const wallet of s.wallets) { for (const wallet of s.wallets) {
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
const got = stored.get(addr.address); const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0"; addr.balance = got ? got.balance : null;
addr.tokenBalances = got ? got.tokenBalances : []; 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. // 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 // 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. // no total can be stated — 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 // partial: the address also holds a token with no price, so usd is a floor
// and not the total. // and not the total.
// //
@@ -155,7 +156,8 @@ function getAddressValue(addr) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return { usd: null, partial: false }; if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) 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; let partial = false;
for (const token of addr.tokenBalances || []) { for (const token of addr.tokenBalances || []) {
// A holding below 0.000001 is left out, as the balance lists leave it // 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 // 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). // 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, // 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. // 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 // 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 // with every address's ETH balance at 2 and `tokenBalances` as the first
// entries, having read the balance of every token tracked when it started. // address's token entries, having read the balance of every token tracked when
// Given null, it finishes having read no token's balance, as when every // it started. Given null, it finishes having read no token's balance, as when
// balanceOf fails, and leaves the token entries as they were. // every balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) { function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
let release; let release;
@@ -138,7 +142,9 @@ function holdNextRefresh(page) {
const tokenBalances = await new Promise((resolve) => { const tokenBalances = await new Promise((resolve) => {
release = 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 []; if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances; wallets[0].addresses[0].tokenBalances = tokenBalances;
return read; 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 "+" 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 // 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. // `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).toContain("quantity unknown");
expect(row).not.toContain("0.0000"); 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 }); 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 // 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. // 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", () => { 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(); const after = bg.persisted();
expect(after.networkId).toBe("mainnet"); 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); expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm(); await bg.fireBalanceAlarm();
+6 -6
View File
@@ -70,7 +70,7 @@ async function chooseNetwork(page, networkId) {
function expectNothingStored(page) { function expectNothingStored(page) {
const stored = page.storage.read("autistmask"); const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia"); 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.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0); 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 chooseNetwork(page, "sepolia");
await refresh.finish(); 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([]); expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page); expectNothingStored(page);
await page.click("btn-settings-back"); await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]); 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(); 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(); await page.settle();
expect(state.networkId).toBe("sepolia"); 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([]); expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page); 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 // 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. // 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 () => { test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile(); const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0"; profile.wallets[0].addresses[0].balance = null;
const page = await bootPopup(profile); const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]); expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state"); 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(); const after = bg.walletState();
expect(after.networkId).toBe("sepolia"); expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); 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([ expect(bg.chainChangedEvents()).toEqual([
{ {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
+1 -1
View File
@@ -39,7 +39,7 @@ function walletFixture() {
{ {
name: "Wallet 1", name: "Wallet 1",
type: "hd", type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }],
}, },
]; ];
} }
+26 -2
View File
@@ -33,6 +33,7 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] }; const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] }; const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] }; const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const NOT_READ = { address: "0x1", balance: null, tokenBalances: [] };
const TOKEN_ONLY = { const TOKEN_ONLY = {
address: "0x1", address: "0x1",
balance: "0.0000", balance: "0.0000",
@@ -106,14 +107,23 @@ describe("whether an address holds anything", () => {
expect(addressHoldsFunds(DUST)).toBe(true); 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", () => { test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false); expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false); expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
}); });
test("a missing address or missing fields do not", () => { test("a missing address does not", () => {
expect(addressHoldsFunds(undefined)).toBe(false); expect(addressHoldsFunds(undefined)).toBe(false);
expect(addressHoldsFunds({ address: "0x1" })).toBe(false); });
// It reads as unknown, as a null balance does.
test("an address with no ETH balance field counts", () => {
expect(addressHoldsFunds({ address: "0x1" })).toBe(true);
}); });
}); });
@@ -140,6 +150,20 @@ describe("the balance warning on the removal confirmation", () => {
expect(html).toContain("<span>0.0000</span>"); expect(html).toContain("<span>0.0000</span>");
}); });
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 may hold a balance.");
expect(html).not.toContain("This address holds a balance.");
expect(html).toContain("quantity unknown");
expect(html).not.toContain("0.0000");
});
test("an address with no ETH balance field reads the same", () => {
expect(balanceWarningHtml({ address: "0x1" })).toContain(
"This address may hold a balance.",
);
});
// The sentence must not assert an amount, because any amount it could // 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 // assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money. // rounded form produces for an address that holds real money.
+1 -1
View File
@@ -134,7 +134,7 @@ test.each([
await backgroundSwitch(page, "sepolia"); await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]); 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. // 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 () => { 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 // 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown. // would say so on whichever screen is shown.