3 Commits
Author SHA1 Message Date
sneak fde8d1cdf0 fix: the confirmation screen's balance read and fee estimate stay on its network
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The fee estimate starts once the balance is read, and both it and the balance
line took the active network at that moment, so a network switched in Settings
while the read was in flight put a mainnet balance under a SepoliaETH label and
estimated its fee on Sepolia.

show() now builds the node of the network it is drawn for once and passes it
to the balance read, the fee estimate and the recipient check, and every native
symbol on the screen comes from the chain id it is drawn for.

Model: opus-5-5
2026-10-18 22:33:53 +00:00
sneak 1b9e14a4e9 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
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 at the scale the amount is shown with, from the node of the network it is drawn for. Until the read returns the balance line says "Loading..." and Sign & Send stays disabled; a read that fails is stated as unknown and blocks the send. The fee estimate starts once the read is done, since a max ETH send's amount comes from the balance. Send no longer hands balances over.

Model: opus-5-5
2026-10-18 22:29:39 +00:00
clawbot 8b0a1bff02 fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A connection allowed without "Remember my choice for this site" lived in a module-level object in the background, so it ended the first time Chrome stopped the idle worker. Each such connection is now one item in the browser's session storage, which outlives the worker and is emptied when the browser closes, read and written through new wrappers in src/shared/browserApi.js. Removal in Settings, address switch, address removal and wallet removal still end it; Settings now waits for the removal before redrawing.

manifest/firefox.json now declares a minimum of Firefox 115.0, the first with session storage.

Judgement call: address and wallet removal still notify the background without waiting.

