fix: explain a stored non-master xprv wallet instead of throwing at signing time (closes #234)
All checks were successful
check / check (push) Successful in 30s
All checks were successful
check / check (push) Successful in 30s
This commit was merged in pull request #247.
This commit is contained in:
290
tests/walletDefects.test.js
Normal file
290
tests/walletDefects.test.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// Tests for the stored-state half of the non-master extended key problem.
|
||||
//
|
||||
// Refusing a non-master xprv at import time does nothing for a wallet that is
|
||||
// already in storage: the import that created it ran before the refusal
|
||||
// existed. Such a wallet used to sign for the wrong tree and now throws on the
|
||||
// send screen instead. These tests pin down that it is named and explained in
|
||||
// the wallet list, that nothing on the way there throws, and that a wallet
|
||||
// imported from a real master key is untouched by any of it.
|
||||
|
||||
const { HDNodeWallet, Mnemonic } = require("ethers");
|
||||
|
||||
const wallet = require("../src/shared/wallet");
|
||||
const {
|
||||
walletDefect,
|
||||
walletDefectHtml,
|
||||
NON_MASTER_XPRV,
|
||||
} = require("../src/shared/walletDefects");
|
||||
|
||||
// BIP-39 test vector phrase, published; never used for real funds.
|
||||
const VECTOR_PHRASE =
|
||||
"test test test test test test test test test test test junk";
|
||||
|
||||
function seedNode(phrase) {
|
||||
return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed());
|
||||
}
|
||||
|
||||
// The master (depth-0) key, which is what the import flow accepts today.
|
||||
function masterXprv(phrase) {
|
||||
return seedNode(phrase).extendedKey;
|
||||
}
|
||||
|
||||
// The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to
|
||||
// hold, and what the import flow used to accept.
|
||||
function accountXprv(phrase) {
|
||||
return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey;
|
||||
}
|
||||
|
||||
// The wallet record the CURRENT import path writes for a master key: the
|
||||
// neutered m/44'/60'/0'/0 node, four levels below a depth-0 key.
|
||||
function healthyXprvWallet(name = "Master") {
|
||||
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
return {
|
||||
name,
|
||||
type: "xprv",
|
||||
xpub,
|
||||
nextIndex: 1,
|
||||
encryptedSecret: "irrelevant-to-these-tests",
|
||||
addresses: [{ address: firstAddress, balance: "0.0000" }],
|
||||
};
|
||||
}
|
||||
|
||||
// The wallet record the PRE-#210 import path wrote for an account-level key:
|
||||
// the same four levels, but derived as a relative path *beneath* the key, so
|
||||
// the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code
|
||||
// built it rather than by calling the module under test, which now refuses.
|
||||
function brokenXprvWallet(name = "Imported xprv") {
|
||||
const node = HDNodeWallet.fromExtendedKey(
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
).derivePath("44'/60'/0'/0");
|
||||
return {
|
||||
name,
|
||||
type: "xprv",
|
||||
xpub: node.neuter().extendedKey,
|
||||
nextIndex: 1,
|
||||
encryptedSecret: "irrelevant-to-these-tests",
|
||||
addresses: [
|
||||
{ address: node.deriveChild(0).address, balance: "0.0000" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("the fixtures are what the two import paths actually produced", () => {
|
||||
test("a master import stores a depth-4 xpub", () => {
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth,
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
test("the pre-fix account-level import stored a depth-7 xpub", () => {
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth,
|
||||
).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletDefect", () => {
|
||||
test("names the defect on a stored non-master xprv wallet", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
|
||||
expect(defect).not.toBeNull();
|
||||
expect(defect.id).toBe(NON_MASTER_XPRV);
|
||||
});
|
||||
|
||||
test("a depth-0 xprv wallet has no defect", () => {
|
||||
expect(walletDefect(healthyXprvWallet())).toBeNull();
|
||||
});
|
||||
|
||||
test("hd and key wallets are never assessed", () => {
|
||||
expect(
|
||||
walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }),
|
||||
).toBe(null);
|
||||
expect(walletDefect({ type: "key" })).toBeNull();
|
||||
});
|
||||
|
||||
test("an xprv wallet whose xpub cannot be parsed makes no claim", () => {
|
||||
// No basis to say the key was non-master, so nothing is asserted
|
||||
// about it rather than guessing.
|
||||
expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull();
|
||||
expect(walletDefect({ type: "xprv" })).toBeNull();
|
||||
});
|
||||
|
||||
test("nothing about the wallet record is modified by the check", () => {
|
||||
const w = brokenXprvWallet();
|
||||
const before = JSON.stringify(w);
|
||||
|
||||
walletDefect(w);
|
||||
|
||||
expect(JSON.stringify(w)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the explanatory copy", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
|
||||
test("every sentence of it is a full sentence", () => {
|
||||
for (const text of [defect.heading, ...defect.paragraphs]) {
|
||||
expect(text).toMatch(/^[A-Z]/);
|
||||
expect(text.trimEnd()).toMatch(/\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("it says what was derived wrongly and that these are not the standard addresses", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toContain("not a master key");
|
||||
expect(body).toMatch(/standard path/);
|
||||
});
|
||||
|
||||
test("it does not claim the funds are safe and does not claim a loss", () => {
|
||||
const all = [defect.heading, ...defect.paragraphs].join(" ");
|
||||
|
||||
expect(all).not.toMatch(/\bsafe\b/i);
|
||||
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
|
||||
});
|
||||
|
||||
test("it says the wallet is not deleted and what the user can do", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toMatch(/until you delete it yourself/);
|
||||
expect(body).toMatch(/recovery phrase/);
|
||||
});
|
||||
|
||||
test("it uses the project's vocabulary", () => {
|
||||
const all = [
|
||||
defect.heading,
|
||||
...defect.paragraphs,
|
||||
defect.shortMessage,
|
||||
].join(" ");
|
||||
|
||||
expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i);
|
||||
expect(all).not.toMatch(/\baccounts?\b/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletDefectHtml", () => {
|
||||
test("renders the heading and every paragraph for a defective wallet", () => {
|
||||
const defect = walletDefect(brokenXprvWallet());
|
||||
const html = walletDefectHtml(brokenXprvWallet());
|
||||
|
||||
expect(html).toContain(defect.heading);
|
||||
for (const p of defect.paragraphs) {
|
||||
expect(html).toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
test("renders nothing at all for a healthy wallet", () => {
|
||||
expect(walletDefectHtml(healthyXprvWallet())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wallet list", () => {
|
||||
let home;
|
||||
let state;
|
||||
|
||||
beforeAll(() => {
|
||||
global.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
runtime: { sendMessage: () => {} },
|
||||
};
|
||||
home = require("../src/popup/views/home");
|
||||
state = require("../src/shared/state").state;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.wallets = [];
|
||||
state.activeAddress = null;
|
||||
});
|
||||
|
||||
test("a stored depth-3 xprv wallet renders the explanation", () => {
|
||||
state.wallets = [brokenXprvWallet("Imported xprv")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
|
||||
expect(html).toContain(walletDefect(state.wallets[0]).heading);
|
||||
expect(html).toContain("Imported xprv");
|
||||
});
|
||||
|
||||
test("it does not offer to derive further addresses from that wallet", () => {
|
||||
state.wallets = [brokenXprvWallet()];
|
||||
|
||||
expect(home.walletListHtml()).not.toContain("btn-add-address");
|
||||
});
|
||||
|
||||
test("a normal depth-0 xprv wallet renders exactly as it did before", () => {
|
||||
state.wallets = [healthyXprvWallet("Master")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
|
||||
expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading);
|
||||
expect(html).toContain("btn-add-address");
|
||||
expect(html).toContain(state.wallets[0].addresses[0].address);
|
||||
});
|
||||
|
||||
test("the defective wallet's notice does not bleed onto a healthy one", () => {
|
||||
state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")];
|
||||
|
||||
const html = home.walletListHtml();
|
||||
const healthyPart = html.slice(html.indexOf("Fine"));
|
||||
|
||||
expect(html).toContain(walletDefect(state.wallets[0]).heading);
|
||||
expect(healthyPart).not.toContain(
|
||||
walletDefect(state.wallets[0]).heading,
|
||||
);
|
||||
expect(healthyPart).toContain("btn-add-address");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no path throws an unhandled error for a defective wallet", () => {
|
||||
test("address derivation from the stored xpub still works", () => {
|
||||
// The stored xpub is at a non-standard depth but is a valid extended
|
||||
// key; deriving from it is what the list render already does.
|
||||
const w = brokenXprvWallet();
|
||||
|
||||
expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow();
|
||||
expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe(
|
||||
w.addresses[0].address,
|
||||
);
|
||||
});
|
||||
|
||||
test("the wallet list renders without throwing", () => {
|
||||
const { state } = require("../src/shared/state");
|
||||
const home = require("../src/popup/views/home");
|
||||
state.wallets = [brokenXprvWallet()];
|
||||
|
||||
expect(() => home.walletListHtml()).not.toThrow();
|
||||
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
test("signing refuses with the named defect rather than a bare failure", () => {
|
||||
// getSignerForAddress is the backstop behind the UI gate. It must
|
||||
// still refuse, and it must say why in a sentence the user can read.
|
||||
let thrown = null;
|
||||
try {
|
||||
wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
expect(thrown).not.toBeNull();
|
||||
expect(thrown.message).toMatch(/master key/);
|
||||
expect(thrown.message.trimEnd()).toMatch(/\.$/);
|
||||
});
|
||||
|
||||
test("a healthy xprv wallet signs as it always did", () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user