fix: the first wallet's first address is active as soon as it is added (closes #512)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

The popup picked an active address only when it opened, so after the first
wallet was created nothing was active until a reopen: Home had no headline,
and Send and Receive refused. Each of the three import paths on AddWallet now
makes the new wallet's first address active when no address is. When the
phrase or xprv scan then replaces the address list with one that leaves that
address out, the new list's first address becomes active and the background
is told, as when the user picks an address on Home. The address scan stand-in
in the popup test boot now answers in the real scan's shape, which the phrase
and xprv imports read.

Model: opus-5-5
This commit was merged in pull request #562.
This commit is contained in:
2026-10-16 07:15:29 +02:00
parent aa10a2a5f5
commit e48308c5b6
4 changed files with 180 additions and 3 deletions
+8 -2
View File
@@ -1552,8 +1552,14 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and → characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses **Home**. When no address is active yet, as on the first wallet, the new
and report the count as a flash message. 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 and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "Import" with a missing, short or mismatched password → full-sentence - "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change error on the error line, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash - "Import" with an invalid entry or a duplicate wallet or address → flash
+25
View File
@@ -20,6 +20,7 @@ const {
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances"); const { scanForAddresses } = require("../../shared/balances");
const { notify } = require("../../shared/browserApi");
/** /**
* Check if an address already exists in ANY wallet (hd, xprv, or key). * 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.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -215,8 +219,16 @@ async function importMnemonic(ctx) {
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; 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(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses."); showFlash("Found " + scan.addresses.length + " addresses.");
} else { } else {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
@@ -254,6 +266,9 @@ async function importPrivateKey(ctx) {
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }], addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
}); });
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -315,6 +330,9 @@ async function importXprvKey(ctx) {
}; };
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -336,8 +354,15 @@ async function importXprvKey(ctx) {
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; 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(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses."); showFlash("Found " + scan.addresses.length + " addresses.");
} else { } else {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
+141
View File
@@ -0,0 +1,141 @@
// 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;
// Addresses 1 and 2 of PHRASE used, address 0 not: a scan that answers these
// replaces the wallet's list with one that leaves out its first address.
const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0");
const used = [1, 2].map((index) => ({
address: account.deriveChild(index).address,
index,
}));
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", () => {
// 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([]);
// The phrase and xprv scans leave out the new wallet's first
// address, which was never active here. The key tab runs no scan.
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-add-wallet-bottom");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS);
expect(
globalThis.chrome.runtime.sendMessage,
).not.toHaveBeenCalledWith({ type: "AUTISTMASK_ACTIVE_CHANGED" });
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
ADDRESS,
);
},
);
});
+6 -1
View File
@@ -265,12 +265,17 @@ async function bootPopup(stored, options) {
formatAddressTotal: () => "", formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }), 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", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null), lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}), getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []), scanForAddresses: jest.fn(async () => ({
addresses: [],
nextIndex: 1,
})),
})); }));
// filterTransactions() answers in the real one's shape: Home, // filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes // AddressDetail and AddressToken read both fields, and a bare list makes