Model: opus-5-5
2026-10-18 23:59:24 +02:00
23 changed files with 1068 additions and 240 deletions
+47 -28
View File
@@ -571,9 +571,12 @@ the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is run in which something else keeps the worker running fails. The site is
connected and the RPC endpoint set through the extension's own screens, against connected, without "Remember my choice for this site", and the RPC endpoint set
the loopback dApp page and stub node the Firefox suite also uses through the extension's own screens, against the loopback dApp page and stub
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors. node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
stopped, the run requires the site still to be told the address
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
above, it does not fail on page errors.
### Firefox (`make test-e2e-firefox`) ### Firefox (`make test-e2e-firefox`)
@@ -857,7 +860,11 @@ on the next event. Two consequences shape every recurring job in the background:
- Module-level variables do not survive either. Anything that must be remembered - Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage. `localStorage` does not exist in a across a restart goes in extension storage. `localStorage` does not exist in a
service worker at all — the one remaining user of it, `src/shared/ens.js`, service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such. runs only in the popup and is marked as such. The sites allowed without
"Remember my choice for this site" are kept in the browser's session storage
(`storage.session`), which outlives the worker but not the browser, so such a
connection lasts until it is ended or the browser closes. Firefox has session
storage from version 115, the minimum `manifest/firefox.json` declares.
Pending approvals are the exception: an approval waiting for the user, and the Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the site's request waiting on it, live only in the worker's memory, and only the
@@ -1006,8 +1013,10 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the wallet's balances and the Send screen, which sends on the active network, use
active network, use the active network's. A transaction's figures use the one of the active network's. The confirmation screen uses the one of the network it is
drawn for, the network active when it was shown, even after the network is
switched in Settings while it is open. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee transaction history and the transaction detail screen, and the refusal of a fee
@@ -1712,10 +1721,18 @@ view would leave a wallet one click from deletion.
the reserve the transaction does not use. If the balance no longer covers the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)` - Your balance: the From address's balance on the network the screen is
for a token whose scale is unknown drawn for, read from that network's RPC endpoint each time the screen is
shown, so a screen the popup reopens onto after the network was switched
states and checks the balance on the new network. "Loading..." until the
read returns, then value + symbol (USD in parentheses), or
`unknown (SYMBOL)` for a token whose scale is unknown or when the read
failed. The amount check uses the same balances: the ETH balance, and for
a token transfer the token balance as well
- Network fee: "Estimating..." then two lines, or "Unable to estimate", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost, fetched async once the balance is read, from the same RPC endpoint as the
balance, even if the network is switched in Settings while the balance is
being read. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the `gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the `gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no balance check gates on. The second line is omitted on a network with no
@@ -1725,19 +1742,20 @@ 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), plus three reserved error boxes — the - Errors (insufficient balance, or a balance that could not be read, which
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to asks the user to go back and try again), plus three reserved error boxes —
pay the fee for the transfer (ERC-20 transfers), and the fee could not be the amount plus the fee exceeds the balance (ETH transfers), not enough
estimated. The first two are mutually exclusive per transfer type, so only ETH to pay the fee for the transfer (ERC-20 transfers), and the fee could
the applicable one holds space. The last names its cause: for a token not be estimated. The first two are mutually exclusive per transfer type,
whose scale is unknown the fee can never be estimated, and it says the so only the applicable one holds space. The last names its cause: for a
wallet does not know how many decimal places the token uses and that the token whose scale is unknown the fee can never be estimated, and it says
transaction cannot be sent; for any other failure it asks the user to go the wallet does not know how many decimal places the token uses and that
back and try again 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, and while the network fee - "Sign & Send" button (disabled if errors, while the balance is being read,
estimate is pending or unavailable) and while the network fee estimate is pending or unavailable)
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
@@ -1936,7 +1954,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my - Connected Sites: the origins of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with holds these, in session storage, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES` `AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the origins remembered as denied, under any address, with - Denied Sites: the origins remembered as denied, under any address, with
remove buttons remove buttons
@@ -1955,9 +1973,10 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved `AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The `accountsChanged` with an empty list to the open tabs of that origin.
same host under another scheme or port is another site and is left alone. Settings waits for the background's answer before it draws the lists
Only the extension's own pages may send either message again. The same host under another scheme or port is another site and is
left alone. Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects - `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change) - Ten clicks on the version → reveals the Debug well (no screen change)
@@ -2018,8 +2037,8 @@ view would leave a wallet one click from deletion.
(`src/shared/walletDelete.js`) (`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of - Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops without "Remember", which only the background holds, in session storage,
on `AUTISTMASK_ADDRESSES_REMOVED` and drops on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please - "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword** - "I have lost my password" → **DeleteWalletLostPassword**
@@ -2132,8 +2151,8 @@ view would leave a wallet one click from deletion.
one in this wallet follows the splice. one in this wallet follows the splice.
- The address loses its site permissions of both kinds, whether or not it was - The address loses its site permissions of both kinds, whether or not it was
the active one: the remembered ones in storage, and any connection approved the active one: the remembered ones in storage, and any connection approved
without "Remember", which only the background holds, in memory, and drops on without "Remember", which only the background holds, in session storage, and
`AUTISTMASK_ADDRESSES_REMOVED`. drops on `AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a - The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed. fresh address rather than handing back the one just removed.
+2 -1
View File
@@ -27,7 +27,8 @@
"web_accessible_resources": ["src/content/inpage.js"], "web_accessible_resources": ["src/content/inpage.js"],
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "autistmask@sneak.berlin" "id": "autistmask@sneak.berlin",
"strict_min_version": "115.0"
} }
} }
} }
+78 -40
View File
@@ -49,6 +49,9 @@ const {
actionApi, actionApi,
getPlatformInfo, getPlatformInfo,
runtimeApi, runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery, tabsQuery,
tabsSendMessage, tabsSendMessage,
windowsApi, windowsApi,
@@ -61,12 +64,31 @@ const runtime = runtimeApi();
const windowsNs = windowsApi(); const windowsNs = windowsApi();
const actionNs = actionApi(); const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true } // Connections the user allowed without "Remember my choice for this site".
// Each is one item in the browser's session storage, key
// origin + ":" + address and value true, and session storage holds nothing
// else. Session storage outlives this worker being stopped when idle and
// started again, so such a connection lasts until it is ended below or the
// browser closes. One item per connection means that making one is a single
// write, and two never overwrite each other.
// //
// A site is its full origin (scheme://host[:port]), here and in the // A site is its full origin (scheme://host[:port]), here and in the
// remembered allowedSites/deniedSites lists alike: a grant to // remembered allowedSites/deniedSites lists alike: a grant to
// https://dapp.example says nothing about http://dapp.example or another port. // https://dapp.example says nothing about http://dapp.example or another port.
const connectedSites = {}; // An origin can carry a port, so the address is what follows the key's last
// colon and the origin is what precedes it.
async function isConnected(origin, address) {
const key = origin + ":" + address;
const items = await sessionStorageGet(key);
return items[key] === true;
}
// End every connection allowed without "Remember" whose key `ends` returns
// true for.
async function endConnections(ends) {
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
if (keys.length > 0) await sessionStorageRemove(keys);
}
// Pending approval requests: { id: { origin, resolve } } // Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
@@ -764,10 +786,10 @@ async function handleConnectionRequest(origin) {
}; };
} }
// Check allowed list or in-memory connected // Check allowed list or a connection allowed without "Remember"
if ( if (
allowed.includes(origin) || allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress] (await isConnected(origin, activeAddress))
) { ) {
return { result: [activeAddress] }; return { result: [activeAddress] };
} }
@@ -804,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) { if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin); await rememberSiteChoice("allowedSites", activeAddress, origin);
} else { } else {
connectedSites[origin + ":" + activeAddress] = true; await sessionStorageSet({ [origin + ":" + activeAddress]: true });
} }
return { result: [activeAddress] }; return { result: [activeAddress] };
} else { } else {
@@ -858,7 +880,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
allowed.includes(origin) || allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress] (await isConnected(origin, activeAddress))
) { ) {
return { result: [activeAddress] }; return { result: [activeAddress] };
} }
@@ -894,7 +916,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
!allowed.includes(origin) && !allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -987,10 +1009,10 @@ async function handleRpc(method, params, origin) {
const s = await getState(); const s = await getState();
const activeAddress = activeAddressOf(s); const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
const isConnected = const hasPermission =
allowed.includes(origin) || allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]; (await isConnected(origin, activeAddress));
if (!isConnected || !activeAddress) { if (!hasPermission || !activeAddress) {
return { result: [] }; return { result: [] };
} }
return { return {
@@ -1017,7 +1039,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
!allowed.includes(origin) && !allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1071,7 +1093,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
!allowed.includes(origin) && !allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1142,7 +1164,7 @@ async function handleSendTransaction(params, origin) {
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
!allowed.includes(origin) && !allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress] !(await isConnected(origin, activeAddress))
) { ) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
@@ -1254,10 +1276,6 @@ async function broadcastChainChanged(chainId) {
// Broadcast accountsChanged to all tabs, respecting per-address permissions // Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() { async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// Reject and close any pending approval popups so they don't hang. An // Reject and close any pending approval popups so they don't hang. An
// approval an attempt has already claimed is left alone entirely: it is // approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the // being signed and broadcast right now, and neither rejecting it to the
@@ -1276,6 +1294,10 @@ async function broadcastAccountsChanged() {
} }
} }
resetPopupUrl(); resetPopupUrl();
// An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives.
await endConnections(() => true);
const s = await getState(); const s = await getState();
const activeAddress = activeAddressOf(s); const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : []; const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
@@ -1290,7 +1312,7 @@ async function broadcastAccountsChanged() {
const hasPermission = const hasPermission =
activeAddress && activeAddress &&
(allowed.includes(origin) || (allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]); (await isConnected(origin, activeAddress)));
// Same as chainChanged above: a tab without our content script // Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault. // rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, { tabsSendMessage(tab.id, {
@@ -1917,41 +1939,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
// The popup removed these addresses, so no site stays connected to them. // The popup removed these addresses, so no site stays connected to them.
// A connectedSites key is origin + ":" + address, and an origin can carry
// a port, so the address is what follows the last colon.
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") { if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
const removed = Array.isArray(msg.addresses) ? msg.addresses : []; const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
for (const key of Object.keys(connectedSites)) { endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1); const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) { return removed.some((a) => sameAddress(a, address));
delete connectedSites[key]; }).catch((err) => {
} log.errorf(
} "ending the removed addresses' connections failed:",
err,
);
});
return false; return false;
} }
// Settings lists the sites connected without "Remember", which only this // Settings lists the sites connected without "Remember", which only
// worker knows. The origin is what precedes the key's last colon. // session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") { if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse( sessionStorageGet(null)
Object.keys(connectedSites).map((key) => .then((items) =>
key.slice(0, key.lastIndexOf(":")), Object.keys(items).map((key) =>
), key.slice(0, key.lastIndexOf(":")),
); ),
return false; )
.catch((err) => {
log.errorf("listing the connected sites failed:", err);
return [];
})
.then(sendResponse);
return true;
} }
// Settings removed this site (msg.origin) and has already dropped its // Settings removed this site (msg.origin) and has already dropped its
// remembered entries. Its connections approved without "Remember" end // remembered entries. Its connections approved without "Remember" end
// here, under every address, and its open tabs are told it has no account. // here, under every address, and its open tabs are told it has no account.
// The answer goes back once they have ended, and Settings waits for it
// before it lists the connected sites again.
if (msg.type === "AUTISTMASK_REMOVE_SITE") { if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) { endConnections(
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) { (key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
delete connectedSites[key]; )
} .catch((err) => {
} log.errorf(
broadcastSiteRemoved(msg.origin); "ending the removed site's connections failed:",
return false; err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
sendResponse(null);
});
return true;
} }
// Settings switched the network and has saved it. Open tabs are told the // Settings switched the network and has saved it. Open tabs are told the
+140 -64
View File
@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On Sign & Send, // Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers"); const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
@@ -11,7 +17,6 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -20,6 +25,7 @@ const {
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -32,6 +38,7 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { const {
displayedDecimals, displayedDecimals,
toDecimals,
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
@@ -60,6 +67,14 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from // amount was derived from. Null for any other send, which ethers prices from
// the node at signing time. // the node at signing time.
let maxSendFees = null; let maxSendFees = null;
// The From address's balances on the network this screen is drawn for, read
// from that network's node by readBalances() each time the screen is shown.
// The popup can reopen onto this screen after the network was switched, so
// any balance Send knew may be another network's. Null until the read
// returns; then `eth` and `token` are decimal strings, `token` null for an
// ETH send or a token whose scale is unknown, and both null if the read
// failed.
let balances = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -87,7 +102,9 @@ function valueWithUsd(text, usdAmount) {
// once its amount is re-derived from the fee estimate. // once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) { function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH"); const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
@@ -96,21 +113,56 @@ function renderAmount(txInfo) {
); );
} }
// The balance line, with its USD value: "Loading..." until readBalances()
// returns, then the balance it read.
function renderBalance(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
if (balances === null) {
$("confirm-balance").textContent = "Loading...";
return;
}
// A null balance is one nobody established, for a token whose scale is
// unknown or after a read that failed, and is stated as unknown, never as
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
const bal = isErc20 ? balances.token : balances.eth;
if (bal === null) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null; maxSendFees = null;
balances = null;
// The network this screen is drawn for, and the only one it sends on: // The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The // Settings can switch the active network while the screen is open. This
// wait, success and error screens label the amount by it too. // screen and the wait, success and error screens label amounts by it.
txInfo.chainId = currentNetwork().chainId; txInfo.chainId = currentNetwork().chainId;
// That network's node, read here once and passed to every read below, so
// a read still in flight when the network is switched, and the fee
// estimate that follows the balance read, stay on it.
const provider = getProvider(state.rpcUrl, state.networkId);
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the // screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped. // price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency(); const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol); const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
@@ -152,33 +204,7 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); renderAmount(txInfo);
renderBalance(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
// Check for warnings (synchronous local checks) // Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, { const localWarnings = getLocalWarnings(txInfo.to, {
@@ -212,15 +238,15 @@ function show(txInfo) {
$("confirm-gas-error").classList.toggle("hidden", !isErc20); $("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent = $("confirm-gas-error").textContent =
"You do not have enough " + "You do not have enough " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " + " to pay the network fee for this transfer. Please add " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again."; " to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract. // Shown later, once checkRecipientHistory() finds a contract.
$("confirm-contract-warning").textContent = $("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " + "WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" + " or tokens directly to a contract may result in permanent loss of" +
" funds."; " funds.";
@@ -262,25 +288,67 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible"; $("confirm-burn-warning").style.visibility = "visible";
} }
estimateGas(txInfo); // The fee is estimated once the balances are read, because a max ETH
checkRecipientHistory(txInfo); // send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo, provider).then(() => estimateGas(txInfo, provider));
checkRecipientHistory(txInfo, provider);
}
// Read the From address's balances from `provider`, the node of the network
// this screen is drawn for: ETH, and for a token send the token, at the scale
// the screen renders the amount with. A token whose scale is unknown has no
// balance to read. Never rejects.
async function readBalances(txInfo, provider) {
let read;
try {
const decimals = toDecimals(txInfo.tokenDecimals);
const readToken = txInfo.token !== "ETH" && decimals !== null;
const [ethWei, tokenUnits] = await Promise.all([
provider.getBalance(txInfo.from),
readToken
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
txInfo.from,
)
: null,
]);
read = {
eth: formatEther(ethWei),
token: readToken ? formatUnits(tokenUnits, decimals) : null,
};
} catch (e) {
log.errorf("balance read failed:", e.shortMessage || e.message);
read = { eth: null, token: null };
}
// The user may have left this transaction while the read was in flight.
if (pendingTx !== txInfo) return;
balances = read;
renderBalance(txInfo);
renderValidation(txInfo);
} }
// Render the balance check for the transaction on screen. Called once during // Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it // show(), again when the balances are read, and again when the fee estimate
// touches already occupies its space, so re-running it never moves anything. // resolves or fails. Every element it touches already occupies its space, so
// 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 const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?") ? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency(); : 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.
const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null;
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
amount: txInfo.amount, amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals, tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance, ethBalance,
tokenBalance: txInfo.tokenBalance, tokenBalance,
feeStatus, feeStatus,
feeWei, feeWei,
}); });
@@ -292,9 +360,16 @@ function renderValidation(txInfo) {
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 (codes.includes(CODES.INSUFFICIENT_TOKEN)) { if (balances !== null && !balanceRead) {
messages.push( messages.push(
txInfo.tokenBalance == null "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" + ? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" + " wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" + " uses, so the amount you are trying to send cannot be" +
@@ -302,7 +377,7 @@ function renderValidation(txInfo) {
: "Insufficient " + : "Insufficient " +
symbol + symbol +
" balance. You have " + " balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) + truncateAmountNeverZero(tokenBalance) +
" " + " " +
symbol + symbol +
" but are trying to send " + " but are trying to send " +
@@ -312,10 +387,10 @@ function renderValidation(txInfo) {
".", ".",
); );
} }
if (codes.includes(CODES.INSUFFICIENT_ETH)) { if (balanceRead && codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push( messages.push(
"Insufficient balance. You have " + "Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") + truncateAmountNeverZero(ethBalance) +
" " + " " +
symbol + symbol +
" but are trying to send " + " but are trying to send " +
@@ -339,11 +414,11 @@ function renderValidation(txInfo) {
setVisible( setVisible(
"confirm-amount-fee-error", "confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
); );
setVisible( setVisible(
"confirm-gas-error", "confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
); );
setVisible( setVisible(
"confirm-fee-unknown-error", "confirm-fee-unknown-error",
@@ -362,9 +437,10 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(id).style.visibility = visible ? "visible" : "hidden";
} }
async function estimateGas(txInfo) { // Estimate the network fee on `provider`, the node of the network this screen
// is drawn for.
async function estimateGas(txInfo, provider) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
let gasLimit; let gasLimit;
@@ -412,12 +488,13 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = $("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency()); "~" +
formatFee(estimateWei, nativeCurrencyByChainId(txInfo.chainId));
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
" " + " " +
nativeCurrency() + nativeCurrencyByChainId(txInfo.chainId) +
" reserved"; " reserved";
setVisible("confirm-fee-reserve", true); setVisible("confirm-fee-reserve", true);
} else { } else {
@@ -426,20 +503,20 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee( $("confirm-fee-amount").textContent = formatFee(
gasCostWei, gasCostWei,
nativeCurrency(), nativeCurrencyByChainId(txInfo.chainId),
); );
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the // A max ETH send is the balance readBalances() read minus this
// Send screen's, and is signed with this estimate's fee fields: fees // estimate's reserve, not the Send screen's, and is signed with this
// fetched again at signing could exceed the reserve it leaves, and the // estimate's fee fields: fees fetched again at signing could exceed
// node would refuse it for want of funds. Where the balance no longer // the reserve it leaves, and the node would refuse it for want of
// covers the fee, the amount is left as it is and the balance check // funds. Where the balance is unknown or no longer covers the fee, the
// below says so. // amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") { if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei); const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) { if (amount !== null) {
txInfo.amount = amount; txInfo.amount = amount;
renderAmount(txInfo); renderAmount(txInfo);
@@ -514,9 +591,8 @@ function showSendError(message) {
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo, provider) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
}); });
+9 -16
View File
@@ -164,8 +164,9 @@ function renderSendTokenSelect(addr) {
} }
} }
// The token balance and scale the Send screen states and hands the // The token balance the Send screen states, and the scale it hands the
// confirmation screen, so the two screens describe the holding the same way. // confirmation screen, which reads the balance itself from the network it is
// drawn for.
// //
// The scale is resolved the same way balances.js resolved the scale it // The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked // DISPLAYED this token's balance at: bundled list, then the user's tracked
@@ -188,13 +189,11 @@ function renderSendTokenSelect(addr) {
// //
// The two resolutions can therefore differ, and where they do, the stored // The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to // `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount // stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// against a number the wallet does not vouch for, so it is withdrawn: unknown // null rather than "0": this screen states it as unknown, as the confirmation
// scale means unknown balance. It is null rather than "0": both screens state // screen does, and Max fills nothing in for it. Only a stored quantity is
// an unknown balance as unknown, and validateTransfer() treats it as no balance // withdrawn: the "0" for a token that has no row at all is an absence of
// to spend from, which is the fail-closed side of an amount nobody can check. // holdings, which is true at every scale.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) { function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -378,7 +377,6 @@ function init(_ctx) {
const addr = currentAddress(); const addr = currentAddress();
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null; let tokenDecimals = null;
if (token !== "ETH") { if (token !== "ETH") {
tokenSymbol = resolveSymbol( tokenSymbol = resolveSymbol(
@@ -386,10 +384,7 @@ function init(_ctx) {
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
); );
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals( ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
addr,
token,
));
} }
// The confirmation screen's own amount check, so it is never shown // The confirmation screen's own amount check, so it is never shown
@@ -424,9 +419,7 @@ function init(_ctx) {
ensName: ensName, ensName: ensName,
amount: amount, amount: amount,
token: token, token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax, max: amountIsMax,
}); });
+6 -4
View File
@@ -76,11 +76,13 @@ function forgetOrigin(siteMap, origin) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is // Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its // no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs. // connections approved without "Remember" and tells its open tabs. The
// background answers once those have ended, so the lists drawn after it no
// longer show the site.
async function removeAllowedSite(origin) { async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin); forgetOrigin(state.allowedSites, origin);
await saveState(); await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin }); await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists(); await renderSiteLists();
} }
@@ -230,8 +232,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(), Object.values(state.deniedSites).flat(),
removeDeniedSite, removeDeniedSite,
); );
// Sites allowed without "Remember" are held only by the background, in // Sites allowed without "Remember" are held only in session storage, which
// memory, so it is asked for them. // the background keeps, so it is asked for them.
renderSiteList( renderSiteList(
"settings-connected-sites", "settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }), await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
+56 -5
View File
@@ -174,9 +174,9 @@ function notify(message) {
// default: resolving {} would make an existing wallet read back as no wallet, // default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing // and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly. // logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) { function storageUnavailable(area, method) {
return Promise.reject( return Promise.reject(
new Error("extension storage.local is not available: " + method), new Error("extension storage." + area + " is not available: " + method),
); );
} }
@@ -187,7 +187,7 @@ function storageUnavailable(method) {
*/ */
function storageGet(keys) { function storageGet(keys) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("get"); if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys)); return Promise.resolve(storage.get(keys));
} }
@@ -198,7 +198,7 @@ function storageGet(keys) {
*/ */
function storageSet(items) { function storageSet(items) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("set"); if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items)); return Promise.resolve(storage.set(items));
} }
@@ -215,7 +215,55 @@ function storageSet(items) {
*/ */
function storageRemove(keys) { function storageRemove(keys) {
const storage = storageLocal(); const storage = storageLocal();
if (!storage) return storageUnavailable("remove"); if (!storage) return storageUnavailable("local", "remove");
return Promise.resolve(storage.remove(keys));
}
// `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background
// scheduling). The background keeps the connections allowed without
// "Remember" there; see src/background/index.js. Firefox has it from version
// 115, the minimum manifest/firefox.json declares.
//
// The three below reject where it is absent, as the three above do where
// `storage.local` is: a write that silently did nothing would tell a site it
// is connected and then refuse its next request.
function storageSession() {
const storage = namespaceMember("storage");
return (storage && storage.session) || null;
}
/**
* @param {string|string[]|null} keys null for every item.
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageGet(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageSet(items) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "set");
return Promise.resolve(storage.set(items));
}
/**
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageRemove(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "remove");
return Promise.resolve(storage.remove(keys)); return Promise.resolve(storage.remove(keys));
} }
@@ -307,6 +355,9 @@ module.exports = {
notify, notify,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet, storageGet,
storageLocal, storageLocal,
storageRemove, storageRemove,
+2 -2
View File
@@ -111,8 +111,8 @@ function isValidAmount(amount, tokenDecimals = null) {
// The most ETH a send can carry: the exact balance minus the fee reserve from // The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with // feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string // exactly that reserve left behind. `ethBalance` is an exact decimal string as
// balances.js stores, never a rounded one. Null when the balance does not // formatEther() writes it, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is // leave anything to send once the fee is paid, or when either input is
// unusable. // unusable.
function maxEthAmount(ethBalance, feeWei) { function maxEthAmount(ethBalance, feeWei) {
+1 -1
View File
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
// Forget every site permission held against the given addresses: the // Forget every site permission held against the given addresses: the
// remembered ones in `state`, and the connections approved without // remembered ones in `state`, and the connections approved without
// "Remember", which only the background holds, in memory. // "Remember", which only the background holds, in session storage.
function dropSitePermissions(state, addresses) { function dropSitePermissions(state, addresses) {
for (const addr of addresses) { for (const addr of addresses) {
delete state.allowedSites[addr]; delete state.allowedSites[addr];
-1
View File
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT, to: RECIPIENT,
amount: "0.5", amount: "0.5",
token: "ETH", token: "ETH",
balance: "1.0",
}); });
await settle(); await settle();
node("confirm-tx-password").value = PASSWORD; node("confirm-tx-password").value = PASSWORD;
+8 -8
View File
@@ -321,9 +321,10 @@ function loadBackground(options) {
require("../src/background/index"); require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the // Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse. // handler passed to sendResponse. `onResponse`, when given, receives the
function send(msg, sender) { // answer when the handler sends it, as the sender's callback does.
const sendResponse = jest.fn(); function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse); const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept }; return { sendResponse, kept };
} }
@@ -2615,9 +2616,9 @@ async function siteAccounts(bg, origin) {
return sendResponse.mock.calls[0][0]; return sendResponse.mock.calls[0][0];
} }
// A site connected without "Remember" is held only in the background's memory, // A site connected without "Remember" is held only in the background's session
// keyed to the address it was connected to. Removing that address, or the // storage, keyed to the address it was connected to. Removing that address, or
// wallet holding it, must end the connection as part of the removal itself. // the wallet holding it, must end the connection as part of the removal itself.
// The accountsChanged broadcast the views send afterwards also clears it, but // The accountsChanged broadcast the views send afterwards also clears it, but
// only when the active address moved, and nothing waits for it to arrive. // only when the active address moved, and nothing waits for it to arrive.
// //
@@ -2796,8 +2797,7 @@ describe("removing a site in Settings disconnects it", () => {
const element = (id) => (lists[id] ||= fakeSiteList()); const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element }; global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => { global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup); bg.send(msg, bg.fromPopup, callback);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
}; };
await require("../src/shared/state").loadState(); await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists(); await require("../src/popup/views/settings").renderSiteLists();
+225 -11
View File
@@ -7,17 +7,35 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in // reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between. // Settings in between.
// //
// Its balance line and balance check are that network's too
// (https://git.eeqj.de/sneak/AutistMask/issues/558). The popup can reopen onto
// a confirmation screen saved before the network was switched in another
// popup, and the screen, drawn again for the new network, showed and checked
// the balance Send had read on the old one. The balance read and the fee
// estimate after it stay on that network when it is switched while they run.
//
// Driven through the real confirmation screen, signer and chain switch, with // Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its // only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to. // provider was built for, so a broadcast records the network it went to, and
// a balance read answers with that network's balance.
"use strict"; "use strict";
// The signed transactions the stub node was handed, with its network's chain id. // The balances the stub node reports on each network, by network name: ETH
const mockNode = { broadcast: [] }; // in wei and the token in its base units. The networks whose node was asked
// for the fee, in order. And the signed transactions it was handed, with its
// network's chain id.
const mockNode = {
balanceWei: {},
tokenUnits: {},
feeAskedOn: [],
broadcast: [],
};
jest.mock("ethers", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider { class StubProvider {
constructor(url, network) { constructor(url, network) {
this.network = network; this.network = network;
@@ -25,7 +43,22 @@ jest.mock("ethers", () => {
async getNetwork() { async getNetwork() {
return this.network; return this.network;
} }
async getBalance() {
return mockNode.balanceWei[this.network.name];
}
// balanceOf() is the one contract call the screen makes before Sign
// & Send.
async call(tx) {
if (!tx.data.startsWith(BALANCE_OF)) {
throw new Error("the stub node answers only balanceOf()");
}
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() { async getFeeData() {
mockNode.feeAskedOn.push(this.network.name);
return { return {
maxFeePerGas: 20000000000n, maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n,
@@ -123,7 +156,13 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers"); const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault"); const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state"); const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -135,6 +174,8 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address; const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple"; const PASSWORD = "correct horse battery staple";
// A token with 6 decimal places, which the bundled list does not know.
const TOKEN = "0x" + "d".repeat(40);
function el(id) { function el(id) {
return global.document.getElementById(id); return global.document.getElementById(id);
@@ -150,6 +191,12 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.feeAskedOn = [];
mockNode.broadcast = []; mockNode.broadcast = [];
txStatus.showWait.mockClear(); txStatus.showWait.mockClear();
txStatus.showError.mockClear(); txStatus.showError.mockClear();
@@ -160,16 +207,23 @@ afterEach(() => {
signTransaction.mockRestore(); signTransaction.mockRestore();
}); });
// Show the confirmation screen for 1 of the native token on `networkId`, as // show() reads the balances and estimates the fee without awaiting either;
// Review on the Send screen does, with its fee estimate settled. // this lets both settle.
async function review(networkId) { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for what `send` describes instead, as Review on the Send screen does, with
// its balance read and its fee estimate settled.
async function review(networkId, send = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
state.wallets = [ state.wallets = [
{ {
type: "key", type: "key",
name: "Wallet 1", name: "Wallet 1",
encryptedSecret, encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }], addresses: [{ address: HOLDER }],
}, },
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -181,13 +235,32 @@ async function review(networkId) {
ensName: null, ensName: null,
amount: "1", amount: "1",
token: "ETH", token: "ETH",
balance: "5.0",
tokenSymbol: null, tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...send,
}); });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); await settle();
}
// Switch the network to `networkId` as Settings in another popup does, then
// open the popup again onto the confirmation screen it saved.
async function reopenOn(networkId) {
const saved = JSON.parse(JSON.stringify(state.viewData));
await onChainSwitch(networkId);
elements.clear();
state.viewData = saved;
confirmTx.init({});
confirmTx.restore();
await settle();
}
function errors() {
return el("confirm-errors").innerHTML;
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
} }
async function signAndSend() { async function signAndSend() {
@@ -257,3 +330,144 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
}); });
describe("a confirmation screen saved on mainnet and reopened after a switch to Sepolia", () => {
test("states and checks the Sepolia balance, which covers the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("0.5"),
sepolia: parseEther("5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("0.5000 ETH");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-type").textContent).toBe(
"Native SepoliaETH transfer",
);
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(errors()).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia balance, which does not cover the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("0.5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(sendDisabled()).toBe(false);
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(sendDisabled()).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("states and checks the Sepolia token balance, which covers the send", async () => {
mockNode.tokenUnits = { mainnet: 0n, sepolia: 10000000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("0.0000 TOK");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(el("confirm-gas-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia token balance, which does not cover the send", async () => {
mockNode.tokenUnits = { mainnet: 10000000n, sepolia: 500000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(sendDisabled()).toBe(false);
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(sendDisabled()).toBe(true);
});
});
describe("the balance read", () => {
test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("Loading...");
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
expect(el("confirm-errors").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// 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 () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("mainnet");
} finally {
getBalance.mockRestore();
}
expect(mockNode.feeAskedOn).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(mockNode.feeAskedOn).toEqual(["mainnet"]);
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
test("that fails states the balance as unknown, not zero, and blocks sending", async () => {
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockRejectedValueOnce(new Error("the node did not answer"));
try {
await review("sepolia");
} finally {
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(sendDisabled()).toBe(true);
});
});
+354
View File
@@ -0,0 +1,354 @@
// A site allowed without "Remember my choice for this site" stays connected
// when the background worker is stopped and started again
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// Chrome stops the worker after about 30 seconds idle and evaluates the
// module afresh on the next event, so nothing held at module scope survives
// it. Such a connection used to be held exactly there, and so lasted until the
// first idle spell. Each test here makes the connection in one load of the
// background, loads the module afresh over the same storage, as the browser
// does, and asks the new one. The paths that end a connection are driven after
// that restart, so each is shown to end what the earlier worker stored.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const SITE = "https://dapp.example";
const OTHER_SITE = "https://other.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One wallet with two addresses, so that one of them can be removed, and no
// remembered sites, so that every connection here is one made without
// "Remember".
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: {},
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
// Load the background module afresh, as the browser does when it starts the
// worker, over `storage`. The storage stub stands in for the browser's own
// storage, local and session, so it outlives each load.
function startWorker(storage) {
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;
let connectListener = null;
// Every URL the toolbar popup was set to open: a site's connection prompt
// is shown there, so this is where its approval id appears.
const toolbarPopups = [];
const windowsCreated = [];
const sentToTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
getPlatformInfo: (cb) => cb({ os: "linux" }),
// What the popup sends reaches the worker from the extension's own
// page.
sendMessage: (msg, callback) => {
messageListener(msg, FROM_POPUP, callback || (() => {}));
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
windowsCreated.push(options);
cb({ id: windowsCreated.length });
},
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: (o) => {
toolbarPopups.push(o.popup);
},
openPopup: () => Promise.resolve(),
},
};
require("../src/background/index");
// A request from a site, answered once the worker has had time to answer.
async function rpc(method, origin, params) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin },
(r) => {
response = r;
},
);
await settle();
return response;
}
// A message from the popup, and the worker's answer to it.
async function fromPopup(msg) {
let response;
messageListener(msg, FROM_POPUP, (r) => {
response = r;
});
await settle();
return response;
}
// The site asks to connect, and the user allows it in the prompt with
// "Remember my choice for this site" left unchecked.
async function connectWithoutRemember(origin) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
{ origin },
(r) => {
response = r;
},
);
await settle();
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
"approval",
);
const decisionListeners = [];
const port = {
name: "approval:" + id,
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
onDisconnect: { addListener: () => {} },
};
connectListener(port);
for (const fn of decisionListeners) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved: true,
remember: false,
},
port,
);
}
await settle();
return response;
}
return {
rpc,
fromPopup,
connectWithoutRemember,
windowsCreated,
sentToTabs,
};
}
// A site connected without "Remember" in one worker, and the worker started
// again over the same storage, where the site is still connected.
async function connectedThenRestarted() {
const storage = makeStorageStub({ autistmask: storedProfile() });
const first = startWorker(storage);
expect(await first.connectWithoutRemember(SITE)).toEqual({
result: [ADDRESS],
});
const worker = startWorker(storage);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
return { storage, worker };
}
describe("a site allowed without Remember, after the worker restarts", () => {
test("is still told the address, and is not asked again", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
result: [ADDRESS],
});
});
test("is still listed under Connected Sites in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([SITE]);
});
test("may still ask for a signature", async () => {
// Refused with 4100 before the fix. Allowed, it raises the signing
// prompt and waits on the user.
const { worker } = await connectedThenRestarted();
const response = await worker.rpc("personal_sign", SITE, [
"0x48656c6c6f",
ADDRESS,
]);
expect(response).toBeNull();
expect(worker.windowsCreated).toHaveLength(1);
});
test("is not connected after the browser restarts", async () => {
// The browser empties session storage when it closes; the wallet's
// own storage keeps nothing of the connection.
const { storage } = await connectedThenRestarted();
const afterBrowserRestart = startWorker(
makeStorageStub({ autistmask: storage.read("autistmask") }),
);
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
result: [],
});
});
});
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
test("removing the site in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
result: [ADDRESS],
});
await worker.fromPopup({
type: "AUTISTMASK_REMOVE_SITE",
origin: SITE,
});
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(worker.sentToTabs).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([OTHER_SITE]);
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
result: [ADDRESS],
});
});
test("switching to another address", async () => {
// Switched away and back again, so the answer turns on the connection
// having ended rather than on which address is active.
const { storage, worker } = await connectedThenRestarted();
const switched = { ...storage.read("autistmask") };
switched.activeAddress = OTHER_ADDRESS;
storage.write("autistmask", switched);
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
switched.activeAddress = ADDRESS;
storage.write("autistmask", switched);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([]);
});
// The two below ask while storage still names the removed address as
// active, because the popup has not saved yet, so the answer turns on the
// connection alone.
test("removing the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing the wallet holding the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
removeWalletFromState(storage.read("autistmask"), 0);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing a different address leaves it connected", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
});
});
+14 -6
View File
@@ -244,16 +244,21 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4], and the same for symbol() and name(). // keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41"; const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol() // Every eth_call still answers with a zero word except decimals(), symbol(),
// and name() on the stub token. The wallet reads decimals() back at signing // name() and balanceOf() on the stub token. The wallet reads decimals() back
// time to compare with the scale the confirmation screen rendered (issue // at signing time to compare with the scale the confirmation screen rendered
// #305). Adding the token by its contract address reads all three (issue // (issue #305). Adding the token by its contract address reads the first three
// #295); symbol() and name() answer what the explorer reports for it. // (issue #295); symbol() and name() answer what the explorer reports for it.
// The confirmation screen reads balanceOf()
// (https://git.eeqj.de/sneak/AutistMask/issues/558), which answers the holding
// the explorer reports, or zero when no holding is seeded.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
@@ -278,6 +283,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) { if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]); return abi.encode(["string"], [tokenObject(opts).name]);
} }
if (data.startsWith(SELECTOR_BALANCE_OF) && opts.seedTokenBalance) {
return word(tokenBalanceItems(opts)[0].value);
}
return ZERO_WORD; return ZERO_WORD;
} }
+12 -4
View File
@@ -2303,6 +2303,16 @@ 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.
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
await assertAddressesFit(page, "the confirmation screen"); await assertAddressesFit(page, "the confirmation screen");
} }
@@ -3861,10 +3871,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// all; this measures that the whole address is visible (#517). // all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt"); await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3 // Remembered, because the sign and transaction tests below all
// terminates after 30 seconds idle. The in-memory connectedSites map // require the origin to still be authorized.
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember"); await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve"); await clickAndClose(popup, "#btn-approve");
+22 -3
View File
@@ -22,6 +22,11 @@
// Before the approvals are raised it watches the worker stop on its own, so a // Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass. // run in which something else keeps the worker running cannot pass.
// //
// The site is connected without "Remember", and once the worker has stopped
// the program requires the site still to be told the address: such a
// connection used to be held in the worker's memory and ended with it
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// The dApp page and the stub node are the loopback ones the Firefox suite also // The dApp page and the stub node are the loopback ones the Firefox suite also
// uses (tests/e2e/dapp.js). The site is connected and the node chosen // uses (tests/e2e/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage // through the extension's own screens, so nothing is written to its storage
@@ -311,8 +316,8 @@ async function main() {
); );
await send("Target.closeTarget", { targetId: popup.targetId }); await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so // The site, connected through the real prompt without "Remember", so
// the connection is stored rather than held by the worker. // the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url); const dapp = await openPage(node.url);
await until("the injected provider", () => await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"), dapp.evaluate("!!window.ethereum && !!window.__dapp"),
@@ -329,7 +334,7 @@ async function main() {
inPrompt(shows("view-approve-site")), inPrompt(shows("view-approve-site")),
); );
await inPrompt( await inPrompt(
"document.getElementById('approve-remember').checked = true", "document.getElementById('approve-remember').checked = false",
); );
// The prompt closes itself on the click. // The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {}); await inPrompt(click("btn-approve")).catch(() => {});
@@ -365,6 +370,20 @@ async function main() {
"s", "s",
); );
// The site's request starts the worker again, and the new worker still
// has the connection allowed without "Remember"
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
const stillConnected = await dapp.evaluate(
"window.ethereum.request({ method: 'eth_accounts' })",
);
check(
"a site allowed without Remember is still connected after the worker stopped",
Array.isArray(stillConnected) &&
stillConnected.length === 1 &&
getAddress(stillConnected[0]) === address,
"eth_accounts answered " + JSON.stringify(stillConnected),
);
// A transaction approval and a signature approval, both open at once. // A transaction approval and a signature approval, both open at once.
await dapp.evaluate( await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" + "window.__dapp.start('tx', 'eth_sendTransaction', [" +
+4
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
// The 1.5 of the native token the address holds on either network.
async getBalance() {
return 1500000000000000000n;
}
// 10 gwei expected, 20 gwei reserved per gas. // 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() { async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
-1
View File
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
amount: "1", amount: "1",
balance: "2",
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
+9 -2
View File
@@ -23,7 +23,10 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// What the confirmation screen asks the node for. const { AbiCoder } = require("ethers");
// What the confirmation screen asks the node for. The address holds the 1 ETH
// and 250 TOK the wallet below records.
const mockProvider = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -32,7 +35,11 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 1000000000000000000n,
// The one contract call the screen makes, balanceOf(), answered at the
// token's 6 decimal places.
call: async () =>
AbiCoder.defaultAbiCoder().encode(["uint256"], [250000000n]),
}; };
jest.mock("../src/shared/balances", () => { jest.mock("../src/shared/balances", () => {
+12 -1
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs. // What the stub node answers. Each test sets what it needs.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n }, feeData: { maxFeePerGas: 1n, gasPrice: 1n },
}; };
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -196,9 +204,12 @@ function errors() {
} }
// 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. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
+13 -1
View File
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed. // What the stub node answers, and the signed transactions it was handed.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: null, feeData: null,
broadcast: [], broadcast: [],
}; };
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make before Sign
// & Send.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -187,9 +196,12 @@ function text(id) {
} }
// 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. // fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [ state.wallets = [
{ {
+23 -15
View File
@@ -1,4 +1,4 @@
// A chrome.storage.local stub that behaves like the real one. // A chrome.storage stub that behaves like the real one.
// //
// The real extension storage API is a serialization boundary: `set` writes a // The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured // structured clone of what it is given, and `get` hands back a structured
@@ -17,13 +17,8 @@
// So every test that drives real persistence uses this, and nothing rebuilds // So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand. // a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }. // One storage area: get, set and remove over its own store, cloning both ways.
// `onOp` runs before each operation, for a test that needs to advance a clock function makeArea(store, tick) {
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const get = jest.fn(async (key) => { const get = jest.fn(async (key) => {
tick(); tick();
if (key === undefined || key === null) return structuredClone(store); if (key === undefined || key === null) return structuredClone(store);
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
for (const k of Array.isArray(key) ? key : [key]) delete store[k]; for (const k of Array.isArray(key) ? key : [key]) delete store[k];
}); });
return { get, set, remove };
}
// `initial` is the starting contents of `storage.local`, keyed as storage is:
// { autistmask: {...} }. `storage.session` starts empty.
// `onOp` runs before each operation on either area, for a test that needs to
// advance a clock or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
return { return {
// Drop this straight in as chrome.storage. // Drop this straight in as chrome.storage.
local: { get, set, remove }, local,
get, session: makeArea({}, tick),
set, get: local.get,
remove, set: local.set,
// What is stored, cloned on the way out: a test can neither observe a remove: local.remove,
// later write through an object it read nor reach into the store by // What is stored in `storage.local`, cloned on the way out: a test can
// mutating one. // neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) => read: (key) =>
key === undefined key === undefined
? structuredClone(store) ? structuredClone(store)
+31 -26
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas // Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure // estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub. // in these tests is a failure of the scale and not of the stub. The address
// holds the 1 ETH the wallets below record, and `tokenUnits` of the token.
const mockProvider = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
}; };
jest.mock("../src/shared/balances", () => { jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -155,8 +163,9 @@ function row(token = {}, value = FIVE_WETH) {
} }
// Fetch the explorer's rows through the real fetcher and put them exactly where // Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. // refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) { async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach // is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows. // explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) { async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -270,7 +280,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5"); const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18); expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals); expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
}); });
test("that scale estimates a fee and leaves Send enabled", async () => { test("that scale estimates a fee and leaves Send enabled", async () => {
@@ -307,7 +316,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the // No fallback was introduced: resolution answers null here, and the
// null is what goes forward. // null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
@@ -355,7 +363,8 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance. // The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
}); });
test("so the user is told the balance is unknown, not only that the fee failed", async () => { test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -434,7 +443,6 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL"); expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6); expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
@@ -444,21 +452,10 @@ describe("a scale the explorer's own rows disagree about", () => {
}); });
describe("the confirmation screen tells an unknown balance from a zero one", () => { describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) { // A send of 1.5 NOVEL from an address the node reports none of, with the
return { // token's scale `tokenDecimals`, or null for a scale nobody knows.
from: HOLDER, async function render(tokenDecimals) {
to: RECIPIENT, mockProvider.tokenUnits = 0n;
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -469,14 +466,22 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance)); confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
await settle(); await settle();
return { balance: text("confirm-balance"), errors: errors() }; return { balance: text("confirm-balance"), errors: errors() };
} }
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 () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.balance).not.toBe(zero.balance); expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)"); expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL"); expect(zero.balance).toBe("0.0000 NOVEL");
@@ -487,7 +492,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
// about the holding, and this one has no established quantity to 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 () => { test("the insufficient-balance message names the reason, not a figure", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have"); expect(unknown.errors).not.toContain("You have");