// What a dApp is told when the wallet's stored profile cannot be read // (https://git.eeqj.de/sneak/AutistMask/issues/311). // // The popup is not the only casualty of a bad blob. getActiveAddress() // dereferences the stored wallet list on nearly every method, so against the // build this file was added to, EVERY request from EVERY page came back as // -32603 "AutistMask could not complete this request because of an internal // error" — the code the wallet also answers when a signing attempt blows up, // with nothing in it to tell the page or the user what is actually wrong or // what to do about it. // // So what is pinned here is that the answer is SPECIFIC: its own code, and a // message that says the saved data cannot be read, that nothing was signed or // sent, and where to go to fix it. // // Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state // modules, over a storage stub, with no loadState() of the test's own — the // handler has to reach storage by itself, as a worker revived by the page's // own message does. const CONNECTED_ORIGIN = "https://dapp.example"; const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // The generic answer, quoted rather than imported: this file's whole point is // that the state-unusable path stopped using it. const GENERIC_INTERNAL_ERROR_CODE = -32603; // EIP-1474's assigned non-standard codes, verbatim. The spec sets aside // -32000..-32099 for implementation-defined server errors but hands out // meanings for the first seven, so those are exactly the codes this condition // may NOT take: a page reading -32001 is entitled to read "Resource not // found". -32002 is in this table AND in use here, for a pending approval. const EIP_1474_ASSIGNED = { "-32000": "Invalid input", "-32001": "Resource not found", "-32002": "Resource unavailable", "-32003": "Transaction rejected", "-32004": "Method not supported", "-32005": "Limit exceeded", "-32006": "JSON-RPC version not supported", }; // The three blobs from the issue. const CORRUPT_BLOBS = [ { name: "wallets is a string", blob: { hasWallet: true, wallets: ADDRESS }, }, { name: "wallets is an array of garbage", blob: { hasWallet: true, wallets: [null, 42, "wallet"] }, }, { name: "future-schema blob (unknown fields, no version)", blob: { hasWallet: true, wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }], profileFormat: "am-2", }, }, ]; async function settle() { for (let i = 0; i < 50; i++) await Promise.resolve(); } afterEach(() => { delete global.chrome; }); function loadColdWorker(stored) { jest.resetModules(); jest.doMock("../src/shared/balances", () => ({ getProvider: () => ({}), refreshBalances: jest.fn(async () => {}), })); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, })); jest.doMock("../src/shared/alarms", () => ({ BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_PERIOD_MINUTES: 1, ensureRecurringAlarms: jest.fn(async () => {}), registerAlarmHandlers: jest.fn(), })); const store = { autistmask: structuredClone(stored) }; let messageListener = null; const set = jest.fn(async (items) => { store.autistmask = structuredClone(items.autistmask); }); global.chrome = { storage: { local: { get: jest.fn(async () => structuredClone(store)), set, }, }, runtime: { getURL: (p) => "chrome-extension://autistmask/" + p, onMessage: { addListener: (fn) => { messageListener = fn; }, }, onConnect: { addListener: () => {} }, lastError: null, }, windows: { getLastFocused: (cb) => cb(null), create: (options, cb) => cb({ id: 1 }), remove: (id, cb) => { if (cb) cb(); }, onRemoved: { addListener: () => {} }, }, tabs: { query: (queryInfo, cb) => cb([{ id: 1 }]), sendMessage: (tabId, message, cb) => { if (cb) cb(); }, }, action: { setPopup: () => {} }, }; require("../src/background/index"); async function rpc(method, params) { let result = null; messageListener( { type: "AUTISTMASK_RPC", method, params: params || [] }, { origin: CONNECTED_ORIGIN }, (r) => { result = r; }, ); await settle(); return result; } return { rpc, persisted: () => store.autistmask, storageSet: set }; } // Every method a page can reach that has to consult the profile. const METHODS = [ "eth_accounts", "eth_requestAccounts", "eth_chainId", "personal_sign", "eth_sendTransaction", ]; describe("a dApp call against a profile the wallet cannot read", () => { for (const { name, blob } of CORRUPT_BLOBS) { test(`${name}: a specific error, not the generic internal one`, async () => { const bg = loadColdWorker(blob); const answer = await bg.rpc("eth_accounts"); expect(answer.error).toBeDefined(); expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE); // The message has to say what is wrong, that nothing was sent, // and where to go. "Internal error" says none of the three. expect(answer.error.message).toMatch(/saved data/i); expect(answer.error.message).toMatch(/nothing was/i); expect(answer.error.message).toMatch(/AutistMask/); }); } test("every method that consults the profile answers the same way", async () => { const bg = loadColdWorker(CORRUPT_BLOBS[0].blob); const codes = new Set(); for (const method of METHODS) { const answer = await bg.rpc(method, ["0x00", ADDRESS]); expect(answer.error).toBeDefined(); codes.add(answer.error.code); } // One code for the condition, whatever the method was. expect(codes.size).toBe(1); expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false); // And it is a code EIP-1474 has not already given a meaning to, so a // page reading it by the spec's table is not told something false. const code = [...codes][0]; expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined(); expect(code).toBeLessThanOrEqual(-32007); expect(code).toBeGreaterThanOrEqual(-32099); }); test("it does not write over the record it could not read", async () => { const bg = loadColdWorker(CORRUPT_BLOBS[1].blob); await bg.rpc("eth_accounts"); await bg.rpc("eth_chainId"); expect(bg.storageSet).not.toHaveBeenCalled(); expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob); }); });