// After "Add" on AddToken, AddressDetail lists the new token. Its line reads // `quantity unknown`, not 0.0000, until a balance refresh has read the token's // balance at every address, even if the popup closes and opens again // meanwhile. A token added on SettingsAddToken reads the same wherever it is // listed. A refresh that returns draws the balances on AddressDetail or // AddressToken again, so the fetched balance shows without the screen being // reopened (https://git.eeqj.de/sneak/AutistMask/issues/516). // // The tracked token's record carries `balanceNotFetched` until a refresh has // read the token's balance. Removing the token removes that record. A network // switch keeps each network's records apart. A refresh takes the field off the // stored record's tokens and keeps another page's changes to the stored list. // // Every tracked token carries that field again once an address or a wallet is // added, and after a network switch, so it reads the same until a refresh has // read it (https://git.eeqj.de/sneak/AutistMask/issues/720). // // 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. const { HDNodeWallet, Mnemonic } = require("ethers"); const { bootPopup, cleanupPopup, unversionedValidProfile, } = require("./support/popupBoot"); const { hdWalletFromMnemonic } = require("../src/shared/wallet"); const PHRASE = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"; const PASSWORD = "correct horse battery staple"; // What AddWallet's tabs take. The first and last scan for more addresses. const FROM_PHRASE = { tab: "tab-mnemonic", field: "wallet-mnemonic", secret: PHRASE, }; const FROM_PRIVATE_KEY = { tab: "tab-privkey", field: "import-private-key", secret: "0x" + "11".repeat(32), }; const FROM_XPRV = { tab: "tab-xprv", field: "import-xprv-key", secret: HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed()) .extendedKey, }; // Tracked by nobody until a test adds it. const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; // A holding of a million NEW_TOKEN, as refreshBalances() stores it. const MILLION = { address: NEW_TOKEN, name: "New Token", symbol: "NEW", decimals: 18, balance: "1000000.0", holders: 3, }; // NEW_TOKEN as "Add" on AddToken tracks it, once a refresh has read its // balance. const NEW_TRACKED = { address: NEW_TOKEN, symbol: "NEW", name: "New Token", decimals: 18, }; // A token that another popup page tracks. const OTHER_TOKEN = { address: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", symbol: "OTH", name: "Other Token", decimals: 18, }; afterEach(() => { cleanupPopup(); }); // The popup reopened onto AddressDetail for the one address. function bootOnAddress(extra) { return bootPopup( unversionedValidProfile({ currentView: "address", selectedWallet: 0, selectedAddress: 0, ...extra, }), ); } // The popup over one wallet that "+" can derive an address from. Its one // address holds a million NEW_TOKEN, and NEW_TOKEN is tracked. function bootOnDerivingWallet() { const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE); return bootPopup( unversionedValidProfile({ wallets: [ { type: "hd", name: "Wallet 1", xpub, encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [ { address: firstAddress, balance: "1.5", tokenBalances: [MILLION], }, ], }, ], activeAddress: firstAddress, trackedTokens: [NEW_TRACKED], }), ); } // Holds the next balance refresh open. The returned function lets it finish // with 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; refreshBalances.mockImplementationOnce( async (wallets, rpcUrl, explorerUrl, trackedTokens) => { const read = trackedTokens.map((t) => t.address.toLowerCase()); const tokenBalances = await new Promise((resolve) => { release = resolve; }); 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; }, ); return async (tokenBalances) => { release(tokenBalances); await page.settle(); }; } // Runs the ten-second refresh to the end, finishing it as holdNextRefresh() // does with `tokenBalances`. async function refreshOnce(page, tokenBalances) { const finish = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await finish(tokenBalances); await ticking; } // "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup // answering at once. async function addNewToken(page) { const { lookupTokenInfo } = require("../src/shared/balances"); lookupTokenInfo.mockResolvedValueOnce({ symbol: "NEW", decimals: 18, name: "New Token", }); await page.click("btn-add-token"); page.node("add-token-address").value = NEW_TOKEN; await page.click("btn-add-token-confirm"); } // Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract // lookup answering at once. It ends on SettingsAddToken. async function addOnSettingsAddToken(page) { const { lookupTokenInfo } = require("../src/shared/balances"); lookupTokenInfo.mockResolvedValueOnce({ symbol: "NEW", decimals: 18, name: "New Token", }); await page.click("btn-settings-add-token"); page.node("settings-addtoken-address").value = NEW_TOKEN; await page.click("btn-settings-addtoken-manual"); } // The "[x]" button Settings draws beside the first tracked token. The page // stub does not turn the markup Settings draws into elements. So the list // hands Settings a stand-in for that button each time it is drawn, and // `press()` presses the last one drawn. Call it before Settings opens. function firstRemoveButton(page) { const button = { press: null }; page.node("settings-tracked-tokens").querySelectorAll = () => [ { dataset: { idx: "0" }, addEventListener: (name, fn) => { button.press = fn; }, }, ]; return button; } // Picks `networkId` in the Settings network selector, as the user does. async function chooseNetwork(page, networkId) { const select = page.node("settings-network"); select.value = networkId; for (const fn of select.listeners.change) await fn(); await page.settle(); } // Another popup page saves `trackedTokens` as the tracked token list. This // page is not told of the write. function anotherPageTracks(page, trackedTokens) { page.storage.write("autistmask", { ...page.storage.read("autistmask"), trackedTokens, }); } // NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless // another is named, as markup. function newTokenRow(page, id = "address-balances") { return page .node(id) .innerHTML.split("
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'), ); } // Every address's row for NEW_TOKEN on Home, as markup, in the order Home lists // the addresses. function newTokenRowsOnHome(page) { return page .node("wallet-list") .innerHTML.split("
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'), ); } // 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("
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. function plusButton(page) { const button = { press: null }; page.node("wallet-list").querySelectorAll = (selector) => selector !== ".btn-add-address" ? [] : [ { dataset: { wallet: "0" }, addEventListener: (name, fn) => { button.press = () => fn({ stopPropagation: () => {} }); }, }, ]; return button; } // "Add wallet" on Home, filled in from `source`, and "Import". async function addWallet(page, { tab, field, secret }) { await page.click("btn-add-wallet-bottom"); await page.click(tab); page.node(field).value = secret; page.node("add-wallet-password").value = PASSWORD; page.node("add-wallet-password-confirm").value = PASSWORD; await page.click("btn-add-wallet-confirm"); } // AddressDetail for the one address opened from Home, as its `[info]` button // does. async function openAddress(page) { const { state } = require("../src/shared/state"); state.selectedWallet = 0; state.selectedAddress = 0; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressDetail").show(); await page.settle(); } // AddressToken for `token` opened from AddressDetail, as tapping the token's // row there does. async function openToken(page, token) { const { state } = require("../src/shared/state"); state.selectedToken = token; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressToken").show(); await page.settle(); } test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => { const page = await bootOnAddress(); expect(page.pageErrors).toEqual([]); const finish = holdNextRefresh(page); await addNewToken(page); expect(page.visibleViews()).toEqual(["address"]); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); await finish([MILLION]); }); test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); // Stands for the transactions on the screen, which only show() replaces. page.node("tx-list").innerHTML = "transactions on the screen"; await finish([MILLION]); expect(page.visibleViews()).toEqual(["address"]); expect(newTokenRow(page)).toContain("1000000.0000"); // Only the balances are drawn again, not the transaction list. expect(page.node("tx-list").innerHTML).toBe("transactions on the screen"); }); test("a refresh already running when the token is added does not count as reading its balance", async () => { const page = await bootOnAddress(); const finishRunning = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await addNewToken(page); await finishRunning([]); await ticking; expect(newTokenRow(page)).toContain("quantity unknown"); // The next one reads the token's balance, which is zero. await refreshOnce(page, []); expect(newTokenRow(page)).toContain("0.0000"); }); test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => { const closed = await bootOnAddress(); holdNextRefresh(closed); await addNewToken(closed); cleanupPopup(); // The refresh this boot starts reads no token's balance: the boot's stub // of refreshBalances() reads none. const page = await bootPopup(undefined, { storage: closed.storage }); expect(page.visibleViews()).toEqual(["address"]); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); await refreshOnce(page, [MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); await finish(null); // Drawn again: the ETH balance it fetched is shown. expect(page.node("address-balances").innerHTML).toContain("2.0000"); expect(newTokenRow(page)).toContain("quantity unknown"); }); test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); await openToken(page, NEW_TOKEN.toLowerCase()); 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 finish([MILLION]); expect(page.node("address-token-balance").innerHTML).toContain( "1000000.0000", ); }); test("Send for the new token reads its balance as unknown before any refresh has read it, and Max says so", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); await openToken(page, NEW_TOKEN.toLowerCase()); await page.click("btn-address-token-send"); expect(page.visibleViews()).toEqual(["send"]); expect(page.text("send-balance")).toBe("Current balance: unknown (NEW)"); await page.click("btn-send-max"); expect(page.text("flash-msg")).toBe("This token's balance is unknown."); await finish([MILLION]); }); test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => { const page = await bootPopup(unversionedValidProfile()); expect(page.visibleViews()).toEqual(["main"]); const finish = holdNextRefresh(page); await page.click("btn-settings"); await addOnSettingsAddToken(page); await page.click("btn-settings-addtoken-back"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["main"]); const onHome = newTokenRow(page, "wallet-list"); expect(onHome).toContain("quantity unknown"); expect(onHome).not.toContain("0.0000"); await openAddress(page); const onAddress = newTokenRow(page); expect(onAddress).toContain("quantity unknown"); expect(onAddress).not.toContain("0.0000"); await finish([MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh that returns while AddressToken is up draws its balance again", async () => { const page = await bootPopup( unversionedValidProfile({ currentView: "address-token", viewStack: ["main", "address"], selectedWallet: 0, selectedAddress: 0, selectedToken: "ETH", }), ); expect(page.visibleViews()).toEqual(["address-token"]); expect(page.node("address-token-balance").innerHTML).toContain("1.5000"); await refreshOnce(page, null); expect(page.node("address-token-balance").innerHTML).toContain("2.0000"); }); test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => { const page = await bootPopup(unversionedValidProfile()); const removeButton = firstRemoveButton(page); const stored = () => page.storage.read("autistmask"); await page.click("btn-settings"); await addOnSettingsAddToken(page); // Settings opened again from Home draws its list with the token in it. await page.click("btn-settings-addtoken-back"); await page.click("btn-settings-back"); await page.click("btn-settings"); await removeButton.press(); await page.settle(); expect(stored().trackedTokens).toEqual([]); // Nothing in the stored record names the token any more. expect(JSON.stringify(stored()).toLowerCase()).not.toContain( NEW_TOKEN.toLowerCase(), ); await addOnSettingsAddToken(page); // A second add of the same token is refused. page.node("settings-addtoken-address").value = NEW_TOKEN; await page.click("btn-settings-addtoken-manual"); const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 }; expect(stored().trackedTokens).toEqual([ { ...added, name: "New Token", balanceNotFetched: true }, ]); await refreshOnce(page, []); expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]); }); test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => { // Sepolia tracks a token at the same contract address, also just added. const sepoliaToken = { address: NEW_TOKEN, symbol: "SEP", name: "Sepolia Token", decimals: 18, }; const page = await bootOnAddress({ networkTrackedTokens: { sepolia: [{ ...sepoliaToken, balanceNotFetched: true }], }, }); const finishMainnet = holdNextRefresh(page); await addNewToken(page); await page.click("btn-settings"); await chooseNetwork(page, "sepolia"); // The refresh Add started returns after the switch and writes nothing. await finishMainnet([MILLION]); // Sepolia's refresh reads the balance of its own token. await refreshOnce(page, []); const stored = page.storage.read("autistmask"); expect(stored.trackedTokens).toEqual([sepoliaToken]); expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true); await chooseNetwork(page, "mainnet"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); expect(newTokenRow(page)).toContain("quantity unknown"); await refreshOnce(page, [MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh keeps a token that another page added after this page loaded", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); const otherAdded = { ...OTHER_TOKEN, balanceNotFetched: true }; anotherPageTracks(page, [ { ...NEW_TRACKED, balanceNotFetched: true }, otherAdded, ]); await finish([MILLION]); expect(page.storage.read("autistmask").trackedTokens).toEqual([ NEW_TRACKED, otherAdded, ]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh does not bring back a token that another page removed after this page loaded", async () => { const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN] }); const finish = holdNextRefresh(page); await addNewToken(page); anotherPageTracks(page, [{ ...NEW_TRACKED, balanceNotFetched: true }]); await finish([MILLION]); expect(page.storage.read("autistmask").trackedTokens).toEqual([ NEW_TRACKED, ]); }); test("an address just added reads quantity unknown for a tracked token until a refresh has read it", async () => { const page = await bootOnDerivingWallet(); const plus = plusButton(page); await refreshOnce(page, [MILLION]); const finish = holdNextRefresh(page); await plus.press(); await page.settle(); const [held, added] = newTokenRowsOnHome(page); expect(held).toContain("1000000.0000"); expect(added).toContain("quantity unknown"); expect(added).not.toContain("0.0000"); expect( page.storage.read("autistmask").trackedTokens[0].balanceNotFetched, ).toBe(true); // It reads the new address's balance, which is zero. await finish([MILLION]); expect(newTokenRowsOnHome(page)[1]).toContain("0.0000"); }); test('an address "+" adds during a refresh reads quantity unknown when that refresh returns', async () => { const page = await bootOnDerivingWallet(); const plus = plusButton(page); await refreshOnce(page, [MILLION]); const finishRunning = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await plus.press(); await page.settle(); const finishNext = holdNextRefresh(page); await finishRunning([MILLION]); await ticking; const added = newTokenRowsOnHome(page)[1]; expect(added).toContain("quantity unknown"); expect(added).not.toContain("0.0000"); // It reads the new address's balance, which is zero. await finishNext([MILLION]); expect(newTokenRowsOnHome(page)[1]).toContain("0.0000"); }); test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])( "a wallet just added from $tab reads quantity unknown for a tracked token until a refresh has read it", async (source) => { const page = await bootPopup( unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }), ); const finish = holdNextRefresh(page); await addWallet(page, source); const added = newTokenRowsOnHome(page)[1]; expect(added).toContain("quantity unknown"); expect(added).not.toContain("0.0000"); // It reads the new address's balance, which is zero. await finish([]); expect(newTokenRowsOnHome(page)[1]).toContain("0.0000"); }, ); test("a wallet from a private key added during a refresh reads quantity unknown when that refresh returns", async () => { const page = await bootPopup( unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }), ); const finishRunning = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await addWallet(page, FROM_PRIVATE_KEY); const finishNext = holdNextRefresh(page); await finishRunning([]); await ticking; const added = newTokenRowsOnHome(page)[1]; expect(added).toContain("quantity unknown"); expect(added).not.toContain("0.0000"); // It reads the new address's balance, which is zero. await finishNext([]); expect(newTokenRowsOnHome(page)[1]).toContain("0.0000"); }); test.each([FROM_PHRASE, FROM_XPRV])( "addresses the scan finds for a wallet from $tab read quantity unknown, even after a refresh returned during the scan", async (source) => { const page = await bootPopup( unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }), ); const { scanForAddresses } = require("../src/shared/balances"); let finish; scanForAddresses.mockImplementationOnce(async () => { await refreshOnce(page, []); finish = holdNextRefresh(page); return { addresses: [ { address: "0x" + "dd".repeat(20), index: 0 }, { address: "0x" + "ee".repeat(20), index: 1 }, ], nextIndex: 2, }; }); await addWallet(page, source); const [, first, second] = newTokenRowsOnHome(page); for (const found of [first, second]) { expect(found).toContain("quantity unknown"); expect(found).not.toContain("0.0000"); } await finish([]); expect(newTokenRowsOnHome(page)[2]).toContain("0.0000"); }, ); test.each([FROM_PHRASE, FROM_XPRV])( "addresses the scan finds for a wallet from $tab read quantity unknown when a refresh running as the scan ends returns", async (source) => { const page = await bootPopup( unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }), ); const { scanForAddresses } = require("../src/shared/balances"); let finishRunning; let ticking; scanForAddresses.mockImplementationOnce(async () => { finishRunning = holdNextRefresh(page); ticking = page.tick(); await page.settle(); return { addresses: [ { address: "0x" + "dd".repeat(20), index: 0 }, { address: "0x" + "ee".repeat(20), index: 1 }, ], nextIndex: 2, }; }); await addWallet(page, source); const finishNext = holdNextRefresh(page); await finishRunning([]); await ticking; const [, first, second] = newTokenRowsOnHome(page); for (const found of [first, second]) { expect(found).toContain("quantity unknown"); expect(found).not.toContain("0.0000"); } // It reads the new addresses' balances, which are zero. await finishNext([]); expect(newTokenRowsOnHome(page)[2]).toContain("0.0000"); }, ); test("after a network switch in Settings, a tracked token reads quantity unknown until a refresh has read it", async () => { const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN], networkTrackedTokens: { sepolia: [NEW_TRACKED] }, }); await page.click("btn-settings"); await chooseNetwork(page, "sepolia"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); // It reads the balance, which is zero. await refreshOnce(page, []); expect(newTokenRow(page)).toContain("0.0000"); }); test("after a switch saved by the background, a tracked token reads quantity unknown until a refresh has read it", async () => { const page = await bootOnAddress({ networkTrackedTokens: { sepolia: [NEW_TRACKED] }, }); const { applyChainSwitchFields, } = require("../src/shared/chainSwitchFields"); const record = page.storage.read("autistmask"); applyChainSwitchFields(record, "sepolia"); page.storage.write("autistmask", record); // The popup takes the switch, and this refresh reads no token's balance. await page.tick(); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); }); 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."); }, );