Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ac51d514 | ||
|
|
fc042c3191 |
@@ -1742,16 +1742,18 @@ view would leave a wallet one click from deletion.
|
||||
plus four reserved warning boxes made visible by the async checks —
|
||||
recipient with no transaction history, recipient is a contract, burn
|
||||
address, and an Etherscan phishing/scam label
|
||||
- Errors (insufficient balance, or a balance that could not be read, which
|
||||
asks the user to go back and try again), plus three reserved error boxes —
|
||||
the amount plus the fee exceeds the balance (ETH transfers), not enough
|
||||
ETH to pay the fee for the transfer (ERC-20 transfers), and the fee could
|
||||
not be estimated. The first two are mutually exclusive per transfer type,
|
||||
so only the applicable one holds space. The last names its cause: for a
|
||||
token whose scale is unknown the fee can never be 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
|
||||
- Errors (an invalid amount), plus five reserved error boxes, so the balance
|
||||
and the fee arriving later move nothing — the amount exceeds the balance
|
||||
(for a token whose scale is unknown it says the token's balance is
|
||||
unknown), the balance could not be read (it asks the user to go back and
|
||||
try again), the amount plus the fee exceeds the balance (ETH transfers),
|
||||
not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
|
||||
fee could not be estimated. The third and fourth are mutually exclusive
|
||||
per transfer type, so only the applicable one holds space. The last names
|
||||
its cause: for a token whose scale is unknown the fee can never be
|
||||
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
|
||||
line
|
||||
- "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
|
||||
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
|
||||
for a local file. The background script prefers the toolbar popup
|
||||
(`action.openPopup()`) and falls back to a separate popup window
|
||||
(`src/background/index.js`, `requestApproval()`). Only one exists per site at
|
||||
a time: a further connection request from a site whose prompt is 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.
|
||||
for a local file. Nor is such a page told the active address by
|
||||
`accountsChanged`, even at a connected site's URL: the background names the
|
||||
origin of the tab's URL on the event, and the content script passes the
|
||||
address on only to a page whose own origin is that one. The background script
|
||||
prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
|
||||
popup window (`src/background/index.js`, `requestApproval()`). Only one exists
|
||||
per site at a time: a further connection request from a site whose prompt is
|
||||
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**:
|
||||
- "Connection Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
|
||||
@@ -1308,6 +1308,12 @@ async function broadcastAccountsChanged() {
|
||||
return;
|
||||
}
|
||||
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 hasPermission =
|
||||
activeAddress &&
|
||||
@@ -1319,6 +1325,7 @@ async function broadcastAccountsChanged() {
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "accountsChanged",
|
||||
data: hasPermission ? [activeAddress] : [],
|
||||
origin,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -49,14 +49,23 @@ window.addEventListener("message", (event) => {
|
||||
|
||||
// Listen for events pushed from the background (e.g. accountsChanged)
|
||||
runtimeApi().onMessage.addListener((msg) => {
|
||||
if (msg.type === "AUTISTMASK_EVENT") {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: msg.eventName,
|
||||
data: msg.data,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
if (msg.type !== "AUTISTMASK_EVENT") return;
|
||||
// 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.
|
||||
// This page hears them only when its own origin is that one. A page whose
|
||||
// origin has no host is not a site: one its server sandboxes with a
|
||||
// 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,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -669,6 +669,20 @@
|
||||
id="confirm-errors"
|
||||
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
||||
></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
|
||||
id="confirm-amount-fee-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
|
||||
@@ -263,6 +263,20 @@ function show(txInfo) {
|
||||
" cannot be checked against your balance. Please go back and" +
|
||||
" 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);
|
||||
|
||||
// Reset password field and error
|
||||
@@ -332,13 +346,11 @@ async function readBalances(txInfo, provider) {
|
||||
// re-running it never moves anything.
|
||||
function renderValidation(txInfo) {
|
||||
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.
|
||||
// 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 tokenBalance = balances ? balances.token : null;
|
||||
const balanceRead = ethBalance !== null;
|
||||
@@ -353,53 +365,14 @@ function renderValidation(txInfo) {
|
||||
feeWei,
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html, except the
|
||||
// gas and fee-unknown ones, which show() sets.
|
||||
// An invalid amount is known before the first paint, so its message is
|
||||
// written here. Every message a later result brings has a reserved
|
||||
// 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 = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
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");
|
||||
if (messages.length > 0) {
|
||||
@@ -412,6 +385,13 @@ function renderValidation(txInfo) {
|
||||
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(
|
||||
"confirm-amount-fee-error",
|
||||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -259,6 +259,10 @@ function errors() {
|
||||
return el("confirm-errors").innerHTML;
|
||||
}
|
||||
|
||||
function shown(id) {
|
||||
return el(id).style.visibility === "visible";
|
||||
}
|
||||
|
||||
function sendDisabled() {
|
||||
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(errors()).toBe("");
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||
expect(sendDisabled()).toBe(false);
|
||||
});
|
||||
@@ -362,9 +367,10 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
|
||||
|
||||
await reopenOn("sepolia");
|
||||
expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH");
|
||||
expect(errors()).toContain(
|
||||
"Insufficient balance. You have 0.5000 SepoliaETH but are trying" +
|
||||
" to send 1 SepoliaETH.",
|
||||
expect(shown("confirm-balance-error")).toBe(true);
|
||||
expect(el("confirm-balance-error").textContent).toBe(
|
||||
"Your SepoliaETH balance does not cover this amount. Please go" +
|
||||
" back and send a smaller amount.",
|
||||
);
|
||||
expect(sendDisabled()).toBe(true);
|
||||
});
|
||||
@@ -384,6 +390,7 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
|
||||
await reopenOn("sepolia");
|
||||
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(el("confirm-gas-error").style.visibility).toBe("hidden");
|
||||
expect(sendDisabled()).toBe(false);
|
||||
});
|
||||
@@ -396,9 +403,10 @@ describe("a confirmation screen saved on mainnet and reopened after a switch to
|
||||
|
||||
await reopenOn("sepolia");
|
||||
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
|
||||
expect(errors()).toContain(
|
||||
"Insufficient TOK balance. You have 0.5000 TOK but are trying to" +
|
||||
" send 1 TOK.",
|
||||
expect(shown("confirm-balance-error")).toBe(true);
|
||||
expect(el("confirm-balance-error").textContent).toBe(
|
||||
"Your TOK balance does not cover this amount. Please go back and" +
|
||||
" send a smaller amount.",
|
||||
);
|
||||
expect(sendDisabled()).toBe(true);
|
||||
});
|
||||
@@ -418,6 +426,8 @@ describe("the balance read", () => {
|
||||
expect(el("confirm-balance").textContent).toBe("Loading...");
|
||||
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
|
||||
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);
|
||||
|
||||
answer(parseEther("5"));
|
||||
@@ -426,6 +436,32 @@ describe("the balance read", () => {
|
||||
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
|
||||
// be the new network's.
|
||||
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();
|
||||
}
|
||||
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
|
||||
expect(errors()).toBe(
|
||||
'<div class="text-xs">Your balance could not be read from the' +
|
||||
" network, so this transaction cannot be checked against it." +
|
||||
" Please go back and try again.</div>",
|
||||
);
|
||||
expect(shown("confirm-balance-read-error")).toBe(true);
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(errors()).toBe("");
|
||||
expect(sendDisabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
|
||||
// here rather than captured, so a test flips it on the same options object the
|
||||
// route was registered with — the same pattern as seedTokenTransfer. This is
|
||||
// The switch (holdBalance, holdGasEstimate, holdTransactionCount or
|
||||
// holdBlockscout) is read here rather than captured, so a test flips it on the
|
||||
// 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;
|
||||
// sampling the screen and hoping to win a race against the network would
|
||||
// assert nothing on a slow machine.
|
||||
@@ -565,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
|
||||
report("unstubbed request: POST " + route.request().url());
|
||||
return route.abort();
|
||||
}
|
||||
if (batch.some((req) => req.method === "eth_getBalance")) {
|
||||
await awaitRelease(opts, "holdBalance", report);
|
||||
}
|
||||
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
||||
await awaitRelease(opts, "holdGasEstimate", report);
|
||||
}
|
||||
@@ -623,6 +627,8 @@ function traceEnabled(raw) {
|
||||
* fixture expects.
|
||||
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
|
||||
* 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
|
||||
* eth_estimateGas until this is cleared again.
|
||||
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
|
||||
|
||||
+92
-45
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
return page.evaluate(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
|
||||
};
|
||||
return {
|
||||
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(),
|
||||
balance: el("confirm-balance").textContent.trim(),
|
||||
fee: el("confirm-fee-amount").textContent.trim(),
|
||||
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
|
||||
errors: shown("confirm-errors")
|
||||
? el("confirm-errors").textContent.trim()
|
||||
: "",
|
||||
balanceError: shown("confirm-balance-error")
|
||||
? el("confirm-balance-error").textContent.trim()
|
||||
: "",
|
||||
amountFeeError: shown("confirm-amount-fee-error"),
|
||||
gasError: shown("confirm-gas-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) {
|
||||
if (await page.isVisible("#view-confirm-tx")) {
|
||||
await page.click("#btn-confirm-back");
|
||||
@@ -2282,13 +2298,14 @@ async function backToAddress(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
|
||||
// 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
|
||||
// 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 page.click("#btn-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.click("#btn-send-review");
|
||||
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.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById("confirm-balance").textContent.trim() !==
|
||||
"Loading...",
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
}
|
||||
|
||||
// The same, returning once the screen has read the balance. It reads it
|
||||
// before it estimates the fee, so every state below is read with the balance
|
||||
// in place, the pending fee included.
|
||||
async function goToConfirm(page, send) {
|
||||
await openConfirm(page, send);
|
||||
await waitForBalance(page);
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
});
|
||||
@@ -2419,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
|
||||
);
|
||||
assert(
|
||||
st.errors === "" &&
|
||||
st.balanceError === "" &&
|
||||
!st.amountFeeError &&
|
||||
!st.gasError &&
|
||||
!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) => {
|
||||
await goToConfirm(env.page, {
|
||||
token: "ETH",
|
||||
balance: FUNDED_ETH_TEXT + " ETH",
|
||||
amount: OVER_BALANCE_AMOUNT,
|
||||
});
|
||||
// The screen is first measured with its balance read held, so the message the
|
||||
// read brings must already hold its space when the read returns (#558).
|
||||
test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
|
||||
env.routeOpts.holdBalance = true;
|
||||
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);
|
||||
|
||||
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(
|
||||
st.errors === want,
|
||||
"wrong over-balance message: " + JSON.stringify(st.errors),
|
||||
st.balance === FUNDED_ETH_TEXT + " ETH",
|
||||
"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.amountFeeError,
|
||||
"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) => {
|
||||
@@ -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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
@@ -2643,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
|
||||
await waitForEstimate(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(
|
||||
st.errors === want,
|
||||
"wrong over-token-balance message: " + JSON.stringify(st.errors),
|
||||
st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
|
||||
"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(
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
st.errors === "",
|
||||
st.errors === "" && st.balanceError === "",
|
||||
"a token-balance message is shown for a transfer the token balance covers: " +
|
||||
JSON.stringify(st.errors),
|
||||
JSON.stringify(st.errors + st.balanceError),
|
||||
);
|
||||
assert(
|
||||
st.height === env.erc20PendingHeight,
|
||||
@@ -4869,6 +4913,9 @@ async function main() {
|
||||
seedTokenBalance: false,
|
||||
ethBalanceWei: null,
|
||||
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,
|
||||
// Whether a JSON-RPC batch asking for a transaction count is held
|
||||
// unanswered: the address scan after creating a wallet (#475).
|
||||
|
||||
@@ -336,14 +336,11 @@ describe.each([
|
||||
|
||||
test("the insufficient-balance error", async () => {
|
||||
await confirmSend("2");
|
||||
expect(
|
||||
global.document.getElementById("confirm-errors").innerHTML,
|
||||
).toContain(
|
||||
"You have 1.5000 " +
|
||||
expect(text("confirm-balance-error")).toBe(
|
||||
"Your " +
|
||||
symbol +
|
||||
" but are trying to send 2 " +
|
||||
symbol +
|
||||
".",
|
||||
" balance does not cover this amount. Please go back and send" +
|
||||
" a smaller amount.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,12 @@ function errors() {
|
||||
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,
|
||||
// fetched and stored exactly where the popup stores them. The node reports the
|
||||
// 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);
|
||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
await confirmSend("0.0000006", TOKEN);
|
||||
expect(errors()).toContain(
|
||||
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
||||
expect(balanceError()).toContain(
|
||||
"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);
|
||||
expect(text("confirm-balance")).toBe("1.5000 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
});
|
||||
|
||||
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);
|
||||
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
|
||||
expect(errors()).toContain(
|
||||
"You have 0.000000000000000000000001 TOK but are trying to send" +
|
||||
" 0.000000000000000001 TOK.",
|
||||
expect(balanceError()).toContain(
|
||||
"Your TOK balance does not cover this amount.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -367,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
|
||||
await confirmSend("0.0000005", TOKEN);
|
||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
await confirmSend("0.0000006", TOKEN);
|
||||
expect(errors()).toContain(
|
||||
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
||||
expect(balanceError()).toContain(
|
||||
"Your TOK balance does not cover this amount.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +249,12 @@ function errors() {
|
||||
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() {
|
||||
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);
|
||||
await settle();
|
||||
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||
expect(errors()).toContain("This token's balance is unknown");
|
||||
expect(balanceError()).toContain("This token's balance is unknown");
|
||||
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
|
||||
// estimated... please go back and try again" — as the only explanation
|
||||
// for a screen that can never proceed.
|
||||
expect(errors()).not.toBe("");
|
||||
expect(errors()).toContain("This token's balance is unknown");
|
||||
expect(balanceError()).toContain("This token's balance is unknown");
|
||||
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||
expect(sendDisabled()).toBe(true);
|
||||
// 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();
|
||||
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
|
||||
expect(errors()).toBe("");
|
||||
expect(balanceError()).toBe("");
|
||||
expect(sendDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -476,7 +482,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
|
||||
tokenDecimals,
|
||||
});
|
||||
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 () => {
|
||||
@@ -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
|
||||
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
|
||||
// about the holding, and this one has no established quantity to claim.
|
||||
test("the insufficient-balance message names the reason, not a figure", async () => {
|
||||
// spend from, which is the fail-closed side — but "your balance does not
|
||||
// cover this amount" is a claim about the holding, and this one has no
|
||||
// established quantity to claim.
|
||||
test("the insufficient-balance message names the reason, not a shortfall", async () => {
|
||||
const unknown = await render(null);
|
||||
const zero = await render(18);
|
||||
expect(unknown.errors).not.toBe(zero.errors);
|
||||
expect(unknown.errors).toContain("This token's balance is unknown");
|
||||
expect(unknown.errors).not.toContain("You have");
|
||||
expect(zero.errors).toContain("You have 0.0000 NOVEL");
|
||||
expect(zero.errors).not.toContain("balance is unknown");
|
||||
expect(unknown.message).toContain("This token's balance is unknown");
|
||||
expect(unknown.message).not.toContain("does not cover");
|
||||
expect(zero.message).toBe(
|
||||
"Your NOVEL balance does not cover this amount. Please go back and" +
|
||||
" send a smaller amount.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user