1 Commits
Author SHA1 Message Date
clawbot cb02304417 fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, and a token holding cut to zero was dropped, so a
value below 0.000001 read as zero. Balances are now stored exactly, whatever
decimals a token declares, and every nonzero token holding is kept; the balance
check reads a token balance to its first 18 places. The balance lists, the
send-screen token selector, the address total and the remove-address warning
leave out a holding below 0.000001 themselves, through isBelowOneMillionth().
The send and send-confirm screens' balances, reserve and insufficient-balance
messages go through truncateAmountNeverZero(). The send-confirm and approval
screens both render the fee through formatFee(), which prices the exact fee in
USD.

Model: opus-5-5
2026-10-04 06:57:23 +00:00
16 changed files with 92 additions and 341 deletions
-20
View File
@@ -62,26 +62,6 @@ but the review is broader than any of them.
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
prices the exact fee in USD, so the same fee reads the same on both, USD value prices the exact fee in USD, so the same fee reads the same on both, USD value
included. included.
- 2026-10-04: The flash line keeps to the one line it reserves at any message
length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message
that wrapped pushed the whole screen below it down. `#flash-msg` no longer
wraps: text too long for the line is cut with an ellipsis, and `showFlash()`
puts the whole message in the line's title. Every message is also reworded to
at most 50 characters so none is cut; none carries a wallet name or text from
a server, and the add-token screens flash a fixed line for any error other
than a contract that is not a token. A new test in `tests/e2e/run.js` puts a
message several lines long on the line and fails if the line or the screen
below it moves. The two approval-screen error boxes are left to
[#297](https://git.eeqj.de/sneak/AutistMask/issues/297).
- 2026-10-04: A method the wallet does not implement is refused with EIP-1193
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
background's `Unsupported method: <method>` error carried no code, so a site
probing for an optional method could not tell "not implemented" from "the call
failed". The message is unchanged; the background's other errors with no code
are untouched.
- 2026-10-04: Settings lists the sites connected without "Remember", and - 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection ([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
+1 -3
View File
@@ -932,9 +932,7 @@ async function handleRpc(method, params, origin) {
} }
} }
// EIP-1193 4200 lets a site tell "this wallet does not implement that" return { error: { message: "Unsupported method: " + method } };
// from "that call failed", and fall back.
return { error: { code: 4200, message: "Unsupported method: " + method } };
} }
// The body of eth_sendTransaction, from the connection check through to the // The body of eth_sendTransaction, from the connection check through to the
+5 -6
View File
@@ -31,12 +31,11 @@
// an error instead of accepting the refusal. // an error instead of accepting the refusal.
// //
// Whatever code arrived is passed through verbatim rather than being // Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits codes such as 4001, 4100, // matched against a list: the extension emits 4001, 4100 and 4902 today,
// 4200 and 4902, and a code this file has never heard of is still the // and a code this file has never heard of is still the truth about what
// truth about what happened. An error reported with no code at all stays // happened. An error reported with no code at all stays a plain Error —
// a plain Error — a ProviderRpcError whose `code` is undefined would // a ProviderRpcError whose `code` is undefined would advertise a
// advertise a conformance it does not have. `message` is untouched in // conformance it does not have. `message` is untouched in every case.
// every case.
function toPageError(error) { function toPageError(error) {
const message = (error && error.message) || "Request failed"; const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) { if (error && error.code !== undefined && error.code !== null) {
+7 -3
View File
@@ -19,9 +19,13 @@
// that the user did not type — the same silent substitution the visible // that the user did not type — the same silent substitution the visible
// rejection message exists to end. // rejection message exists to end.
// Must render on ONE line of #flash-msg; see showFlash() in // Must render on ONE line of #flash-msg, whose reserved height
// src/popup/views/helpers.js for how long that is. // (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater."; // wrap to two lines pushes the settings view down, which the No Layout Shift
// policy forbids. Do not lengthen this without re-running the layout test in
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
const DUST_THRESHOLD_MESSAGE =
"Please enter a whole number of gwei, zero or greater.";
// Returns the threshold in gwei, or null if the input is not one. // Returns the threshold in gwei, or null if the input is not one.
function parseDustThresholdGwei(raw) { function parseDustThresholdGwei(raw) {
+1 -1
View File
@@ -33,7 +33,7 @@
<!-- ============ FLASH MESSAGE AREA ============ --> <!-- ============ FLASH MESSAGE AREA ============ -->
<div <div
id="flash-msg" id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1 truncate" class="text-xs text-muted min-h-[1.25rem] mb-1"
></div> ></div>
<!-- ============ WELCOME / FIRST USE ============ --> <!-- ============ WELCOME / FIRST USE ============ -->
+5 -10
View File
@@ -28,7 +28,9 @@ function init(ctx) {
$("btn-add-token-confirm").addEventListener("click", async () => { $("btn-add-token-confirm").addEventListener("click", async () => {
const contractAddr = $("add-token-address").value.trim(); const contractAddr = $("add-token-address").value.trim();
if (!contractAddr || !contractAddr.startsWith("0x")) { if (!contractAddr || !contractAddr.startsWith("0x")) {
showFlash("Enter a valid contract address starting with 0x."); showFlash(
"Please enter a valid contract address starting with 0x.",
);
return; return;
} }
const already = state.trackedTokens.find( const already = state.trackedTokens.find(
@@ -69,15 +71,8 @@ function init(ctx) {
require("./addressDetail").show(); require("./addressDetail").show();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", contractAddr, detail); log.errorf("Token lookup failed for", contractAddr, detail);
// lookupTokenInfo() rejects a contract with a one-line message showFlash(detail);
// starting "Not a valid ERC-20 token". Any other error, such as a
// failed save, can be far longer, so it is only logged.
showFlash(
detail.startsWith("Not a valid ERC-20 token")
? detail
: "Could not add the token.",
);
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+23 -10
View File
@@ -142,13 +142,15 @@ function validatePassword() {
async function importMnemonic(ctx) { async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) { if (!mnemonic) {
showFlash("Enter a recovery phrase, or press the die."); showFlash("Enter a recovery phrase or press the die to generate one.");
return; return;
} }
const words = mnemonic.split(/\s+/); const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) { if (words.length !== 12 && words.length !== 24) {
showFlash( showFlash(
"Recovery phrase must be 12 or 24 words, not " + words.length + ".", "Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
); );
return; return;
} }
@@ -161,12 +163,14 @@ async function importMnemonic(ctx) {
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const xpubDup = findWalletByXpub(xpub); const xpubDup = findWalletByXpub(xpub);
if (xpubDup) { if (xpubDup) {
showFlash("This recovery phrase is already added."); showFlash(
"This recovery phrase is already added (" + xpubDup.name + ").",
);
return; return;
} }
const addrDup = findWalletByAddress(firstAddress); const addrDup = findWalletByAddress(firstAddress);
if (addrDup) { if (addrDup) {
showFlash("Address already exists in a wallet."); showFlash("Address already exists in wallet (" + addrDup.name + ").");
return; return;
} }
const encrypted = await encryptWithPassword(mnemonic, pw); const encrypted = await encryptWithPassword(mnemonic, pw);
@@ -225,7 +229,9 @@ async function importPrivateKey(ctx) {
if (!pw) return; if (!pw) return;
const duplicate = findWalletByAddress(addr); const duplicate = findWalletByAddress(addr);
if (duplicate) { if (duplicate) {
showFlash("This address already exists in a wallet."); showFlash(
"This address already exists in wallet (" + duplicate.name + ").",
);
return; return;
} }
const encrypted = await encryptWithPassword(key, pw); const encrypted = await encryptWithPassword(key, pw);
@@ -252,29 +258,36 @@ async function importXprvKey(ctx) {
return; return;
} }
if (!isValidXprv(xprv)) { if (!isValidXprv(xprv)) {
showFlash("That extended private key is not valid."); showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return; return;
} }
if (!isMasterExtendedKey(xprv)) { if (!isMasterExtendedKey(xprv)) {
showFlash("Please paste the master key, not a child key."); showFlash(
"That is an account-level or child key, which cannot be imported. " +
"Please paste the master extended private key for the wallet.",
);
return; return;
} }
let result; let result;
try { try {
result = hdWalletFromXprv(xprv); result = hdWalletFromXprv(xprv);
} catch { } catch {
showFlash("That extended private key is not valid."); showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return; return;
} }
const { xpub, firstAddress } = result; const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub); const xpubDup = findWalletByXpub(xpub);
if (xpubDup) { if (xpubDup) {
showFlash("This key is already added."); showFlash("This key is already added (" + xpubDup.name + ").");
return; return;
} }
const addrDup = findWalletByAddress(firstAddress); const addrDup = findWalletByAddress(firstAddress);
if (addrDup) { if (addrDup) {
showFlash("Address already exists in a wallet."); showFlash("Address already exists in wallet (" + addrDup.name + ").");
return; return;
} }
const pw = validatePassword(); const pw = validatePassword();
+4 -8
View File
@@ -228,19 +228,15 @@ function clearFlash() {
flashTimer = null; flashTimer = null;
} }
$("flash-msg").textContent = ""; $("flash-msg").textContent = "";
$("flash-msg").title = "";
} }
// The flash line reserves exactly one line, and a message that wrapped would
// push the screen below it down (README, No Layout Shift). So #flash-msg never
// wraps: text too long for the line is cut with an ellipsis, and the whole
// message is also put in the line's title. Write messages to fit, at most 50
// characters, so none is cut.
function showFlash(msg, duration = 2000) { function showFlash(msg, duration = 2000) {
clearFlash(); clearFlash();
$("flash-msg").textContent = msg; $("flash-msg").textContent = msg;
$("flash-msg").title = msg; flashTimer = setTimeout(() => {
flashTimer = setTimeout(clearFlash, duration); $("flash-msg").textContent = "";
flashTimer = null;
}, duration);
} }
// A stored token balance as a number, or null when there is no number in it. // A stored token balance as a number, or null when there is no number in it.
+3 -3
View File
@@ -67,13 +67,13 @@ function validateToAddress(value) {
if (checksummed !== v) { if (checksummed !== v) {
return { return {
valid: false, valid: false,
error: "Address checksum is invalid. Check the address.", error: "Address checksum is invalid. Please double-check the address.",
}; };
} }
} catch { } catch {
return { return {
valid: false, valid: false,
error: "Address checksum is invalid. Check the address.", error: "Address checksum is invalid. Please double-check the address.",
}; };
} }
} }
@@ -224,7 +224,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to); const resolved = await provider.resolveName(to);
if (!resolved) { if (!resolved) {
showFlash("That ENS name has no address."); showFlash("Could not resolve " + to);
return; return;
} }
resolvedTo = resolved; resolvedTo = resolved;
+8 -2
View File
@@ -264,12 +264,18 @@ function init(ctx) {
const json = await resp.json(); const json = await resp.json();
if (json.error) { if (json.error) {
log.errorf("RPC validation error:", json.error); log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error."); showFlash("Endpoint returned error: " + json.error.message);
return; return;
} }
const net = currentNetwork(); const net = currentNetwork();
if (json.result !== net.chainId) { if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + "."); showFlash(
"Wrong network (expected " +
net.name +
", got chain " +
json.result +
").",
);
return; return;
} }
} catch (e) { } catch (e) {
+5 -10
View File
@@ -115,7 +115,9 @@ function init(_ctx) {
$("btn-settings-addtoken-manual").addEventListener("click", async () => { $("btn-settings-addtoken-manual").addEventListener("click", async () => {
const addr = $("settings-addtoken-address").value.trim(); const addr = $("settings-addtoken-address").value.trim();
if (!addr || !addr.startsWith("0x")) { if (!addr || !addr.startsWith("0x")) {
showFlash("Enter a valid contract address starting with 0x."); showFlash(
"Please enter a valid contract address starting with 0x.",
);
return; return;
} }
if (isTracked(addr)) { if (isTracked(addr)) {
@@ -153,15 +155,8 @@ function init(_ctx) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", addr, detail); log.errorf("Token lookup failed for", addr, detail);
// lookupTokenInfo() rejects a contract with a one-line message showFlash(detail);
// starting "Not a valid ERC-20 token". Any other error, such as a
// failed save, can be far longer, so it is only logged.
showFlash(
detail.startsWith("Not a valid ERC-20 token")
? detail
: "Could not add the token.",
);
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+6 -4
View File
@@ -41,10 +41,12 @@ const DEFECTS = {
"changed or removed, and this wallet stays until you delete " + "changed or removed, and this wallet stays until you delete " +
"it yourself.", "it yourself.",
], ],
// One line, for the flash on a blocked Send and the inline error on // One sentence for the places that have room for one: the flash on a
// the approval screens. It must fit on the flash line; see showFlash() // blocked Send, the inline error on the approval screens.
// in src/popup/views/helpers.js. shortMessage:
shortMessage: "This wallet cannot sign. See the wallet list.", "This wallet cannot sign, because it was imported from an " +
"extended private key that is not a master key. The wallet list " +
"explains what happened.",
}, },
}; };
+6 -7
View File
@@ -99,13 +99,12 @@ describe("the flash line the message is shown in", () => {
// length, including one that wrapped to two lines and pushed the // length, including one that wrapped to two lines and pushed the
// settings view down 12px. // settings view down 12px.
// //
// The line cuts a message too long for it with an ellipsis (see // The assertion that actually measures — empty line vs. the message,
// showFlash() in src/popup/views/helpers.js). The assertions that // real Chromium, documented 360x600 popup — is
// measure that, in a real browser at the documented 360x600 popup, are // "a rejected dust threshold shifts no layout (#233)" in
// "a rejected dust threshold shifts no layout (#233)" and "an over-long // tests/e2e/run.js, run by make test-e2e. It is not in make check
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by // because REPO_POLICIES.md caps make test at 20 seconds and a browser
// make test-e2e. They are not in make check because REPO_POLICIES.md // suite does not fit; run it before changing the wording.
// caps make test at 20 seconds and a browser suite does not fit.
test("reserves its height in the markup", () => { test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match( const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/, /<div\s+id="flash-msg"\s+class="([^"]*)"/,
+13 -62
View File
@@ -1320,13 +1320,17 @@ async function waitForFilledFlashLine(page) {
} }
// README, No Layout Shift: the rejection message goes into #flash-msg, // README, No Layout Shift: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which // whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
// cuts a message too long for that line with an ellipsis rather than wrap // the space is not enough on its own — a message too long for one line
// it. This shows the real message and measures that nothing moves; the // wraps and pushes everything below it down anyway, which is what the
// test after it does the same with a message several lines long. Both // first version of this change shipped: 75 characters, 32px, the settings
// measure rather than inspect markup: the unit suite runs on the node // view and the threshold field 12px lower than with an empty line.
// environment with no layout engine, where every height is zero (see the //
// note in tests/dustThreshold.test.js). // So this measures rather than inspects markup. It is the only assertion
// in the repo that can see the wording grow: the unit suite runs on the
// node environment with no layout engine, where every height is zero (see
// the note in tests/dustThreshold.test.js). Lengthen
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
test("a rejected dust threshold shifts no layout (#233)", async (env) => { test("a rejected dust threshold shifts no layout (#233)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl); const page = await openPopup(env.ctx, env.popupUrl);
try { try {
@@ -1373,11 +1377,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
); );
assert( assert(
after.flashHeight === before.flashHeight, after.flashHeight === before.flashHeight,
"the message does not keep to the reserved line: " + "the message does not fit the reserved line: " +
before.flashHeight + before.flashHeight +
"px empty vs " + "px empty vs " +
after.flashHeight + after.flashHeight +
"px with the message", "px with the message. Shorten DUST_THRESHOLD_MESSAGE",
); );
assert( assert(
after.settingsTop === before.settingsTop, after.settingsTop === before.settingsTop,
@@ -1396,59 +1400,6 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
} }
}); });
// ------------------------------------------------ the flash line (#252)
// #flash-msg never wraps: a message too long for its one line is cut with an
// ellipsis (see showFlash() in src/popup/views/helpers.js). This puts a
// message several lines long into it and measures that the line and the
// screen below it stay where they were.
test("an over-long flash message keeps to one line (#252)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await openSettings(page);
const before = await page.evaluate(measureFlashLine);
const overflows = await page.evaluate(() => {
const line = document.getElementById("flash-msg");
line.textContent =
"This message is far too long for one line. ".repeat(5);
return line.scrollWidth > line.clientWidth;
});
const after = await page.evaluate(measureFlashLine);
assert(
after.flashHeight === before.flashHeight,
"the flash line is " +
before.flashHeight +
"px before and " +
after.flashHeight +
"px with an over-long message, so it wraps",
);
assert(
after.settingsTop === before.settingsTop,
"the settings view moved " +
(after.settingsTop - before.settingsTop) +
"px when the message appeared",
);
assert(
after.fieldTop === before.fieldTop,
"the dust threshold field moved " +
(after.fieldTop - before.fieldTop) +
"px when the message appeared",
);
// Checked last: a line that wraps does not run past its right edge,
// so this only shows the message really was cut once nothing moved.
assert(
overflows,
"the message fits on the line, so it proves nothing: " +
JSON.stringify(after.text),
);
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238) // --------------------------------------------- confirmation screen (#238)
// //
// The screen that decides what gets signed. The arithmetic underneath it // The screen that decides what gets signed. The arithmetic underneath it
-130
View File
@@ -1,130 +0,0 @@
// The flash line (#252). #flash-msg reserves one line and cuts a message too
// long for it with an ellipsis; that is measured in a real browser by
// tests/e2e/run.js. Here: showFlash() keeps the whole message readable in the
// line's title, and the two add-token screens flash a fixed line, not the text
// of whatever error adding the token threw.
const ADDRESS = "0x1111111111111111111111111111111111111111";
let elements;
function fakeElement() {
return {
value: "",
textContent: "",
title: "",
style: {},
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
};
}
// Stands in for document.getElementById(): one fake element per id.
function element(id) {
return (elements[id] ||= fakeElement());
}
beforeEach(() => {
jest.resetModules();
elements = {};
globalThis.document = { getElementById: element };
// state.js reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
jest.dontMock("../src/shared/state");
jest.dontMock("../src/shared/balances");
jest.restoreAllMocks();
jest.useRealTimers();
delete globalThis.document;
delete globalThis.chrome;
});
test("showFlash() puts the whole message in the title, and clears both", () => {
jest.useFakeTimers();
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Saved.");
expect(element("flash-msg").textContent).toBe("Saved.");
expect(element("flash-msg").title).toBe("Saved.");
jest.advanceTimersByTime(2000);
expect(element("flash-msg").textContent).toBe("");
expect(element("flash-msg").title).toBe("");
});
describe.each([
["addToken", "add-token-address", "btn-add-token-confirm"],
[
"settingsAddToken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("adding a token on %s", (view, field, button) => {
let flashes;
let errors;
// Clicks the screen's add button with lookupTokenInfo() and saveState()
// replaced by the given functions.
async function add(lookupTokenInfo, saveState) {
flashes = [];
errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(console, "log").mockImplementation(() => {});
jest.doMock("../src/shared/balances", () => ({ lookupTokenInfo }));
jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] },
saveState,
}));
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
}));
require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {},
});
element(field).value = ADDRESS;
await element(button).listeners.click();
}
test("a failed save flashes a fixed line and logs the error", async () => {
const detail = "A sentence about the stored record. ".repeat(4);
await add(
async () => ({ symbol: "TKN", decimals: 18, name: "Token" }),
async () => {
throw new Error(detail);
},
);
expect(flashes).toEqual(["Could not add the token."]);
expect(errors).toHaveBeenCalledWith(
"[AutistMask]",
"Adding token failed for",
ADDRESS,
detail,
);
});
test("a contract that is not a token flashes the lookup message", async () => {
const detail = "Not a valid ERC-20 token (symbol() failed).";
await add(
async () => {
throw new Error(detail);
},
async () => {},
);
expect(flashes).toEqual([detail]);
});
});
+5 -62
View File
@@ -49,12 +49,11 @@ class StubCustomEvent extends StubEvent {
} }
} }
// Examples of codes the background emits on the RPC path, read out of // Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know any list of codes — it // src/background/index.js. The provider must not know this list — it passes
// passes through whatever arrived — but the cases below are real ones. // through whatever arrived — but the cases below are the real ones.
const REJECTED = 4001; // user rejected the request const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNSUPPORTED_METHOD = 4200; // a method the wallet does not implement
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners, // A stub window with the four things inpage.js touches: message listeners,
@@ -116,41 +115,6 @@ async function rejectionFrom(start, response) {
return outcome.error; return outcome.error;
} }
// The reply the real background worker (src/background/index.js) sends for
// `method`, loaded against just enough of the extension API to receive one
// RPC message. Same shape as tests/coldWorkerChainId.test.js.
function backgroundReply(method) {
jest.resetModules();
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: async () => {},
registerAlarmHandlers: () => {},
}));
let messageListener = null;
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
},
};
require("../src/background/index");
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: "https://dapp.example" },
resolve,
);
});
}
describe("an EIP-1193 code reaches the page", () => { describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => { test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom( const err = await rejectionFrom(
@@ -200,9 +164,8 @@ describe("an EIP-1193 code reaches the page", () => {
expect(err.message).toBe(message); expect(err.message).toBe(message);
}); });
// The provider is not allowed to know the codes above: any other code, // The provider is not allowed to know the list above: a code added to the
// including one added to the background later, must reach the page // background later must reach the page without this file being edited.
// without inpage.js being edited.
test("a code the provider has never heard of is passed through", async () => { test("a code the provider has never heard of is passed through", async () => {
const err = await rejectionFrom( const err = await rejectionFrom(
(p) => p.request({ method: "eth_accounts" }), (p) => p.request({ method: "eth_accounts" }),
@@ -240,26 +203,6 @@ describe("an EIP-1193 code reaches the page", () => {
}); });
}); });
// The reply here is the background's own, not one written in this file: it
// used to carry no code for a method the wallet does not implement
// (https://git.eeqj.de/sneak/AutistMask/issues/279), so a site probing for an
// optional method could not tell "not implemented" from "the call failed".
describe("a method the wallet does not implement", () => {
afterEach(() => {
delete global.chrome;
});
test("reaches the page as code 4200", async () => {
const method = "wallet_noSuchMethod";
const err = await rejectionFrom(
(p) => p.request({ method }),
await backgroundReply(method),
);
expect(err.code).toBe(UNSUPPORTED_METHOD);
expect(err.message).toBe("Unsupported method: " + method);
});
});
describe("the message is untouched", () => { describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => { test("a coded error keeps the message byte for byte", async () => {
const message = const message =