diff --git a/README.md b/README.md index 975c2ce..c01e6bf 100644 --- a/README.md +++ b/README.md @@ -1531,8 +1531,13 @@ view would leave a wallet one click from deletion. - **Transitions**: - "Import" with a valid entry and a matching password of at least 12 characters → creates the wallet, clears the navigation stack, and → - **Home**. The phrase and xprv modes then scan for further used addresses - and report the count as a flash message. + **Home**. When no address is active yet, as on the first wallet, the new + wallet's first address becomes the active one. The phrase and xprv modes + then scan for further used addresses and report the count as a flash + message. When the scan finds two or more used addresses, they replace the + wallet's list, which then holds the first address only if it was used. If + the first address was active and is not in the new list, the new list's + first address becomes the active one. - "Import" with a missing, short or mismatched password → full-sentence error on the error line, no screen change - "Import" with an invalid entry or a duplicate wallet or address → flash diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js index 660061a..1e5a8e5 100644 --- a/src/popup/views/addWallet.js +++ b/src/popup/views/addWallet.js @@ -20,6 +20,7 @@ const { const { encryptWithPassword } = require("../../shared/vault"); const { state, saveState } = require("../../shared/state"); const { scanForAddresses } = require("../../shared/balances"); +const { notify } = require("../../shared/browserApi"); /** * Check if an address already exists in ANY wallet (hd, xprv, or key). @@ -194,6 +195,9 @@ async function importMnemonic(ctx) { }; state.wallets.push(wallet); state.hasWallet = true; + if (state.activeAddress === null) { + state.activeAddress = firstAddress; + } await saveState(); clearViewStack(); ctx.renderWalletList(); @@ -215,8 +219,16 @@ async function importMnemonic(ctx) { tokenBalances: [], })); wallet.nextIndex = scan.nextIndex; + // The new list leaves out the first address when the scan found it + // unused. If it was the active one, the new list's first takes over, + // and the background is told as when the user picks one on Home. + const activeMoved = + state.activeAddress === firstAddress && + wallet.addresses[0].address !== firstAddress; + if (activeMoved) state.activeAddress = wallet.addresses[0].address; await saveState(); ctx.renderWalletList(); + if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" }); showFlash("Found " + scan.addresses.length + " addresses."); } else { showFlash("Ready.", 1000); @@ -254,6 +266,9 @@ async function importPrivateKey(ctx) { addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }], }); state.hasWallet = true; + if (state.activeAddress === null) { + state.activeAddress = addr; + } await saveState(); clearViewStack(); ctx.renderWalletList(); @@ -315,6 +330,9 @@ async function importXprvKey(ctx) { }; state.wallets.push(wallet); state.hasWallet = true; + if (state.activeAddress === null) { + state.activeAddress = firstAddress; + } await saveState(); clearViewStack(); ctx.renderWalletList(); @@ -336,8 +354,15 @@ async function importXprvKey(ctx) { tokenBalances: [], })); wallet.nextIndex = scan.nextIndex; + // As for a recovery phrase: an active first address the scan left + // out gives way to the new list's first. + const activeMoved = + state.activeAddress === firstAddress && + wallet.addresses[0].address !== firstAddress; + if (activeMoved) state.activeAddress = wallet.addresses[0].address; await saveState(); ctx.renderWalletList(); + if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" }); showFlash("Found " + scan.addresses.length + " addresses."); } else { showFlash("Ready.", 1000); diff --git a/tests/firstWalletActiveAddress.test.js b/tests/firstWalletActiveAddress.test.js new file mode 100644 index 0000000..c3e5593 --- /dev/null +++ b/tests/firstWalletActiveAddress.test.js @@ -0,0 +1,131 @@ +// Adding the first wallet left no address active until the popup was reopened, +// so Send and Receive on Home only said "No active address selected." +// (https://git.eeqj.de/sneak/AutistMask/issues/512). Each test adds a wallet +// through one of the three import tabs and checks which address is active by +// opening Receive from Home in the same popup. + +const { HDNodeWallet, Mnemonic, Wallet } = require("ethers"); + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, +} = require("./support/popupBoot"); + +const PHRASE = + "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about"; +const PRIVATE_KEY = "0x" + "11".repeat(32); +// The master key of PHRASE, so its first address is the phrase's. +const XPRV = HDNodeWallet.fromSeed( + Mnemonic.fromPhrase(PHRASE).computeSeed(), +).extendedKey; +const PASSWORD = "correct horse battery staple"; + +const PHRASE_ADDRESS = HDNodeWallet.fromPhrase(PHRASE).address; + +const TABS = [ + { + tab: "tab-mnemonic", + field: "wallet-mnemonic", + secret: PHRASE, + address: PHRASE_ADDRESS, + }, + { + tab: "tab-privkey", + field: "import-private-key", + secret: PRIVATE_KEY, + address: new Wallet(PRIVATE_KEY).address, + }, + { + tab: "tab-xprv", + field: "import-xprv-key", + secret: XPRV, + address: PHRASE_ADDRESS, + }, +]; + +// Fills in the open AddWallet screen and presses "Import". +async function importOn(page, { tab, field, secret }) { + 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"); +} + +afterEach(() => { + cleanupPopup(); +}); + +describe("the first wallet's first address is active right away", () => { + test.each(TABS)("added from $tab, Receive opens on it", async (entry) => { + const page = await bootPopup(undefined); + expect(page.pageErrors).toEqual([]); + await page.click("btn-welcome-add"); + await importOn(page, entry); + expect(page.visibleViews()).toEqual(["main"]); + + await page.click("btn-main-receive"); + expect(page.visibleViews()).toEqual(["receive"]); + expect(page.node("receive-address-block").dataset.full).toBe( + entry.address, + ); + }); +}); + +describe("a scan that leaves out the first address", () => { + // Addresses 1 and 2 of PHRASE used, address 0 not: the scan's list + // replaces the wallet's and does not hold the address made active. + const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0"); + const used = [1, 2].map((index) => ({ + address: account.deriveChild(index).address, + index, + })); + + // The key tab runs no scan. + test.each(TABS.filter((t) => t.tab !== "tab-privkey"))( + "added from $tab, the scan's first address is active", + async (entry) => { + const page = await bootPopup(undefined); + const { scanForAddresses } = require("../src/shared/balances"); + scanForAddresses.mockResolvedValueOnce({ + addresses: used, + nextIndex: 3, + }); + await page.click("btn-welcome-add"); + await importOn(page, entry); + + expect(page.storage.read("autistmask").activeAddress).toBe( + used[0].address, + ); + expect(globalThis.chrome.runtime.sendMessage).toHaveBeenCalledWith({ + type: "AUTISTMASK_ACTIVE_CHANGED", + }); + await page.click("btn-main-receive"); + expect(page.node("receive-address-block").dataset.full).toBe( + used[0].address, + ); + }, + ); +}); + +describe("adding a wallet while an address is active", () => { + test.each(TABS)( + "added from $tab, the active address is unchanged", + async (entry) => { + const page = await bootPopup(unversionedValidProfile()); + expect(page.pageErrors).toEqual([]); + await page.click("btn-add-wallet-bottom"); + await importOn(page, entry); + expect(page.visibleViews()).toEqual(["main"]); + + expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS); + await page.click("btn-main-receive"); + expect(page.node("receive-address-block").dataset.full).toBe( + ADDRESS, + ); + }, + ); +}); diff --git a/tests/support/popupBoot.js b/tests/support/popupBoot.js index 308d0d4..06938cf 100644 --- a/tests/support/popupBoot.js +++ b/tests/support/popupBoot.js @@ -258,12 +258,17 @@ async function bootPopup(stored, options) { formatAddressTotal: () => "", getAddressValue: () => ({ usd: null, partial: false }), })); + // scanForAddresses() answers in the real one's shape, a scan that found + // no used address: AddWallet reads `addresses` from it. jest.doMock("../../src/shared/balances", () => ({ fetchTokenBalances: jest.fn(async () => []), refreshBalances: jest.fn(async () => {}), lookupTokenInfo: jest.fn(async () => null), getProvider: () => ({}), - scanForAddresses: jest.fn(async () => []), + scanForAddresses: jest.fn(async () => ({ + addresses: [], + nextIndex: 1, + })), })); // filterTransactions() answers in the real one's shape: Home, // AddressDetail and AddressToken read both fields, and a bare list makes