2 Commits
Author SHA1 Message Date
sneak c1ac51d514 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it.

Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node of the network it is drawn for, and gives that node to the fee estimate, which starts once the read is done. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns.

Model: opus-5-5
2026-10-19 00:17:07 +00:00
clawbot fc042c3191 fix: a page whose origin has no host is never told an address by accountsChanged (closes #564)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The background judges each tab by its URL's origin, the only one it can see, so a page its server sandboxes, whose own origin is null, was still sent the new active address on an address switch although all its requests are refused.

The background now names the origin it judged by on each accountsChanged event, and the content script passes the address to the page only when the page's own window.origin is that origin, an empty list otherwise. An event naming no origin also reaches the page as an empty list. chainChanged is unchanged. The README's SiteApproval section says the same.

Judgement call: covered by a unit test that sets window.origin; no browser-suite test.

Model: opus-5-5
2026-10-19 00:59:39 +02:00
12 changed files with 441 additions and 159 deletions
+23 -18
View File
@@ -1742,16 +1742,18 @@ view would leave a wallet one click from deletion.
plus four reserved warning boxes made visible by the async checks — plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label address, and an Etherscan phishing/scam label
- Errors (insufficient balance, or a balance that could not be read, which - Errors (an invalid amount), plus five reserved error boxes, so the balance
asks the user to go back and try again), plus three reserved error boxes — and the fee arriving later move nothing — the amount exceeds the balance
the amount plus the fee exceeds the balance (ETH transfers), not enough (for a token whose scale is unknown it says the token's balance is
ETH to pay the fee for the transfer (ERC-20 transfers), and the fee could unknown), the balance could not be read (it asks the user to go back and
not be estimated. The first two are mutually exclusive per transfer type, try again), the amount plus the fee exceeds the balance (ETH transfers),
so only the applicable one holds space. The last names its cause: for a not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
token whose scale is unknown the fee can never be estimated, and it says fee could not be estimated. The third and fourth are mutually exclusive
the wallet does not know how many decimal places the token uses and that per transfer type, so only the applicable one holds space. The last names
the transaction cannot be sent; for any other failure it asks the user to its cause: for a token whose scale is unknown the fee can never be
go back and try again estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Password: an inline field on this screen, not a modal, with its own error - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, while the balance is being read, - "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -2186,14 +2188,17 @@ view would leave a wallet one click from deletion.
code `4100` and opens no prompt. Two such origins are `null`, which the code `4100` and opens no prompt. Two such origins are `null`, which the
browser reports for a page with an opaque origin, such as one its server browser reports for a page with an opaque origin, such as one its server
sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports
for a local file. The background script prefers the toolbar popup for a local file. Nor is such a page told the active address by
(`action.openPopup()`) and falls back to a separate popup window `accountsChanged`, even at a connected site's URL: the background names the
(`src/background/index.js`, `requestApproval()`). Only one exists per site at origin of the tab's URL on the event, and the content script passes the
a time: a further connection request from a site whose prompt is still address on only to a page whose own origin is that one. The background script
unanswered is refused with EIP-1193 code `-32002` and opens no new prompt. If prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
that prompt was in a toolbar popup that closed before it connected, and the popup window (`src/background/index.js`, `requestApproval()`). Only one exists
toolbar popup has since been set to open something else, the refused request per site at a time: a further connection request from a site whose prompt is
shows that prompt again. still unanswered is refused with EIP-1193 code `-32002` and opens no new
prompt. If that prompt was in a toolbar popup that closed before it connected,
and the toolbar popup has since been set to open something else, the refused
request shows that prompt again.
- **Elements**: - **Elements**:
- "Connection Request" heading - "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
+7
View File
@@ -1308,6 +1308,12 @@ async function broadcastAccountsChanged() {
return; return;
} }
for (const tab of tabs) { for (const tab of tabs) {
// The tab's URL is all the background knows of the page in it, and a
// page at a site's URL may have another origin: a page its server
// sandboxes with a Content-Security-Policy has the origin "null". So
// the event names the origin it was judged by, and the content script
// passes the address on only to a page whose own origin is that one
// (src/content/index.js).
const origin = tab.url ? new URL(tab.url).origin : ""; const origin = tab.url ? new URL(tab.url).origin : "";
const hasPermission = const hasPermission =
activeAddress && activeAddress &&
@@ -1319,6 +1325,7 @@ async function broadcastAccountsChanged() {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
eventName: "accountsChanged", eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [], data: hasPermission ? [activeAddress] : [],
origin,
}).catch(() => {}); }).catch(() => {});
} }
} }
+18 -9
View File
@@ -49,14 +49,23 @@ window.addEventListener("message", (event) => {
// Listen for events pushed from the background (e.g. accountsChanged) // Listen for events pushed from the background (e.g. accountsChanged)
runtimeApi().onMessage.addListener((msg) => { runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") { if (msg.type !== "AUTISTMASK_EVENT") return;
window.postMessage( // The background chooses the addresses in accountsChanged for the site
{ // at the origin the event names, which is the origin of the tab's URL.
type: "AUTISTMASK_EVENT", // This page hears them only when its own origin is that one. A page whose
eventName: msg.eventName, // origin has no host is not a site: one its server sandboxes with a
data: msg.data, // Content-Security-Policy has the origin "null" whatever its URL, and is
}, // told no address, as its requests are refused.
"*", let data = msg.data;
); if (msg.eventName === "accountsChanged" && msg.origin !== window.origin) {
data = [];
} }
window.postMessage(
{
type: "AUTISTMASK_EVENT",
eventName: msg.eventName,
data,
},
"*",
);
}); });
+14
View File
@@ -669,6 +669,20 @@
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]" class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div> ></div>
<!-- Its sentence names the token, or why the token's balance
is unknown, so show() in confirmTx.js sets it. -->
<div
id="confirm-balance-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div
id="confirm-balance-read-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance could not be read from the network, so this
transaction cannot be checked against it. Please go back and
try again.
</div>
<div <div
id="confirm-amount-fee-error" id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
+27 -47
View File
@@ -263,6 +263,20 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" + " cannot be checked against your balance. Please go back and" +
" try again."; " try again.";
// So does the message for an amount the balance does not cover. It is
// set here, before the first paint, so the balance read returning only
// makes it visible and moves nothing, which is why it quotes no balance.
// Without the token's scale readBalances() reads no token balance.
$("confirm-balance-error").textContent =
isErc20 && toDecimals(txInfo.tokenDecimals) === null
? "This token's balance is unknown, because nothing this wallet" +
" can consult reports how many decimal places it uses, so the" +
" amount you are trying to send cannot be checked against it."
: "Your " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.";
renderValidation(txInfo); renderValidation(txInfo);
// Reset password field and error // Reset password field and error
@@ -332,13 +346,11 @@ async function readBalances(txInfo, provider) {
// re-running it never moves anything. // re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrencyByChainId(txInfo.chainId);
// Null while the balances are being read and after a read that failed. // Null while the balances are being read and after a read that failed.
// validateTransfer() takes a missing balance as nothing to spend from, so // validateTransfer() takes a missing balance as nothing to spend from, so
// sending stays blocked either way, and no message below quotes one. // sending stays blocked either way, and the only message shown for it is
// the one saying the read failed.
const ethBalance = balances ? balances.eth : null; const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null; const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null; const balanceRead = ethBalance !== null;
@@ -353,53 +365,14 @@ function renderValidation(txInfo) {
feeWei, feeWei,
}); });
// Messages carrying the user's own numbers are built here; the fixed // An invalid amount is known before the first paint, so its message is
// sentences live in the reserved elements in index.html, except the // written here. Every message a later result brings has a reserved
// gas and fee-unknown ones, which show() sets. // element, its sentence fixed in index.html or set by show(), and is only
// made visible below, so nothing moves when the result arrives.
const messages = []; const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) { if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send."); messages.push("Please enter a valid amount to send.");
} }
if (balances !== null && !balanceRead) {
messages.push(
"Your balance could not be read from the network, so this" +
" transaction cannot be checked against it. Please go back" +
" and try again.",
);
}
if (balanceRead && codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (balanceRead && codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(ethBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
const errorsEl = $("confirm-errors"); const errorsEl = $("confirm-errors");
if (messages.length > 0) { if (messages.length > 0) {
@@ -412,6 +385,13 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden"; errorsEl.style.visibility = "hidden";
} }
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
setVisible(
"confirm-balance-error",
balanceRead &&
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
codes.includes(CODES.INSUFFICIENT_ETH)),
);
setVisible( setVisible(
"confirm-amount-fee-error", "confirm-amount-fee-error",
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
+167
View File
@@ -0,0 +1,167 @@
// A page whose origin has no host is never told an address by accountsChanged
// (https://git.eeqj.de/sneak/AutistMask/issues/564).
//
// When the user switches address, the background tells each open tab the new
// active address if the site at the tab's URL is connected. A page at a
// connected site's URL can still have another origin: one its server
// sandboxes with a Content-Security-Policy has the origin "null", and every
// request it makes is refused. Each test here switches the address in the real
// background, takes the event it sent to a tab at the site's URL, and hands
// that event to the real content script in a page of the given origin.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const SITE = "https://dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One address, with SITE remembered as allowed for it.
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [SITE] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
delete global.window;
jest.resetModules();
});
// The popup tells the background the active address changed, and the one
// open tab, at SITE's URL, is sent an event. That event is returned.
async function eventSentToSiteTab() {
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(),
}));
let messageListener = null;
const sentToTabs = [];
global.chrome = {
storage: makeStorageStub({ autistmask: storedProfile() }),
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
getPlatformInfo: (cb) => cb({ os: "linux" }),
lastError: null,
},
windows: {
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
messageListener(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
FROM_POPUP,
() => {},
);
await settle();
expect(sentToTabs).toHaveLength(1);
return sentToTabs[0];
}
// The content script in a page whose origin is `pageOrigin` is handed `event`
// from the background. What it posts to the page is returned.
function postedToPage(event, pageOrigin) {
jest.resetModules();
let eventListener = null;
const posted = [];
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
eventListener = fn;
},
},
},
};
global.window = {
origin: pageOrigin,
addEventListener: () => {},
postMessage: (data) => posted.push(data),
};
require("../src/content/index");
eventListener(event);
return posted;
}
describe("accountsChanged after an address switch, at a connected site's URL", () => {
test("the site's own page is told the active address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, SITE)).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [ADDRESS],
},
]);
});
test("a page whose origin has no host is told no address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, "null")).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
});
});
+45 -11
View File
@@ -259,6 +259,10 @@ function errors() {
return el("confirm-errors").innerHTML; return el("confirm-errors").innerHTML;
} }
function shown(id) {
return el(id).style.visibility === "visible";
}
function sendDisabled() { function sendDisabled() {
return el("btn-confirm-send").disabled; return el("btn-confirm-send").disabled;
} }
@@ -347,6 +351,7 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
); );
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH"); expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden"); expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
@@ -362,9 +367,10 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
await reopenOn("sepolia"); await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH"); expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH");
expect(errors()).toContain( expect(shown("confirm-balance-error")).toBe(true);
"Insufficient balance. You have 0.5000 SepoliaETH but are trying" + expect(el("confirm-balance-error").textContent).toBe(
" to send 1 SepoliaETH.", "Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.",
); );
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
@@ -384,6 +390,7 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
await reopenOn("sepolia"); await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("10.0000 TOK"); expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-gas-error").style.visibility).toBe("hidden"); expect(el("confirm-gas-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
@@ -396,9 +403,10 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
await reopenOn("sepolia"); await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 TOK"); expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
expect(errors()).toContain( expect(shown("confirm-balance-error")).toBe(true);
"Insufficient TOK balance. You have 0.5000 TOK but are trying to" + expect(el("confirm-balance-error").textContent).toBe(
" send 1 TOK.", "Your TOK balance does not cover this amount. Please go back and" +
" send a smaller amount.",
); );
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
@@ -418,6 +426,8 @@ describe("the balance read", () => {
expect(el("confirm-balance").textContent).toBe("Loading..."); expect(el("confirm-balance").textContent).toBe("Loading...");
expect(el("confirm-fee-amount").textContent).toBe("Estimating..."); expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
expect(el("confirm-errors").style.visibility).toBe("hidden"); expect(el("confirm-errors").style.visibility).toBe("hidden");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-balance-read-error")).toBe(false);
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
answer(parseEther("5")); answer(parseEther("5"));
@@ -426,6 +436,32 @@ describe("the balance read", () => {
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
// The Chrome suite measures that nothing moves; this checks the message
// is already written, only hidden, before the read returns.
test("that finds the amount not covered only makes a message written before it visible", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
const message =
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.";
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(false);
answer(parseEther("0.5"));
await settle();
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(true);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
// The fee is estimated once the read returns, so a node chosen then would // The fee is estimated once the read returns, so a node chosen then would
// be the new network's. // be the new network's.
test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => { test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => {
@@ -463,11 +499,9 @@ describe("the balance read", () => {
getBalance.mockRestore(); getBalance.mockRestore();
} }
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)"); expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
expect(errors()).toBe( expect(shown("confirm-balance-read-error")).toBe(true);
'<div class="text-xs">Your balance could not be read from the' + expect(shown("confirm-balance-error")).toBe(false);
" network, so this transaction cannot be checked against it." + expect(errors()).toBe("");
" Please go back and try again.</div>",
);
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
}); });
+9 -3
View File
@@ -428,9 +428,10 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false. // Hold a reply open for as long as the test asks: until opts[name] is false.
// //
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read // The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// here rather than captured, so a test flips it on the same options object the // holdBlockscout) is read here rather than captured, so a test flips it on the
// route was registered with — the same pattern as seedTokenTransfer. This is // same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight; // the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would // sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine. // assert nothing on a slow machine.
@@ -565,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url()); report("unstubbed request: POST " + route.request().url());
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) { if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report); await awaitRelease(opts, "holdGasEstimate", report);
} }
@@ -623,6 +627,8 @@ function traceEnabled(raw) {
* fixture expects. * fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an * @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+92 -45
View File
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in // What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the // one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is. // fee block says, how tall the whole view is, and where Sign & Send sits in it.
async function confirmState(page) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, height: el("view-confirm-tx").getBoundingClientRect().height,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(), type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(), balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(), fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
errors: shown("confirm-errors") errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim() ? el("confirm-errors").textContent.trim()
: "", : "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"), feeUnknownError: shown("confirm-fee-unknown-error"),
@@ -2271,6 +2277,16 @@ async function waitForEstimate(page) {
); );
} }
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) { async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) { if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back"); await page.click("#btn-confirm-back");
@@ -2282,13 +2298,14 @@ async function backToAddress(page) {
await openAddressDetail(page); await openAddressDetail(page);
} }
// Drive the popup to the confirmation screen for one send. // Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
// //
// It waits for the send screen to be showing `balance`, the fixture's balance // It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it — // as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every // rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change. // assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) { async function openConfirm(page, { token, balance, amount }) {
await backToAddress(page); await backToAddress(page);
await page.click("#btn-send"); await page.click("#btn-send");
await visible(page, "#view-send"); await visible(page, "#view-send");
@@ -2303,16 +2320,14 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount); await page.fill("#send-amount", amount);
await page.click("#btn-send-review"); await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx"); await visible(page, "#view-confirm-tx");
// The screen reads the balance itself before it estimates the fee, so }
// waiting for it here means every state below is read with the balance
// in place, the pending fee included. // The same, returning once the screen has read the balance. It reads it
await page.waitForFunction( // before it estimates the fee, so every state below is read with the balance
() => // in place, the pending fee included.
document.getElementById("confirm-balance").textContent.trim() !== async function goToConfirm(page, send) {
"Loading...", await openConfirm(page, send);
null, await waitForBalance(page);
{ timeout: 60000 },
);
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
@@ -2388,7 +2403,10 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending", "the estimate-failed message is shown for an estimate that is merely pending",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown before the fee is known", "a balance message is shown before the fee is known",
); );
}); });
@@ -2419,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
); );
assert( assert(
st.errors === "" && st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError && !st.amountFeeError &&
!st.gasError && !st.gasError &&
!st.feeUnknownError, !st.feeUnknownError,
@@ -2485,30 +2504,54 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
); );
}); });
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => { // The screen is first measured with its balance read held, so the message the
await goToConfirm(env.page, { // read brings must already hold its space when the read returns (#558).
token: "ETH", test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
balance: FUNDED_ETH_TEXT + " ETH", env.routeOpts.holdBalance = true;
amount: OVER_BALANCE_AMOUNT, let reading;
}); try {
await openConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert( assert(
st.errors === want, st.balance === FUNDED_ETH_TEXT + " ETH",
"wrong over-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
); );
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance"); assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert( assert(
!st.amountFeeError, !st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance", "the amount-plus-fee message is shown for an amount that alone exceeds the balance",
); );
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
}); });
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => { test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2552,7 +2595,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate", "the estimate-failed message is not shown after a failed estimate",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown for an estimate that simply failed", "a balance message is shown for an estimate that simply failed",
); );
assert( assert(
@@ -2643,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert( assert(
st.errors === want, st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"wrong over-token-balance message: " + JSON.stringify(st.errors), "the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
); );
assert( assert(
st.sendDisabled, st.sendDisabled,
@@ -2713,9 +2757,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer", "the native-ETH over-budget message is shown on an ERC-20 transfer",
); );
assert( assert(
st.errors === "", st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " + "a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors), JSON.stringify(st.errors + st.balanceError),
); );
assert( assert(
st.height === env.erc20PendingHeight, st.height === env.erc20PendingHeight,
@@ -4869,6 +4913,9 @@ async function main() {
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false, holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held // Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475). // unanswered: the address scan after creating a wallet (#475).
+4 -7
View File
@@ -336,14 +336,11 @@ describe.each([
test("the insufficient-balance error", async () => { test("the insufficient-balance error", async () => {
await confirmSend("2"); await confirmSend("2");
expect( expect(text("confirm-balance-error")).toBe(
global.document.getElementById("confirm-errors").innerHTML, "Your " +
).toContain(
"You have 1.5000 " +
symbol + symbol +
" but are trying to send 2 " + " balance does not cover this amount. Please go back and send" +
symbol + " a smaller amount.",
".",
); );
}); });
}); });
+15 -7
View File
@@ -203,6 +203,12 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML; return global.document.getElementById("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports, // The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. The node reports the // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does. // token holding the explorer does.
@@ -311,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
@@ -327,6 +334,7 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN); await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK"); expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
}); });
test("with more than 18 decimals and a holding below 10^-18", async () => { test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -340,9 +348,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
); );
await confirmSend("0.000000000000000001", TOKEN); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" + "Your TOK balance does not cover this amount.",
" 0.000000000000000001 TOK.",
); );
}); });
}); });
@@ -367,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "Your TOK balance does not cover this amount.",
); );
}); });
}); });
+20 -12
View File
@@ -249,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML; return el("confirm-errors").innerHTML;
} }
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() { function sendDisabled() {
return el("btn-confirm-send").disabled; return el("btn-confirm-send").disabled;
} }
@@ -319,7 +325,7 @@ describe("the Send screen resolves the scale rather than reading the stored one"
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown"); expect(balanceError()).toContain("This token's balance is unknown");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
}); });
}); });
@@ -376,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be // confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation // estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed. // for a screen that can never proceed.
expect(errors()).not.toBe(""); expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it // The fee line still reports the estimate as unavailable, because it
@@ -447,6 +452,7 @@ describe("a scale the explorer's own rows disagree about", () => {
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
}); });
@@ -476,7 +482,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
tokenDecimals, tokenDecimals,
}); });
await settle(); await settle();
return { balance: text("confirm-balance"), errors: errors() }; return { balance: text("confirm-balance"), message: balanceError() };
} }
test("the balance line states unknown rather than a quantity of zero", async () => { test("the balance line states unknown rather than a quantity of zero", async () => {
@@ -488,16 +494,18 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
}); });
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to // Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim // spend from, which is the fail-closed side — but "your balance does not
// about the holding, and this one has no established quantity to claim. // cover this amount" is a claim about the holding, and this one has no
test("the insufficient-balance message names the reason, not a figure", async () => { // established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render(18); const zero = await render(18);
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.message).not.toContain("does not cover");
expect(unknown.errors).not.toContain("You have"); expect(zero.message).toBe(
expect(zero.errors).toContain("You have 0.0000 NOVEL"); "Your NOVEL balance does not cover this amount. Please go back and" +
expect(zero.errors).not.toContain("balance is unknown"); " send a smaller amount.",
);
}); });
}); });