1 Commits
Author SHA1 Message Date
sneak 871fa80234 test: the popup boot in the jest suite sends no request to the internet (closes #559)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
bootPopup() in tests/support/popupBoot.js mocked three of the modules
that reach the network, but a boot onto the transaction screen still
asked Blockscout for its transaction, and a boot onto the confirmation
screen had src/shared/etherscanLabels.js look the recipient up on
Etherscan, both through the real fetch().
tests/persistedFieldContract.test.js boots onto both, so every run sent
those requests and logged their answers after its tests had finished.
bootPopup() now replaces fetch() with the throwing stub the rest of the
suite uses, and cleanupPopup() puts it back. tests/popupBoot.test.js
checks that both requests reach the stub.

Model: opus-5-5
2026-10-19 05:58:42 +00:00
75 changed files with 1109 additions and 13247 deletions
+168 -606
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -13,11 +13,6 @@
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"jest": {
"setupFiles": [
"<rootDir>/tests/support/navigatorLocks.js"
]
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "4.3.1",
-8
View File
@@ -1379,13 +1379,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
async function backgroundRefresh() {
const s = await getState();
const now = Date.now();
@@ -1409,7 +1402,6 @@ async function backgroundRefresh() {
}
await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
+26 -19
View File
@@ -16,20 +16,17 @@
// across any number of awaits and no concurrent work can
// move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by
// the background or by any page except a page in a
// Firefox private window (README, "Stored state and its
// version"). The read is one storage round trip ahead of
// the write rather than a page lifetime ahead of it
// (which is what made the popup's saveState() need a
// back — all inside a queue, so two background writes
// never interleave, and the read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding.
const { storageGet, storageSet, withLock } = require("../shared/browserApi");
const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState");
const {
STATE_SCHEMA_VERSION,
@@ -54,6 +51,11 @@ async function getState() {
return normalizePersisted(result.autistmask);
}
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) {
const s = await getState();
await mutate(s);
@@ -68,22 +70,27 @@ async function updateStateOnce(mutate) {
// Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow. The write is the
// WHOLE record, which is safe only because the lock (withLock() in
// src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held, except a page in a Firefox private window, which Firefox
// gives locks of its own (README, "Stored state and its version").
// place; it may be async, but it must not do anything slow — the window
// between the read and the write is the window in which another context's
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here. Concretely: the write is the WHOLE record, so
// a popup write that lands inside that window is reverted, in every field, by
// the record this turn read before it. That is accepted because the window is
// one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds,
// and the outer one is waiting on the inner one. That deadlocks every write of
// the stored record, not just the caller. Mutate the record you were handed.
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
return withLock("autistmask", () => updateStateOnce(mutate));
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };
+30 -74
View File
@@ -90,7 +90,7 @@
<div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row
of its own it pushes Import below the fold at
396x600. -->
360x600. -->
<div class="mb-2 flex justify-between items-start gap-2">
<p>
Enter your 12 or 24 word recovery phrase below, or
@@ -212,7 +212,7 @@
/>
</div>
<!-- The error line sits beside Import, not above it: at
396x600 the button already starts near the bottom of
360x600 the button already starts near the bottom of
the popup, and a line of its own would push it below
the fold. The longest error fits on one line here. -->
<div class="flex items-center gap-2">
@@ -664,20 +664,6 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div>
<!-- Its sentence names the token, or why the token's balance
is unknown, so show() in confirmTx.js sets it. -->
<div
id="confirm-balance-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div
id="confirm-balance-read-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance could not be read from the network, so this
transaction cannot be checked against it. Please go back and
try again.
</div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
@@ -697,33 +683,6 @@
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<!-- Shown when Sign & Send refuses a send for its network, for
a From address the selected wallet does not hold, or for its
gas limit or fee. Each holds its space from the first paint, so
the refusal moves nothing. -->
<div
id="confirm-network-changed-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The active network changed after this transaction was
reviewed, so it was not sent. Please go back and review it
again.
</div>
<div
id="confirm-from-address-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The selected wallet does not hold the From address above, so
nothing was sent. Please go back and send again.
</div>
<div
id="confirm-fee-bound-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The gas limit or fee the RPC endpoint quoted for this
transaction is more than this wallet will sign for, so it
was not sent. Please go back and try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
@@ -879,7 +838,7 @@
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
></div>
<div id="add-token-common" class="mb-2">
<div class="mb-2">
<label class="block mb-1 text-xs text-muted"
>Or pick a common token:</label
>
@@ -1363,39 +1322,36 @@
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
Common tokens are offered on Ethereum Mainnet only.
</p>
<div id="settings-addtoken-common">
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Top tokens:</label
>
<div
id="settings-addtoken-top10"
class="flex flex-wrap gap-1"
></div>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
<!-- top 100 dropdown -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label
>
<select
id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="">-- select --</option>
</select>
<button
id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add selected
</button>
</div>
<!-- manual contract address -->
+5 -74
View File
@@ -5,14 +5,12 @@ const {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
@@ -24,7 +22,6 @@ const {
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
@@ -71,55 +68,23 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh.
let refreshTimer = null;
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followNetworkSwitch() below).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
refreshPrices(),
refreshBalances(
wallets,
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
networkId,
state.networkId,
pageClosed.signal,
),
]);
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
}
}
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
const got = refreshed.get(addr.address);
if (!got) continue;
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
state.lastBalanceRefresh = Date.now();
return true;
});
state.lastBalanceRefresh = Date.now();
await saveState();
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
@@ -151,7 +116,6 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -219,28 +183,6 @@ function fallbackView() {
showView("main");
}
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
async function followNetworkSwitch() {
let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId === state.networkId) return false;
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
switched = true;
}).catch(() => {});
if (switched) settings.showNewNetwork();
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
@@ -278,7 +220,7 @@ async function widenApprovalWindow() {
async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
// reported rather than reaching only the console
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
//
@@ -385,17 +327,6 @@ async function init() {
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) {
showView("welcome");
} else {
+10 -26
View File
@@ -38,11 +38,9 @@ function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one after a network switch, because every screen then on the page
// shows the previous network (showNewNetwork() in views/settings.js); the
// Begin a fresh page-load scope. The popup gets one by being loaded; the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
// instance, ask for one.
function resetRenderedViews() {
renderedViews.clear();
}
@@ -127,33 +125,28 @@ function isRenderableDecoded(value) {
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
// when it is absent, picks the network whose explorer the address links open,
// so it has to name one.
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open and whose
// Blockscout the transaction is read from, so it has to name one.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) &&
SUPPORTED_CHAIN_IDS.has(value.chainId)
(!value.contractAddress || isAddressText(value.contractAddress))
);
}
@@ -205,19 +198,11 @@ function renderView(view, state, views) {
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
// `chainId` picks the network whose explorer the links open, so
// when it is there it has to name one. Likewise on error-tx.
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderError();
return true;
default:
@@ -232,10 +217,9 @@ function renderView(view, state, views) {
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
// it the same way (views/settings.js), so such a view can only be on the
// stack from this page load, where forward navigation rendered it on the
// way in.
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
// be on the stack from this page load, where forward navigation
// rendered it on the way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
+1 -19
View File
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -9,12 +9,6 @@ function show() {
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("add-token-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
const list = $("common-token-list");
list.innerHTML = getTopTokens(25)
.map(
@@ -52,9 +46,6 @@ function init(ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -62,15 +53,6 @@ function init(ctx) {
state.networkId,
ctx.pageClosed,
);
// A contract address names a token on one network only, so an
// answer after a switch is not added to the new network's list.
if (currentNetwork().chainId !== chainId) {
showFlash(
"The active network changed before the lookup finished," +
" so the token was not added.",
);
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
+29 -46
View File
@@ -11,7 +11,6 @@ const {
onViewLeave,
formatFee,
tokenLabel,
tokenSourcesByChainId,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
@@ -58,15 +57,9 @@ const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
// The link opens the explorer of the network with `chainId`, or of the active
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
function approvalAddressHtml(address, chainId) {
function approvalAddressHtml(address) {
const title = addressTitle(address, state.wallets);
if (chainId && !networkByChainId(chainId)) {
return renderAddressHtml(address, { title, noLink: true });
}
return renderAddressHtml(address, { title, chainId });
return renderAddressHtml(address, { title });
}
// The amount line for a decoded ERC-20 call. With a known scale it is the
@@ -86,18 +79,19 @@ function tokenAmountText(rawAmount, decimals, symbol) {
};
}
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Try to decode calldata sent to `toAddress` on the network with chain id
// `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId, fromAddress) {
function decodeCalldata(data, toAddress, chainId) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below:
// what the wallet knows of the tokens on the network with `chainId`, which
// need not be the active one. The ERC-20 amount line and the swap's Amount
// and Min. received lines resolve the scale the same way, and refuse to
// format the same way when it is nowhere.
const decimalsSources = tokenSourcesByChainId(chainId);
// Where a token's scale is looked for, for every decoder below: the ERC-20
// amount line and the swap's Amount and Min. received lines resolve it the
// same way, and refuse to format the same way when it is nowhere.
const decimalsSources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Try ERC-20 (approve / transfer)
try {
@@ -197,7 +191,6 @@ function decodeCalldata(data, toAddress, chainId, fromAddress) {
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
@@ -231,11 +224,10 @@ function showTxFee(approvedTx) {
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. In the native currency of the network shown
// above, and at the price on it, as the value is.
// above, as the value is.
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
approvedTx.chainId,
);
let detail =
@@ -268,7 +260,10 @@ function showTxApproval(details) {
const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0");
const sources = tokenSourcesByChainId(approvedTx.chainId);
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens
pendingTxDetails = {
@@ -282,12 +277,11 @@ function showTxApproval(details) {
// If this is an ERC-20 call, try to extract the real recipient and amount.
// Decoded for the network the transaction is for, which the Network line
// names, and the address the From line names.
// names.
const decoded = decodeCalldata(
approvedTx.data,
toAddr || "",
approvedTx.chainId,
details.approvedFrom,
);
if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token
@@ -345,35 +339,27 @@ function showTxApproval(details) {
};
}
// Every address link on this screen opens the explorer of the network the
// transaction is for, which the Network line names, whichever network is
// active.
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
approvedTx.chainId,
);
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known on the network the
// transaction is for
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
}
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
const ethValueFormatted = formatTxValue(ethValue);
// Priced from the exact value, not the four-decimal figure, at the price
// on the network the transaction is for, as the confirmation screen prices
// its amount, so the same value reads the same on both.
const ethPrice = getPrice("ETH", approvedTx.chainId);
const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null;
const ethValueFormatted = formatTxValue(
formatEther(approvedTx.value || "0"),
);
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can
@@ -400,12 +386,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
}
detailsHtml += approvalAddressHtml(
d.address,
approvedTx.chainId,
);
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+146 -206
View File
@@ -2,13 +2,7 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
$,
showError,
@@ -23,10 +17,12 @@ const {
goBack,
onViewLeave,
formatFee,
reviewedNetworkProvider,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -39,12 +35,11 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
toDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -68,13 +63,6 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
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,
// so a screen the popup reopens onto states them as they are then, not as
// Send knew them. 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() {
const d = state.viewData;
@@ -98,32 +86,8 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The amount as this screen and the wait, success and error screens show it,
// written as the approval screen writes a site's amount: the amount being
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
@@ -132,53 +96,23 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
// 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 = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
balances = null;
// The network this screen is drawn for, and the only one it sends on: the
// one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, the balance, and the node and
// explorer its checks ask. The wait, success and error screens label
// amounts by it.
// currency, USD values, explorer links, and the node and explorer its
// checks ask. The wait, success and error screens label amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
@@ -231,7 +165,33 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
renderBalance(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
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)
const localWarnings = getLocalWarnings(txInfo.to, {
@@ -297,29 +257,11 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" +
" try again.";
// So does the message for an amount the balance does not cover. It is
// set here, before the first paint, so the balance read returning only
// makes it visible and moves nothing, which is why it quotes no balance.
// Without the token's scale readBalances() reads no token balance.
$("confirm-balance-error").textContent =
isErc20 && toDecimals(txInfo.tokenDecimals) === null
? "This token's balance is unknown, because nothing this wallet" +
" can consult reports how many decimal places it uses, so the" +
" amount you are trying to send cannot be checked against it."
: "Your " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.";
renderValidation(txInfo);
// Reset password field and error, and any refusal of an earlier Sign &
// Send.
// Reset password field and error
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
@@ -340,78 +282,69 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible";
}
// The fee is estimated once the balances are read, because a max ETH
// send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo).then(() => estimateGas(txInfo));
estimateGas(txInfo);
checkRecipientHistory(txInfo);
}
// Read the From address's balances from the node of the network this
// transaction was reviewed 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) {
let read;
try {
const provider = reviewedNetworkProvider(txInfo);
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
// show(), again when the balances are read, and again when the fee estimate
// resolves or fails. Every element it touches already occupies its space, so
// re-running it never moves anything.
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
// 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 the only message shown for it is
// the one saying the read failed.
const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null;
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrencyByChainId(txInfo.chainId);
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
ethBalance,
tokenBalance,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
feeWei,
});
// An invalid amount is known before the first paint, so its message is
// written here. Every message a later result brings has a reserved
// element, its sentence fixed in index.html or set by show(), and is only
// made visible below, so nothing moves when the result arrives.
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
txInfo.tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
const errorsEl = $("confirm-errors");
if (messages.length > 0) {
@@ -424,20 +357,13 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden";
}
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
setVisible(
"confirm-balance-error",
balanceRead &&
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
codes.includes(CODES.INSUFFICIENT_ETH)),
);
setVisible(
"confirm-amount-fee-error",
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
@@ -456,6 +382,20 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -532,14 +472,14 @@ async function estimateGas(txInfo) {
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
// A max ETH send is the balance readBalances() read minus this
// estimate's reserve, not the Send screen's, and is signed with this
// estimate's fee fields: fees fetched again at signing could exceed
// the reserve it leaves, and the node would refuse it for want of
// funds. Where the balance is unknown or no longer covers the fee, the
// amount is left as it is and the balance check below says so.
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(balances?.eth, gasCostWei);
const amount = maxEthAmount(txInfo.balance, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
@@ -568,17 +508,16 @@ async function estimateGas(txInfo) {
}
}
// Populate the transaction this send describes, enforce the bounds against the
// gas limit and fees that were actually filled in, then sign and broadcast it.
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// from what the configured RPC node answers, with nothing otherwise bounding
// what a hostile node can set — the dApp path's ceilings never reached this
// one. Populating before the check is what makes assertWithinCeilings() see
// the same numbers that would be signed; it throws an ApprovalMismatchError
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// over its bound, and the caller then shows the one refusal reserved for all
// three rather than sending.
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -606,6 +545,15 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -613,9 +561,6 @@ async function checkRecipientHistory(txInfo) {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
});
// The user may have left this transaction while the checks were in
// flight; its warnings must not reach another recipient's screen.
if (pendingTx !== txInfo) return;
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
@@ -644,11 +589,6 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
// A press shows only its own refusal, if any.
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
@@ -659,11 +599,15 @@ function init(_ctx) {
}
// The screen describes the network the transaction was reviewed for,
// which Settings may have switched away from since: "Back" from
// Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does.
// which Settings may have switched away from since: with this screen
// open, where Back only unhides it, or before the popup reopened onto
// it.
if (currentNetwork().chainId !== pendingTx.chainId) {
setVisible("confirm-network-changed-error", true);
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
@@ -672,7 +616,11 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
setVisible("confirm-from-address-error", true);
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return;
}
@@ -704,10 +652,6 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -724,23 +668,19 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(shownTx, tx.hash);
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// A gas limit or fee over its bound is refused before anything is
// broadcast, so there is no transaction that may have reached the
// network to warn about: the refusal stays on the confirmation
// screen where the user can go back, rather than routing to the
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
if (e && e.approvalMismatch) {
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
showSendError(e.message);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
+11 -58
View File
@@ -1,7 +1,4 @@
// Helpers the views in this directory share: showing a view and going back,
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
// Shared DOM helpers used by all views.
//
// Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -22,7 +19,6 @@ const {
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -132,8 +128,7 @@ function showView(name) {
}
// Create or update the debug/insecure warning banner.
// Called on every view switch, after the settings debug toggle changes, and
// after a network switch in Settings.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
@@ -300,30 +295,15 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork();
if (net && net.id === state.networkId) {
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources the amount line's scale
// comes from. Null when no source names one, so the token lines keep saying
// `Unknown token` for a token nothing knows.
function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// A network fee in wei as the confirmation and approval screens both show it:
@@ -626,30 +606,6 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id,
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
// use the network their transaction's chain id names, and the popup can reopen
// onto them after Settings switched the network. The active network's are
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
// or its defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) {
const net = networkByChainId(chainId);
if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
}
const remembered = state.networkEndpoints[net.id] || {};
return {
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
};
}
// The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
@@ -790,7 +746,6 @@ module.exports = {
keyMaterialName,
unknownableAmount,
nativeCurrency,
tokenSourcesByChainId,
tokenLabel,
formatFee,
addressDotHtml,
@@ -806,8 +761,6 @@ module.exports = {
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
endpointsByChainId,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
+16 -9
View File
@@ -164,9 +164,8 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance the Send screen states, and the scale it hands the
// confirmation screen, which reads the balance itself from the network it is
// drawn for.
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
//
// 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
@@ -189,11 +188,13 @@ function renderSendTokenSelect(addr) {
//
// 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
// stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// null rather than "0": this screen states it as unknown, as the confirmation
// screen does, and Max fills nothing in for it. 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.
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// 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) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -377,6 +378,7 @@ function init(_ctx) {
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
@@ -384,7 +386,10 @@ function init(_ctx) {
addr.tokenBalances,
state.trackedTokens,
);
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
// The confirmation screen's own amount check, so it is never shown
@@ -419,7 +424,9 @@ function init(_ctx) {
ensName: ensName,
amount: amount,
token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
+5 -63
View File
@@ -16,8 +16,6 @@ const {
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState");
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const {
log,
debugFetch,
@@ -38,8 +36,6 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
@@ -245,51 +241,7 @@ async function renderSiteLists() {
);
}
// A network switch, made by the user here or saved by the background once the
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. These two leave the page as README's
// Screen Map describes, the first before the switch is saved, so that save
// keeps the cut for a popup closed straight after, the second once `state`
// holds the new network.
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Every screen counts as not yet rendered, so "Back" renders the one it lands
// on for the new network. Settings on top is brought up to date in place;
// any other screen is drawn again as a reopened popup draws it, which is Home
// for one that cannot be rendered from saved state. Without a wallet any other
// screen is Welcome or AddWallet, which show nothing from a network.
function showNewNetwork() {
resetRenderedViews();
if (state.currentView === "settings") {
markViewRendered("settings");
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
function init(_ctx) {
ctx = _ctx;
function init(ctx) {
deleteWallet.init(ctx);
showPhrase.init();
@@ -365,18 +317,14 @@ function init(_ctx) {
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value;
cutViewStack();
if (newId === state.networkId) return;
const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
showNewNetwork();
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + ".");
});
@@ -511,10 +459,4 @@ function init(_ctx) {
});
}
module.exports = {
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};
module.exports = { init, show, renderSiteLists };
+1 -19
View File
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -75,12 +75,6 @@ function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("settings-addtoken-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
renderTop10();
renderDropdown();
showView("settings-addtoken");
@@ -136,9 +130,6 @@ function init(_ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
addr,
@@ -146,15 +137,6 @@ function init(_ctx) {
state.networkId,
ctx.pageClosed,
);
// A contract address names a token on one network only, so an
// answer after a switch is not added to the new network's list.
if (currentNetwork().chainId !== chainId) {
showFlash(
"The active network changed before the lookup finished," +
" so the token was not added.",
);
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
+2 -10
View File
@@ -24,11 +24,7 @@
// than written again locally so that tests/popupElementIds.test.js sees these
// lookups and holds every id below against the markup.
const { $, VIEWS } = require("./helpers");
const {
storageGet,
storageRemove,
withLock,
} = require("../../shared/browserApi");
const { storageGet, storageRemove } = require("../../shared/browserApi");
const { log } = require("../../shared/log");
// Typed in full before anything is erased, in the same spirit as the wallet
@@ -141,11 +137,7 @@ async function resetProfile() {
return;
}
try {
// Under the lock every write of the stored record takes, so the erase
// cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask"));
await storageRemove("autistmask");
} catch (e) {
log.errorf("state recovery: reset failed:", e);
setFlash("The saved data could not be erased. Nothing was changed.");
+18 -33
View File
@@ -18,11 +18,10 @@ const {
copyableHtml,
etherscanLinkHtml,
explorerUrl,
endpointsByChainId,
displaySymbol,
goBack,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log");
@@ -49,16 +48,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function txAddressHtml(address, ensName, title, chainId) {
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName, chainId })
renderAddressHtml(address, { title, ensName })
);
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -84,11 +83,10 @@ function show(tx) {
method: tx.method || null,
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's,
// the links open its explorer, and the block, network details and
// calldata are read from its Blockscout: the active network can
// change, in Settings or when the user approves a site's request,
// before a later popup shows this screen again.
// the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
chainId: tx.chainId,
},
};
@@ -98,24 +96,18 @@ function show(tx) {
function render() {
const tx = state.viewData.tx;
if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
tx.chainId,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
@@ -154,7 +146,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
const link = explorerUrl("token", tx.contractAddress);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
@@ -212,11 +204,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl(
"block",
String(txData.block_number),
chainId,
);
const blockLink = explorerUrl("block", String(txData.block_number));
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -313,9 +301,8 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return;
try {
const { blockscoutUrl } = endpointsByChainId(chainId);
const resp = await debugFetch(
blockscoutUrl + "/transactions/" + txHash,
state.blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
const txData = await resp.json();
@@ -329,13 +316,11 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
// On the network the transaction was sent on, whose Blockscout was
// just asked, and from the sender it reports.
// On the active network, the one whose explorer was just asked.
const decoded = decodeCalldata(
inputData,
txData.to?.hash || "",
chainId,
txData.from?.hash || "",
currentNetwork().chainId,
);
if (decoded) {
// Render decoded calldata matching approval view style
@@ -353,9 +338,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address, { chainId });
detailsHtml += renderAddressHtml(d.address);
} else if (d.address) {
detailsHtml += renderAddressHtml(d.address, { chainId });
detailsHtml += renderAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+29 -39
View File
@@ -14,13 +14,10 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const {
SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as
@@ -64,22 +61,21 @@ function endWait() {
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`). The links below open the explorer of the network
// with `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title, chainId });
return renderAddressHtml(address, { title });
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function blockNumberHtml(blockNumber, chainId) {
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = explorerUrl("block", num, chainId);
const link = explorerUrl("block", num);
return copyableHtml(num) + etherscanLinkHtml(link);
}
@@ -95,11 +91,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. The links on all three
// screens open that network's explorer, and this screen's receipt is
// asked of that network's node, for the same reason. A null token is a
// swap whose amount was not decoded (see showTxApproval() in
// approval.js): the amount says so, and no symbol follows it.
// is open or before a later popup resumes it. A null token is a swap whose
// amount was not decoded (see showTxApproval() in approval.js): the amount
// says so, and no symbol follows it.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -108,8 +102,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait
@@ -134,7 +128,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = reviewedNetworkProvider(txInfo);
const provider = getProvider(state.rpcUrl, state.networkId);
let consecutiveFailures = 0;
async function poll() {
@@ -213,11 +207,10 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -233,7 +226,6 @@ function restoreWait() {
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
@@ -258,13 +250,12 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function decodedDetailsHtml(decoded, chainId) {
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
if (decoded.name) {
@@ -280,11 +271,11 @@ function decodedDetailsHtml(decoded, chainId) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token";
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address, chainId);
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address, chainId);
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
@@ -313,16 +304,16 @@ function renderSuccess() {
toSection.classList.remove("hidden");
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -348,7 +339,6 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
chainId: txInfo.chainId,
};
renderError();
}
@@ -358,11 +348,11 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-to").innerHTML = toAddressHtml(d.to);
$("error-tx-message").textContent = d.message;
if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
$("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx");
} else {
+19 -131
View File
@@ -1,5 +1,4 @@
// Balance fetching: ETH balances and the balances of the tokens the user
// tracks via RPC, the other ERC-20 token balances and the holder counts via
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
const {
@@ -82,9 +81,7 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }].
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
// explicitly tracked by the user, or have >= 1000 holders.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
@@ -208,96 +205,8 @@ async function fetchTokenBalances(
}
}
// The entries at `address` of the tokens the user tracks, shaped as
// fetchTokenBalances() returns them, for the tokens the address holds.
//
// The balance is read with balanceOf through the RPC endpoint. The name and
// symbol are the tracked token's. `decimals` and `holders` are those of
// Blockscout's entry for the token in `explorerRequest`, this refresh's
// fetchTokenBalances() for the address; while Blockscout fails they are kept
// from the token's entry in `previous`, the address's entries before this
// refresh. The balance is stated at the scale the screens read the token at
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
// records, then Blockscout's, as in fetchTokenBalances().
//
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
// balance keeps its figure, or takes Blockscout's entry from this refresh when
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
//
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
listed.map(async (token) => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
);
return await contract.balanceOf(address);
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"token balance failed",
address,
token.address,
e.shortMessage || e.message,
);
}
return null;
}
}),
);
const explorerRows = await explorerRequest;
const reported = explorerRows ?? previous;
const rows = [];
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
}
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
name: token.name || "",
symbol: token.symbol || "???",
decimals: decimals,
// null when nothing knows the scale: a holding whose amount
// cannot be stated, never a zero.
balance: scale === null ? null : formatTokenBalance(raw, scale),
holders: entry ? entry.holders : null,
});
});
return rows;
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances().
// `signal` is as for fetchTokenBalances().
async function refreshBalances(
wallets,
rpcUrl,
@@ -308,8 +217,6 @@ async function refreshBalances(
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = [];
for (const wallet of wallets) {
@@ -359,42 +266,23 @@ async function refreshBalances(
}),
);
// ERC-20 token balances: the tracked tokens' through the RPC
// endpoint, and from Blockscout the tokens the user has not
// tracked. When Blockscout fails, the untracked rows from before
// this refresh stay. The Blockscout request is started first and
// handed to fetchTrackedTokenBalances() too, which waits for it
// after sending its balanceOf reads, so the two run side by side.
const previous = addr.tokenBalances || [];
const explorerRequest = fetchTokenBalances(
addr.address,
blockscoutUrl,
tracked,
signal,
);
// ERC-20 token balances via Blockscout
updates.push(
Promise.all([
fetchTrackedTokenBalances(
provider,
addr.address,
tracked,
previous,
explorerRequest,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
addr.tokenBalances.length,
"tokens",
);
fetchTokenBalances(
addr.address,
blockscoutUrl,
trackedTokens,
signal,
).then((balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
}
}),
);
}
-47
View File
@@ -31,11 +31,6 @@
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
//
// The module also holds withLock(), a wrapper around the browser's
// `navigator.locks`, which is in neither namespace. It sits beside
// storageGet() and storageSet() because every write of the stored record
// takes it.
// The extension API namespace, preferring `browser.*` where it exists.
//
@@ -224,46 +219,6 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys));
}
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
* and its background share these locks, except a page in a Firefox private
* window (README, "Stored state and its version"), so while one of them holds
* `name` another asking for it waits, in the order they asked. The lock is let
* go when `fn` settles, whether it resolved or rejected.
*
* Every write of the stored record holds the lock named after its key,
* "autistmask", from its read to its write: saveState() in
* src/shared/state.js, updateState() in src/background/state.js, and the
* reset in src/popup/views/stateRecovery.js.
*
* @param {string} name
* @param {function(): Promise<*>} fn
* @returns {Promise<*>} what `fn` resolved with.
* @throws rejects, without running `fn`, where `navigator.locks` is absent.
*/
function withLock(name, fn) {
if (typeof navigator === "undefined" || !navigator.locks) {
return Promise.reject(
new Error("navigator.locks is not available in this context"),
);
}
return navigator.locks.request(name, () => fn());
}
// `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
@@ -398,7 +353,6 @@ module.exports = {
getPlatformInfo,
hasBrowserNamespace,
notify,
onStorageChanged,
runtimeApi,
sendMessage,
sessionStorageGet,
@@ -419,5 +373,4 @@ module.exports = {
windowsGetLastFocused,
windowsRemove,
windowsUpdate,
withLock,
};
+17 -37
View File
@@ -1,18 +1,8 @@
// The popup's network switch.
// Consolidated chain-switch handler for the popup.
//
// A network switch reaches the popup's `state` in one of two ways:
//
// - onChainSwitch() below makes the switch the user picks in Settings, with
// applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
// Every state change required when the active network changes is
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
@@ -30,32 +20,22 @@
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain, or null when
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
// Returns the network configuration object for the new chain.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
let net = null;
await saveState(() => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId);
clearPopupCaches();
});
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
return net;
}
module.exports = { onChainSwitch, clearPopupCaches };
module.exports = { onChainSwitch };
+2 -41
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the objects it is
// Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
@@ -38,16 +38,6 @@ function applyChainSwitchFields(s, newNetworkId) {
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
// Tracked tokens are kept per network the same way, because a contract
// address names a token on one network only. s.trackedTokens stays the
// active network's list, so nothing that reads it changes: the outgoing
// list is put away under its network, and the incoming network's list, or
// an empty one, takes its place.
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
@@ -76,33 +66,4 @@ function applyChainSwitchFields(s, newNetworkId) {
return net;
}
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
module.exports = { applyChainSwitchFields };
-19
View File
@@ -23,11 +23,6 @@ const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
// trackedTokens above is the active network's list; this is what the
// others are restored from when the active network changes. See
// applyChainSwitchFields().
networkTrackedTokens: {},
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -294,20 +289,6 @@ function normalizePersisted(saved) {
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// Each remembered list is floored like trackedTokens, because
// applyChainSwitchFields() puts it straight back onto trackedTokens when
// its network becomes the active one. A key that is not a network in
// networks.js is dropped, which also keeps an own "__proto__" key from
// reaching the plain assignment below.
out.networkTrackedTokens = {};
if (isRecord(saved.networkTrackedTokens)) {
for (const netId of Object.keys(saved.networkTrackedTokens)) {
if (!isKnownNetworkId(netId)) continue;
out.networkTrackedTokens[netId] = structuredClone(
tokenRefs(saved.networkTrackedTokens[netId]),
);
}
}
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
+2 -3
View File
@@ -38,12 +38,11 @@ function clearPrices() {
}
// Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet, on a chain id
// that names no network, or for a symbol with no price.
// active network when no chain id is given; null on testnet / unknown.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state");
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (!network || network.isTestnet) return null;
if (network.isTestnet) return null;
return prices[symbol] || null;
}
+45 -68
View File
@@ -23,7 +23,6 @@
// guarantee.
const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
@@ -36,18 +35,16 @@ const {
migrationNeeded,
} = require("./stateSchema");
const { storageGet, storageSet, withLock } = require("./browserApi");
const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
// The live record the proxy below guards. Everything inside this module reads
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
...DEFAULT_STATE,
// Their own objects, not the ones DEFAULT_STATE holds:
// applyChainSwitchFields() mutates these maps in place, and a spread copies
// the reference.
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
networkTrackedTokens: {},
currentView: null,
selectedWallet: null,
selectedAddress: null,
@@ -126,10 +123,8 @@ function currentNetwork() {
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// finished, or when its last successful saveState() began. saveState() diffs
// the live state against this to find only the fields THIS page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -443,14 +438,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
// mutation for a whole-field diff to collide with; `viewData` is this page's
// own UI scratch space, not data another page has any reason to share
// membership of.
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
@@ -461,18 +454,20 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it,
// which is why saves from one page wait for each other (see saveState()), and
// a save that is slow to come back has no way to tell whether the field it
// which is what makes the queue above necessary in the first place, and a
// save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. The
// network is the exception: the popup takes a switch another context saved
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js).
async function saveStateOnce(beforeWrite) {
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this,
@@ -486,12 +481,6 @@ async function saveStateOnce(beforeWrite) {
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) {
@@ -536,21 +525,35 @@ async function saveStateOnce(beforeWrite) {
rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change
// made during the save must still differ from the baseline, or the next
// save finds nothing to store
// made during the save must still differ from the baseline, or the save
// queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current;
}
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js).
//
// A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and `await saveState()` inside an
// unguarded event handler is no better: either way, without this, a failure
// would reach only the console, where the user never looks, and the wallet
// would keep running against storage that rejects every write. That is the
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362.
// every navigation without awaiting it, and the queue below has to attach a
// rejection handler to keep advancing — so a failing save was swallowed
// entirely: no throw, no message, nothing on screen. The wallet kept running
// against storage that was rejecting every write, which is the data-loss half
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
//
// This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in
@@ -576,25 +579,11 @@ function reportSaveFailure(err) {
}
}
// Each save runs start to finish holding the lock every write of the stored
// record takes (withLock() in src/shared/browserApi.js), so no write by
// another page or the background lands between its read and its write and is
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox
// private window is the exception: Firefox gives it locks of its own (README,
// "Stored state and its version").
//
// The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be
// asked for at once; they take the lock in the order they were asked, and each
// compares the page's state, as it stands once the stored record has been read
// and `beforeWrite` has run, against the baseline the save before it left.
//
// `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is
// written; any other return lets the save go ahead.
function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
function saveState() {
const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles.
saveQueue = turn.catch(() => {});
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
@@ -602,17 +591,6 @@ function saveState(beforeWrite) {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -658,7 +636,6 @@ module.exports = {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
currentAddress,
currentNetwork,
+3 -10
View File
@@ -20,7 +20,6 @@ const { toDecimals } = require("./transferAmount");
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
@@ -52,6 +51,7 @@ function parseTx(tx, addrLower, chainId) {
}
// Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
@@ -59,16 +59,9 @@ function parseTx(tx, addrLower, chainId) {
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
// Multisigs, smart accounts and many other contracts also have an
// `execute` or `multicall` method, so either is a swap only when sent
// to a Universal Router on this transaction's network: the same
// addresses the approval screen decodes a swap for.
const isSwap =
SWAP_METHODS.has(method) ||
((method === "execute" || method === "multicall") &&
routerRelease(to, chainId) !== null);
const label = isSwap
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract";
+5 -5
View File
@@ -2,11 +2,11 @@
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the symbol and
// the balance it reads from the node with the block explorer's cached metadata
// (see fetchTokenBalances() in balances.js); the transfer used to be encoded
// from decimals() read off the contract at signing time, and nothing compared
// the two. A token whose on-chain scale differs from the cached one — an
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
+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
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is an exact decimal string as
// formatEther() writes it, never a rounded one. Null when the balance does not
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
+178 -1667
View File
File diff suppressed because it is too large Load Diff
+4 -17
View File
@@ -291,6 +291,7 @@ async function confirmSend(from, selected) {
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
@@ -442,25 +443,11 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-from-address-error").style.visibility).toBe(
"visible",
);
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
});
});
-3
View File
@@ -68,13 +68,10 @@ function swapData(amountIn, amountOutMin) {
}
function swapDetail(amountIn, amountOutMin, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
swapData(amountIn, amountOutMin),
ROUTER,
"0x1",
{},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
-270
View File
@@ -1,270 +0,0 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active.
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
// It now uses what the wallet knows of the tokens on the network the
// transaction is for.
//
// Its address links opened the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
// explorer of the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
// the signed transaction with a failure, which leads to the error screen.
async function showTokenTransfer(
tokenBalances = [],
chainId = NETWORKS.sepolia.chainId,
) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showTokenTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showTokenTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
const sepolia = NETWORKS.sepolia.explorerUrl;
expect(node("approve-tx-from").innerHTML).toContain(
`href="${sepolia}/address/${FROM}"`,
);
expect(node("approve-tx-to").innerHTML).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Token")).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Recipient")).toContain(
`href="${sepolia}/address/${RECIPIENT}"`,
);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
}
});
// Chain id 1337 names no network here, so it has no explorer to link to.
test("a transaction on a chain id that names no network is shown with no address links", async () => {
await showTokenTransfer([], "0x539");
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-network").textContent).toBe(
"Unknown network (chain id 1337)",
);
expect(node("approve-tx-from").innerHTML).toContain(FROM);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain("href=");
}
});
-192
View File
@@ -1,192 +0,0 @@
// The USD value on the approval screen's Value and Network fee lines, for a
// site's ETH send.
//
// It was worked out from the four-decimal figure the line shows, at the price
// on the active network, so the same value read a few cents different here than
// on the confirmation screen, which prices the exact amount at the price on the
// transaction's own network, and a transaction on another network than the
// active one was priced at the active one's rate, its fee too
// (https://git.eeqj.de/sneak/AutistMask/issues/614).
//
// Driven through the real approval screen, against a minimal DOM stub in the
// shape tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { prices, clearPrices } = require("../src/shared/prices");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the approval screen, with the network `networkId` active, for the
// site's transaction sending 1.23414 ETH to RECIPIENT on the chain `chainId`.
async function showEthSend(chainId, networkId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "1234140000000000000",
data: "0x",
accessList: [],
},
});
},
};
state.networkId = networkId;
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, index: 0, balance: "0" }],
},
];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.currentView = null;
approval.init({});
await approval.show(1);
}
beforeEach(() => {
prices.ETH = 2000;
});
afterEach(() => {
clearPrices();
});
// 1.23414 ETH reads 1.2341 ETH. At $2,000 the exact value is $2,468.28, the
// figure the confirmation screen shows for it; the shown figure would be
// $2,468.20.
test.each([
[
"a mainnet transaction is priced from its exact value",
NETWORKS.mainnet.chainId,
"mainnet",
"1.2341 ETH ($2,468.28)",
],
[
"a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"1.2341 ETH ($2,468.28)",
],
[
"a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"1.2341 SepoliaETH",
],
// Chain id 1337 names no network here, so nothing knows its price.
[
"a transaction on a chain id that names no network has no USD value",
"0x539",
"mainnet",
"1.2341 ETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-value").textContent).toBe(expected);
});
// The Network fee line is priced on the transaction's network too. 21000 gas at
// up to 2 gwei is 0.000042 ETH, $0.08 at $2,000.
test.each([
[
"the fee of a mainnet transaction shown with Sepolia active is priced at the mainnet price",
NETWORKS.mainnet.chainId,
"sepolia",
"0.00004 ETH ($0.08)",
],
[
"the fee of a Sepolia transaction shown with mainnet active has no USD value",
NETWORKS.sepolia.chainId,
"mainnet",
"0.00004 SepoliaETH",
],
])("%s", async (_label, chainId, networkId, expected) => {
await showEthSend(chainId, networkId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-fee").textContent).toBe(expected);
});
+1 -8
View File
@@ -154,14 +154,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: {
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
});
goBack();
expect(calls).toEqual(["transactionDetail"]);
+1 -49
View File
@@ -19,10 +19,7 @@
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// A network switch landing inside a balance refresh's round trip is covered at
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
@@ -398,48 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
+4 -10
View File
@@ -18,11 +18,6 @@ const {
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TRACKED = {
address: "0x7777777777777777777777777777777777777777",
symbol: "TRK",
decimals: 6,
};
const realFetch = globalThis.fetch;
let logged;
@@ -35,9 +30,9 @@ beforeEach(() => {
const failedToFetch = async () => {
throw new TypeError("Failed to fetch");
};
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and
// the scan's balance and transaction count) and the explorer request (the
// other token balances) all fail the same way.
// The RPC calls (ETH balance, ENS name, and the scan's balance and
// transaction count) and the explorer request (token balances) all fail
// the same way.
FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch);
});
@@ -54,7 +49,7 @@ function refresh(signal) {
wallets,
RPC_URL,
EXPLORER_URL,
[TRACKED],
[],
"mainnet",
signal,
);
@@ -65,7 +60,6 @@ test("a failure while the popup is open is reported", async () => {
for (const label of [
"ETH balance failed",
"ENS reverse failed",
"token balance failed",
"fetchTokenBalances failed: Failed to fetch",
]) {
expect(logged.some((line) => line.includes(label))).toBe(true);
-161
View File
@@ -1,161 +0,0 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
+1 -4
View File
@@ -446,10 +446,7 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
const { state, loadState } = require("../src/shared/state");
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
await require("../src/shared/state").loadState();
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
+4 -11
View File
@@ -204,21 +204,17 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.networkTrackedTokens).toEqual({
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
});
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.theme).toBe("dark");
// The mainnet token is tracked on mainnet only: Sepolia starts with
// none of its own.
expect(after.trackedTokens).toEqual([]);
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
@@ -230,9 +226,6 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
expect(bg.persisted().trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
});
test("compares the requested chain against the stored one, not the default", async () => {
-415
View File
@@ -1,415 +0,0 @@
// The amount of the wallet's own send on the confirmation, wait, success and
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
//
// These screens printed the text from Send's amount field as it was typed or
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
// decimal places with the nonzero floor, as the approval screen does, and the
// transaction is still signed for the exact amount.
//
// Driven through the real Send screen, confirmation screen, Sign & Send and
// wait screen, with only the node, the explorer and the DOM stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed. The
// token's own decimals() answers 6, as the explorer does.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
tokenDecimals: 6n,
broadcast: [],
broadcastError: null,
receipt: null,
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of decimals(); every other contract call is balanceOf().
const DECIMALS = "0x313ce567";
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async call(tx) {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[
tx.data.startsWith(DECIMALS)
? mockNode.tokenDecimals
: mockNode.tokenUnits,
],
);
}
async lookupAddress() {
return null;
}
// 20 gwei reserved per gas.
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
if (mockNode.broadcastError) throw mockNode.broadcastError;
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
async getTransactionReceipt() {
return mockNode.receipt;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// The success screen hides whole sections through .parentElement.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
const {
Interface,
Transaction,
Wallet,
getAddress,
parseEther,
} = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const txStatus = require("../src/popup/views/txStatus");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
// 1.234567890123456789 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed,
// at 6 decimal places.
const TOKEN = "0x" + "d".repeat(40);
const transferIface = new Interface([
"function transfer(address to, uint256 amount)",
]);
let encryptedSecret;
// The confirmation screen Review leads to, once shown.
let confirmed = null;
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// show() reads the balance and then estimates the fee without awaiting
// either; this lets both settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
// base units of TOKEN, fetched and stored where the popup stores them. Send
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
async function openSend(token, tokenUnits = null) {
mockNode.balanceWei = BALANCE_WEI;
mockNode.tokenUnits = tokenUnits ?? 0n;
mockExplorer.items =
tokenUnits === null
? []
: [
{
value: String(tokenUnits),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals: "6",
holders_count: "50000",
},
},
];
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
send.init({
showConfirmTx: (info) => (confirmed = info),
pageClosed: new AbortController().signal,
});
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
// The one transaction the stub node was handed.
function broadcastTx() {
expect(mockNode.broadcast).toHaveLength(1);
return Transaction.from(mockNode.broadcast[0]);
}
beforeAll(async () => {
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
clearPrices();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.broadcast = [];
mockNode.broadcastError = null;
mockNode.receipt = null;
txStatus.init({
doRefreshAndRender() {},
pageClosed: new AbortController().signal,
});
});
afterEach(() => {
txStatus.endWait();
});
describe("the wallet's own send shows its amount to four decimal places", () => {
test("for a short amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
// Send accepts both. The approval screen writes the same value `0.5000`.
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
await openSend("ETH");
el("send-amount").value = typed;
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
test("for a token amount typed as .5", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = ".5";
await review();
expect(text("confirm-amount")).toBe("0.5000 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
const [, amount] = transferIface.decodeFunctionData(
"transfer",
broadcastTx().data,
);
expect(amount).toBe(500000n);
});
test("for a Max amount with many decimals", async () => {
await openSend("ETH");
await el("btn-send-max").handlers.get("click")();
// The balance minus 21000 gas at 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
await review();
expect(text("confirm-amount")).toBe("1.2341 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
});
// Cut to four decimals it would read 0.0000, so it is shown to its first
// significant digit instead, and signed in full.
test("for a tiny nonzero amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.000012345";
await review();
expect(text("confirm-amount")).toBe("0.00001 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
});
test("for a token amount", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = "12.345678";
await review();
expect(text("confirm-amount")).toBe("12.3456 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
const tx = broadcastTx();
expect(tx.to).toBe(getAddress(TOKEN));
const [to, amount] = transferIface.decodeFunctionData(
"transfer",
tx.data,
);
expect(to).toBe(RECIPIENT);
expect(amount).toBe(12345678n);
});
test("on the success screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
await signAndSend();
// A popup opened later resumes the wait, and the receipt is there.
mockNode.receipt = { blockNumber: 21000000 };
txStatus.restoreWait();
await settle();
expect(state.currentView).toBe("success-tx");
expect(text("success-tx-summary")).toBe("0.5000 ETH");
});
test("on the error screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
mockNode.broadcastError = new Error("the node refused it");
await signAndSend();
expect(state.currentView).toBe("error-tx");
expect(text("error-tx-summary")).toBe("0.5000 ETH");
});
});
+6 -167
View File
@@ -11,115 +11,20 @@
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened.
//
// The last test drives the real confirmation screen instead, with only the
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
// is refused in the same box as a fee over the bound
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
// nothing on the screen is checked by the Chrome e2e suite.
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — reading the fee error into the reserved errors box — is
// covered by the Chrome e2e suite.
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -193,69 +98,3 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
-221
View File
@@ -1,221 +0,0 @@
// A late answer for a confirmation screen the user has left changes nothing on
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
//
// The confirmation screen asks the node and the explorer about its transaction
// without waiting for the answers: the recipient checks, which can show three
// warning boxes, the balance read and the fee estimate. Back, a new recipient
// and Review again show the screen for another transaction while those
// questions may still be open. The recipient checks showed their boxes
// whenever they answered, so a box about the first recipient could appear on
// the second recipient's screen.
//
// Driven through the real confirmation screen, with only the node, the
// explorer and the DOM stubbed. Each test holds back the first screen's answer
// to one question, and gives it once the screen it tests is shown.
"use strict";
// The From address of every transaction here. The jest.mock() factory below
// may only name outside variables whose names start with "mock".
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// Every answer is a mock, so a test can hold one back. Every address has
// sent one transaction and has no code, and only the From address holds
// anything: 5 ETH.
class StubProvider {}
Object.assign(StubProvider.prototype, {
getBalance: jest.fn(async (address) =>
address === mockFrom ? actual.parseEther("5") : 0n,
),
getCode: jest.fn(async () => "0x"),
getTransactionCount: jest.fn(async () => 1),
// 10 gwei expected, 20 gwei reserved per gas.
getFeeData: jest.fn(async () => ({
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
})),
estimateGas: jest.fn(async () => 21000n),
});
return { ...actual, JsonRpcProvider: StubProvider };
});
// An explorer address page whose title is `title`.
function explorerPage(title) {
return { ok: true, text: async () => "<title>" + title + "</title>" };
}
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
addEventListener() {},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { JsonRpcProvider, parseEther } = require("ethers");
const { loadState } = require("../src/shared/state");
const confirmTx = require("../src/popup/views/confirmTx");
const node = JsonRpcProvider.prototype;
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
// Send screen does.
function review(to) {
confirmTx.show({
from: mockFrom,
to,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
}
// show() does not wait for its questions; this lets every answer not held
// back arrive.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Hold back the next answer `question` gives. Returns the function that gives
// it.
function holdNextAnswer(question) {
let give;
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
return (answer) => give(answer);
}
beforeAll(async () => {
await loadState();
});
beforeEach(() => {
elements.clear();
});
// The three boxes the recipient checks show, each with the question whose
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
// answer that it has sent no transaction is what shows the box saying so.
describe.each([
{
box: "confirm-contract-warning",
question: node.getCode,
answer: "0x6080604052",
},
{
box: "confirm-recipient-warning",
question: node.getTransactionCount,
answer: 0,
},
{
box: "confirm-etherscan-warning",
question: global.fetch,
answer: explorerPage(
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
),
},
])("the $box box", ({ box, question, answer }) => {
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(true);
});
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(false);
});
});
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
const give = holdNextAnswer(node.getBalance);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
give(parseEther("0.5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("btn-confirm-send").disabled).toBe(false);
});
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
const give = holdNextAnswer(node.getFeeData);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
// 100 times the fee the second screen was quoted.
give({
maxFeePerGas: 2000000000000n,
maxPriorityFeePerGas: 100000000000n,
gasPrice: 1000000000000n,
});
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
+34 -273
View File
@@ -15,37 +15,20 @@
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
//
// Its balance line and balance check are that network's balance, read from
// that network's node each time the screen is drawn
// (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send
// handed over. The fee estimate after the read stays on that network when
// the network is switched while the read runs.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node, fetch 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,
// and a balance read answers with that network's balance.
// its provider was built for, so a broadcast records the network it went to.
"use strict";
// The questions the stub node was asked for the balance, the fee and the
// recipient checks, by method, network name and RPC URL, in order. The
// balances it reports on each network, by network name: ETH in wei and the
// token in its base units. The signed transactions it was handed, with its
// network's chain id and RPC URL. And whether it quotes fees as a network
// with no type-2 pricing does.
const mockNode = {
asked: [],
balanceWei: {},
tokenUnits: {},
broadcast: [],
legacyFees: false,
};
// The questions the stub node was asked for the fee and the recipient checks,
// by method, network name and RPC URL, in order. The signed transactions it
// was handed, with its network's chain id and RPC URL. And whether it quotes
// fees as a network with no type-2 pricing does.
const mockNode = { asked: [], broadcast: [], legacyFees: false };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider {
constructor(url, network) {
this.url = url;
@@ -61,22 +44,6 @@ jest.mock("ethers", () => {
async getNetwork() {
return this.network;
}
async getBalance() {
this.record("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()");
}
this.record("balanceOf");
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
@@ -189,15 +156,9 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -237,11 +198,6 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.asked = [];
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
@@ -256,7 +212,7 @@ afterEach(() => {
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for the transfer `changes` makes of it, as Review on the Send screen does,
// with its balance read and its fee estimate settled.
// with its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
@@ -269,7 +225,7 @@ async function reviewOnActiveNetwork(changes = {}) {
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER }],
addresses: [{ address: HOLDER, balance: "5.0" }],
},
];
state.selectedWallet = 0;
@@ -281,7 +237,9 @@ async function reviewOnActiveNetwork(changes = {}) {
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
...changes,
@@ -333,7 +291,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
"1 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -365,24 +323,12 @@ function expectNativeTransferOn(networkId) {
);
}
// show() reads the balance and then estimates the fee, and checks the
// recipient, without awaiting any of them; this lets all of them finish.
// show() estimates the fee and checks the recipient without awaiting either;
// this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function errors() {
return el("confirm-errors").innerHTML;
}
function shown(id) {
return el(id).style.visibility === "visible";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
@@ -413,10 +359,9 @@ test("a transfer reviewed after the active network's RPC endpoint was changed in
await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([
{ method: "getBalance", network: "sepolia", url: newNode },
{ method: "getFeeData", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode },
{ method: "getFeeData", network: "sepolia", url: newNode },
]);
});
@@ -440,8 +385,12 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true);
expect(shown("confirm-fee-bound-error")).toBe(false);
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
});
},
);
@@ -462,7 +411,7 @@ describe.each([
expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([
{
method: "getBalance",
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
@@ -476,11 +425,6 @@ describe.each([
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT,
@@ -493,7 +437,10 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true);
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -525,103 +472,15 @@ describe.each([
await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your " +
expect(el("confirm-errors").innerHTML).toContain(
"Insufficient balance. You have 5.0000 " +
symbol +
" balance does not cover this amount. Please go back and" +
" send a smaller amount.",
" but are trying to send 10 " +
symbol +
".",
);
});
test("states and checks that network's balance as it is then, which no longer covers the send", async () => {
await review(reviewedOn);
expect(sendDisabled()).toBe(false);
mockNode.balanceWei = {
[reviewedOn]: parseEther("0.5"),
[switchedTo]: parseEther("5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"),
);
expect(shown("confirm-balance-error")).toBe(true);
expect(sendDisabled()).toBe(true);
});
test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => {
mockNode.balanceWei[reviewedOn] = parseEther("0.5");
await review(reviewedOn);
expect(sendDisabled()).toBe(true);
mockNode.balanceWei = {
[reviewedOn]: parseEther("5"),
[switchedTo]: parseEther("0.5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"),
);
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-amount-fee-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => {
mockNode.tokenUnits[reviewedOn] = 10000000n;
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(false);
mockNode.tokenUnits = {
[reviewedOn]: 500000n,
[switchedTo]: 10000000n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your TOK balance does not cover this amount. Please go back" +
" and send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => {
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(true);
mockNode.tokenUnits = {
[reviewedOn]: 10000000n,
[switchedTo]: 0n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-gas-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
await review(reviewedOn);
mockNode.legacyFees = true;
@@ -633,10 +492,10 @@ describe.each([
});
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, {
token: TOKEN,
tokenSymbol: "USDC",
tokenBalance: "50",
tokenDecimals: 6,
amount: "10",
});
@@ -649,7 +508,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
"10 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
@@ -677,101 +536,3 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
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(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-balance-read-error")).toBe(false);
expect(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// The Chrome suite measures that nothing moves; this checks the message
// is already written, only hidden, before the read returns.
test("that finds the amount not covered only makes a message written before it visible", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
const message =
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.";
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(false);
answer(parseEther("0.5"));
await settle();
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(true);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
// The fee is estimated once the read returns, after the switch, and still
// asks the node of the network the screen was drawn for.
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();
}
const feeAskedOn = () =>
mockNode.asked
.filter((q) => q.method === "getFeeData")
.map((q) => q.network);
expect(feeAskedOn()).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(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(shown("confirm-balance-read-error")).toBe(true);
expect(shown("confirm-balance-error")).toBe(false);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
});
-1
View File
@@ -192,7 +192,6 @@ describe("the wait, success and error screens", () => {
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
chainId: "0x1",
};
const transfer = { ...creation, to: RECIPIENT };
+1 -1
View File
@@ -101,7 +101,7 @@ describe("the flash line the message is shown in", () => {
//
// The line cuts a message too long for it with an ellipsis (see
// showFlash() in src/popup/views/helpers.js). The assertions that
// measure that, in a real browser at the documented 396x600 popup, are
// measure that, in a real browser at the documented 360x600 popup, are
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
// make test-e2e. They are not in make check because REPO_POLICIES.md
-12
View File
@@ -867,14 +867,6 @@ step(
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network");
@@ -889,10 +881,6 @@ step(
isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints",
);
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow);
},
);
+13 -30
View File
@@ -226,9 +226,8 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
// read at request time like every other fixture switch.
function latestBlock(opts) {
// stop being able to distinguish them.
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
@@ -240,26 +239,21 @@ function latestBlock(opts) {
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
// keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
// keccak("decimals()")[0:4], and the same for symbol() and name().
const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol(),
// name() and balanceOf() on the stub token. The wallet reads decimals() back
// at signing time to compare with the scale the confirmation screen rendered
// (issue #305). Adding the token by its contract address reads the first three
// (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.
// 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
// time to compare with the scale the confirmation screen rendered (issue
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
@@ -284,9 +278,6 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_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;
}
@@ -429,10 +420,9 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false.
//
// The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// holdBlockscout) is read here rather than captured, so a test flips it on the
// same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// here rather than captured, so a test flips it on the same options object the
// route was registered with — the same pattern as seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
@@ -482,7 +472,7 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock(opts) });
return Object.assign(envelope, { result: latestBlock() });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
@@ -567,9 +557,6 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url());
return route.abort();
}
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report);
}
@@ -626,12 +613,8 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+44 -423
View File
@@ -60,14 +60,6 @@ const TEST_TIMEOUT_MS = 120000;
// trailing drain in main().
const TRAILING_WATCH_MS = 1500;
// The popup's size, as README gives it: `body` in src/popup/styles/main.css
// is 396px wide, and src/background/index.js opens an approval window at
// the same 396x600. Pages in this context otherwise get Playwright's
// 1280x720 default, where the page never scrolls sideways and a button
// below the popup's fold is still on screen, so a layout measured there
// passes whatever the page does.
const POPUP_VIEWPORT = { width: 396, height: 600 };
const tests = [];
function test(name, fn) {
@@ -367,14 +359,11 @@ async function leaveTransactionDetail(page) {
await openAddressDetail(page);
}
// Back out to Home from wherever the previous test finished, and wait for the
// save that navigation makes, so a test reading the stored stack next reads
// the one Home left.
// Back out to Home from wherever the previous test finished.
async function goHome(page) {
await leaveTransactionDetail(page);
await page.click("#btn-address-back");
await visible(page, "#view-main");
await waitForPersisted(page, "currentView", "main", "back on Home");
}
// One field of the popup's state as it was actually persisted, read out of
@@ -1835,67 +1824,14 @@ test("the Settings assertions above all ran (#229)", async (env) => {
);
});
// ------------------------------------------------ testnet banner (#511)
// This suite runs a release build, so the red banner at the top reads
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
// Settings must change it while Settings is still on screen; it used to
// change only when the next screen came up
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
// The banner's text, or null when there is none, once the switch to `net`
// has finished. The switch's flash is the last thing its handler does, and
// showView() clears the flash, so the banner read while that flash is up is
// the one the switch left on the screen it was made from.
async function bannerAfterSwitch(page, net) {
await page.waitForFunction(
(text) => document.getElementById("flash-msg").textContent === text,
"Switched to " + net.name + ".",
);
return page.evaluate(() => {
const banner = document.getElementById("debug-banner");
return {
text: banner === null ? null : banner.textContent,
onSettings: !document
.getElementById("view-settings")
.classList.contains("hidden"),
};
});
}
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
await openSettings(env.page);
assert(
(await env.page.locator("#debug-banner").count()) === 0,
"a banner is up on mainnet before the switch",
);
await env.page.selectOption("#settings-network", "sepolia");
let after = await bannerAfterSwitch(env.page, sepolia);
assert(after.onSettings, "Settings left the screen on the switch");
assert(
after.text === "[TESTNET]",
"after the switch to Sepolia, Settings shows the banner " +
JSON.stringify(after.text) +
', expected "[TESTNET]"',
);
await env.page.selectOption("#settings-network", "mainnet");
after = await bannerAfterSwitch(env.page, mainnet);
assert(after.onSettings, "Settings left the screen on the switch back");
assert(
after.text === null,
"after the switch back to mainnet, Settings still shows the banner " +
JSON.stringify(after.text),
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };
// Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page.
//
@@ -2144,7 +2080,7 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
// ------------------------------------------ add wallet Import button (#493)
// At 396x600 the add wallet screen's Import button already starts near the
// At 360x600 the add wallet screen's Import button already starts near the
// bottom of the popup, so its password error line sits beside the button
// rather than above it. Measures the button and the line empty and again
// filled with the longest error addWallet.js puts there. Runs in the page.
@@ -2296,8 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, how tall the whole view is, and where the password field and
// Sign & Send sit in it.
// fee block says, and how tall the whole view is.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2310,12 +2245,6 @@ async function confirmState(page) {
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
@@ -2324,18 +2253,9 @@ async function confirmState(page) {
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
// Its sentence is wrapped across lines in index.html.
feeBoundError: shown("confirm-fee-bound-error")
? el("confirm-fee-bound-error")
.textContent.replace(/\s+/g, " ")
.trim()
: "",
sendDisabled: el("btn-confirm-send").disabled,
};
});
@@ -2351,16 +2271,6 @@ async function waitForEstimate(page) {
);
}
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
@@ -2372,14 +2282,13 @@ async function backToAddress(page) {
await openAddressDetail(page);
}
// Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change.
async function openConfirm(page, { token, balance, amount }) {
async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
await visible(page, "#view-send");
@@ -2394,14 +2303,6 @@ async function openConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
}
// The same, returning once the screen has read the balance. It reads it
// before it estimates the fee, so every state below is read with the balance
// in place, the pending fee included.
async function goToConfirm(page, send) {
await openConfirm(page, send);
await waitForBalance(page);
await assertAddressesFit(page, "the confirmation screen");
}
@@ -2477,10 +2378,7 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending",
);
assert(
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown before the fee is known",
);
});
@@ -2511,7 +2409,6 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
);
assert(
st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError &&
!st.gasError &&
!st.feeUnknownError,
@@ -2578,54 +2475,30 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
);
});
// The screen is first measured with its balance read held, so the message the
// read brings must already hold its space when the read returns (#558).
test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
env.routeOpts.holdBalance = true;
let reading;
try {
await openConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.balance === FUNDED_ETH_TEXT + " ETH",
"the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
st.errors === want,
"wrong over-balance message: " + JSON.stringify(st.errors),
);
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert(
!st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance",
);
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
});
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2669,10 +2542,7 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
@@ -2763,19 +2633,21 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
"Insufficient " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
st.errors === want,
"wrong over-token-balance message: " + JSON.stringify(st.errors),
);
assert(
st.sendDisabled,
@@ -2831,9 +2703,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer",
);
assert(
st.errors === "" && st.balanceError === "",
st.errors === "",
"a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors + st.balanceError),
JSON.stringify(st.errors),
);
assert(
st.height === env.erc20PendingHeight,
@@ -2979,10 +2851,9 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page);
assert(
shown === "0.2500 " + STUB_TOKEN.symbol,
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
@@ -3132,210 +3003,6 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
}
});
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
// of the two sentences.
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
env.errors.expect("confirmTx logging the refused send", /send refused/);
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
const before = await confirmState(env.page);
assert(
!before.sendDisabled && before.feeBoundError === "",
"the screen is not ready to send: " + JSON.stringify(before),
);
// Only now, with the screen drawn and its fee estimated, does the
// node quote a fee over the bound.
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await env.page.waitForFunction(
() =>
getComputedStyle(
document.getElementById("confirm-fee-bound-error"),
).visibility === "visible",
null,
{ timeout: 60000 },
);
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused send: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send over the fee bound reached the RPC",
);
assert(
after.feeBoundError ===
"The gas limit or fee the RPC endpoint quoted for this" +
" transaction is more than this wallet will sign for, so" +
" it was not sent. Please go back and try again.",
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
} finally {
env.routeOpts.baseFeeWei = undefined;
await backToAddress(env.page);
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
@@ -3436,14 +3103,10 @@ test("six failed receipt lookups in a row end on the unreachable-network message
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect.
// The issue's reproduction, with the frame sized to the popup.
// Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:' +
POPUP_VIEWPORT.width +
"px;height:" +
POPUP_VIEWPORT.height +
'px;z-index:99999"></iframe>';
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
// What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
@@ -3480,24 +3143,6 @@ function hostileSymbolState(page, tokenAddress) {
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
// The stub token is no longer tracked from here on, so the explorer is
// what lists it, as it lists a token airdropped to the victim. A tracked
// token is listed from the RPC endpoint under the symbol it was added
// with, which the hostile symbol below never reaches
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await env.page.click("#settings-tracked-tokens .btn-remove-token");
// The list is drawn again once the removal is saved.
await env.page.waitForFunction(
() =>
document
.getElementById("settings-tracked-tokens")
.innerText.trim() === "None",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
@@ -4883,12 +4528,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt(
env,
@@ -4910,9 +4549,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
});
// Switching the network in Settings tells the page too, as an approved site
@@ -5062,20 +4698,8 @@ function addressRowReport(page) {
});
}
// Measured at the popup's 396x600 (POPUP_VIEWPORT). Every page in this
// context, approval windows included, is otherwise 1280x720 whatever size
// the extension asks for, and a page that wide never scrolls sideways. The
// page is then put back to the size it had, so the size a later step runs at
// does not depend on whether this one ran.
async function assertAddressesFit(page, where) {
const sizeBefore = page.viewportSize();
await page.setViewportSize(POPUP_VIEWPORT);
let report;
try {
report = await addressRowReport(page);
} finally {
await page.setViewportSize(sizeBefore);
}
const report = await addressRowReport(page);
assert(
report.rows.length > 0,
where + ": no address rows were rendered, so nothing was measured",
@@ -5235,9 +4859,6 @@ async function main() {
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475).
-1
View File
@@ -80,7 +80,6 @@ describe.each([
jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] },
saveState,
currentNetwork: () => ({ chainId: 1 }),
}));
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
+11 -12
View File
@@ -17,10 +17,6 @@ jest.mock("ethers", () => {
async lookupAddress() {
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.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -151,7 +147,7 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
@@ -245,7 +241,7 @@ describe.each([
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
@@ -343,11 +339,14 @@ describe.each([
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(text("confirm-balance-error")).toBe(
"Your " +
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.",
" but are trying to send 2 " +
symbol +
".",
);
});
});
@@ -429,10 +428,10 @@ describe.each([
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
+1 -1
View File
@@ -364,7 +364,7 @@ describe("a save that fails", () => {
return storage;
}
test("is reported when the caller does not await it", async () => {
test("is reported, not swallowed by the save queue", async () => {
const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage);
+3 -76
View File
@@ -97,7 +97,6 @@ const {
normalizePersisted,
} = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema");
const { isKnownNetworkId } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const {
bootPopup,
@@ -194,29 +193,6 @@ const CONTRACT = [
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
},
{
field: "networkTrackedTokens",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { sepolia: "notalist" }],
floorOnly: [
[1, 2],
{ sepolia: [1, 2] },
{ mainnet: [null, {}, [TOKEN_ADDRESS]] },
{ notanetwork: [{ address: TOKEN_ADDRESS }] },
JSON.parse('{"__proto__":[{"address":"0xabc"}]}'),
],
// Each list is put straight back onto trackedTokens by
// applyChainSwitchFields() when its network becomes the active one, so
// it is floored the same way; a key that names no network is dropped.
holds: (v) =>
isRecord(v) &&
Object.getPrototypeOf(v) === Object.prototype &&
Object.keys(v).every(
(id) =>
isKnownNetworkId(id) &&
everyEntry(v[id], (t) => isRecord(t) && isText(t.address)),
),
},
{
field: "allowedSites",
kind: KIND.ENTRIES,
@@ -441,17 +417,6 @@ const CONTRACT = [
views: ["error-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
views: ["success-tx"],
restored: false,
},
{
value: { message: "boom", to: ADDRESS, chainId: "0x5" },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`.
{
value: { tx: { hash: "0x1" } },
@@ -465,26 +430,6 @@ const CONTRACT = [
from: ADDRESS,
to: ADDRESS,
contractAddress: 42,
chainId: "0x1",
},
},
views: ["transaction"],
restored: false,
},
// A transaction whose chain id is missing or names no network has
// no explorer to link to and no Blockscout to be read from.
{
value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } },
views: ["transaction"],
restored: false,
},
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
chainId: "0x5",
},
},
views: ["transaction"],
@@ -508,19 +453,6 @@ const CONTRACT = [
views: ["confirm-tx"],
restored: false,
},
// The amount line splits the amount to cut it to four decimals.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: 42,
},
},
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
@@ -843,22 +775,17 @@ const WELL_FORMED_DATA = {
message: "boom",
to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: null,
chainId: "0x1",
},
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
txInfo: { to: ADDRESS, amount: "1" },
broadcastTime: BROADCAST_TIME,
},
};
+3 -3
View File
@@ -42,8 +42,8 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
await wait(FLASH_MS * 5);
});
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// A boot onto a screen whose requests no mocked module answers: they reach the
// boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
function restoringOnto(view, viewData) {
return unversionedValidProfile({
@@ -58,7 +58,7 @@ function restoringOnto(view, viewData) {
test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup(
restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" },
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
}),
);
-300
View File
@@ -1,300 +0,0 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+3 -10
View File
@@ -23,10 +23,7 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false,
}));
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.
// What the confirmation screen asks the node for.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -35,11 +32,7 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
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]),
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
@@ -97,7 +90,7 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
+9 -28
View File
@@ -18,7 +18,6 @@
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
@@ -31,13 +30,6 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() {
return null;
}
@@ -151,7 +143,7 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
@@ -203,19 +195,10 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
// fetched and stored exactly where the popup stores them.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
@@ -317,10 +300,9 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
);
});
@@ -334,7 +316,6 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
});
test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -348,8 +329,9 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
);
await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
expect(errors()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.",
);
});
});
@@ -374,10 +356,9 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
);
});
});
+8 -26
View File
@@ -17,7 +17,6 @@
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: null,
broadcast: [],
};
@@ -31,14 +30,6 @@ jest.mock("ethers", () => {
async getBalance() {
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() {
return null;
}
@@ -146,7 +137,7 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
@@ -196,12 +187,9 @@ function text(id) {
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
// fetched and stored exactly where the popup stores them.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [
{
@@ -306,8 +294,7 @@ describe("Max on an ETH send", () => {
openSend();
await pressMax();
await review();
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
@@ -323,16 +310,14 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(canSend()).toBe(true);
});
@@ -529,8 +514,7 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(confirmed.amount).toBe("0.5");
expect(text("confirm-amount")).toBe("0.5000 ETH");
expect(text("confirm-amount")).toBe("0.5 ETH");
});
});
@@ -544,8 +528,7 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
@@ -558,8 +541,7 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
+1 -1
View File
@@ -160,7 +160,7 @@ describe("the approval-window reproduction", () => {
await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save
// waits for the lock behind that one and so resolves only after it,
// joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed.
await approvalWindow.state.saveState();
+1 -6
View File
@@ -225,12 +225,7 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: {
pendingWait: {
hash: "0x1",
txInfo: {
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
broadcastTime: Date.now(),
},
},
-96
View File
@@ -1,96 +0,0 @@
// A popup save reads the stored record, merges this page's changes into it
// and writes it back. The background's write of a network the user approved
// used to be able to land between that read and that write, and the popup's
// write then put the old network back
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every write of the record
// now holds a lock from its read to its write.
//
// The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and a page outside
// a Firefox private window share the browser's
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub");
function loadContexts(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return {
popup: require("../src/shared/state"),
background: require("../src/background/state"),
applyChainSwitchFields: require("../src/shared/chainSwitchFields")
.applyChainSwitchFields,
};
}
// Lets every pending promise callback run, and then some.
function nextMacrotask() {
return new Promise((resolve) => setImmediate(resolve));
}
afterEach(() => {
delete globalThis.chrome;
jest.restoreAllMocks();
});
test("a network the background switches to during a popup save stays stored", async () => {
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
// The popup navigates, so its save has a change of its own to write.
popup.state.currentView = "settings";
// The popup's save has read the record and is about to write it back when
// the user's approval of a site's switch to mainnet reaches the
// background. Without the lock the background's whole update runs before
// the next macrotask; with it, the update waits for the popup's write.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
test("after a save that failed, the next save still keeps a network the background switches to during it", async () => {
jest.spyOn(console, "error").mockImplementation(() => {});
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
await popup.loadState();
popup.state.currentView = "settings";
// The first save's write is refused, so it gives the lock back having
// failed.
storage.local.set.mockRejectedValueOnce(new Error("quota exceeded"));
await expect(popup.saveState()).rejects.toThrow("quota exceeded");
// The next save is between its read and its write when the background
// switches to mainnet, as in the test above.
let backgroundSwitch;
const realSet = storage.local.set.getMockImplementation();
storage.local.set.mockImplementationOnce(async (items) => {
backgroundSwitch = background.updateState((fresh) =>
applyChainSwitchFields(fresh, "mainnet"),
);
await nextMacrotask();
return realSet(items);
});
await popup.saveState();
await backgroundSwitch;
const stored = storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.currentView).toBe("settings");
});
-27
View File
@@ -1,27 +0,0 @@
// `navigator.locks` for the unit tests, which run on node, where there is none.
//
// One lock per name: granted in the order it was asked for, and let go when
// the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's,
// except a page in a Firefox private window (README, "Stored state and its
// version").
//
// Run before each test file by `setupFiles` in package.json.
// For each name, a promise that settles once the last request for it has
// let the lock go.
const released = new Map();
function request(name, callback) {
const previous = released.get(name) || Promise.resolve();
const result = previous.then(() => callback({ name, mode: "exclusive" }));
released.set(
name,
result.catch(() => {}),
);
return result;
}
globalThis.navigator = { locks: { request } };
+7 -11
View File
@@ -261,14 +261,10 @@ async function settle() {
async function bootPopup(stored, options) {
jest.resetModules();
// The RPC requests made through src/shared/balances.js go through Node's
// own http and https modules, not fetch(), so the fetch() stub below would
// not stop them, which is why that module stays mocked.
// src/shared/prices.js and src/shared/transactions.js send their requests
// through fetch(), and their mocks answer with no prices and no
// transactions. Every request the page still sends, such as the
// transaction screen's Blockscout lookup and the Etherscan lookup in
// src/shared/etherscanLabels.js, meets the fetch() stub.
// Three of the modules that reach the network. None is on the path under
// test; all would make the suite hit the internet. Every other request,
// such as the transaction screen's Blockscout lookup and the Etherscan
// lookup in src/shared/etherscanLabels.js, meets the fetch() stub below.
jest.doMock("../../src/shared/prices", () => ({
prices: {},
refreshPrices: jest.fn(async () => {}),
@@ -311,7 +307,7 @@ async function bootPopup(stored, options) {
const windowListeners = {};
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
storage: { local: storage.local },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
@@ -342,8 +338,8 @@ async function bootPopup(stored, options) {
},
};
// Every fetch() the page makes fails, as it does elsewhere in the suite,
// instead of reaching the internet. A new stub per boot, so the calls it
// records start with that boot. Put back by cleanupPopup().
// instead of reaching the internet. A new stub per boot, so its calls are
// that boot's requests. Put back by cleanupPopup().
globalThis.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
+5 -30
View File
@@ -18,9 +18,7 @@
// a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways.
// `changed` is handed what each set or remove changed, in the shape
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
function makeArea(store, tick) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -36,25 +34,14 @@ function makeArea(store, tick, changed) {
const set = jest.fn(async (items) => {
tick();
const changes = {};
for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]);
}
changed(changes);
});
const remove = jest.fn(async (key) => {
tick();
const changes = {};
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return { get, set, remove };
@@ -67,22 +54,12 @@ function makeArea(store, tick, changed) {
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
// storage.onChanged: after a set or remove has finished, every listener is
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
const local = makeArea(store, tick);
return {
// Drop this straight in as chrome.storage.
local,
session: makeArea({}, tick, announce("session")),
onChanged: { addListener: (fn) => listeners.push(fn) },
session: makeArea({}, tick),
get: local.get,
set: local.set,
remove: local.remove,
@@ -94,9 +71,7 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this". Nothing is
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
// test — for standing in as "another page wrote this".
write: (key, value) => {
store[key] = structuredClone(value);
},
+6 -123
View File
@@ -8,14 +8,6 @@
// tracks. It decodes against the address the explorer reports the transaction
// was sent to.
//
// Its links open the explorer of the network the transaction was sent on,
// whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
// read from that network's Blockscout, the one Settings has for it
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
// the token, and scale its amount, from what the wallet knows of that
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape
@@ -30,10 +22,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => {
mockAsked.push(url);
return { ok: true, json: async () => mockExplorerAnswer(url) };
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
@@ -43,13 +35,10 @@ globalThis.chrome = {
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2";
// A Blockscout that knows nothing of the transaction.
const OTHER_EXPLORER = "https://other-explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name.
@@ -62,9 +51,6 @@ const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// Every URL asked of a Blockscout, in order.
const mockAsked = [];
// The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract.
function mockExplorerAnswer(url) {
@@ -91,11 +77,7 @@ function mockExplorerAnswer(url) {
};
}
if (url === EXPLORER + "/transactions/" + TX_HASH) {
return {
to: { hash: TOKEN },
raw_input: TRANSFER_DATA,
block_number: 100,
};
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
}
return { items: [] };
}
@@ -158,7 +140,6 @@ beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [
{
name: "Main",
@@ -167,18 +148,13 @@ beforeEach(() => {
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
@@ -198,96 +174,3 @@ test("a token transfer's decoded details name the token contract and the amount
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
// row was read from the Blockscout Settings remembers for Sepolia, and the
// screen reads the transaction from there too, so every link on it is drawn.
test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const sepolia = "https://sepolia.etherscan.io";
const links = (id) =>
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
expect(links("tx-detail-token-contract")).toEqual([
`${sepolia}/token/${TOKEN.toLowerCase()}`,
]);
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
expect(links("tx-detail-calldata-details")).toEqual([
`${sepolia}/address/${TOKEN}`,
`${sepolia}/address/${RECIPIENT}`,
]);
});
test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(mockAsked).toEqual([
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]);
});
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
// its decoded details.
async function showSepoliaTransferWithMainnetActive() {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
state.trackedTokens = [];
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
state.wallets[0].addresses[0].tokenBalances = [
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
];
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
-303
View File
@@ -1,303 +0,0 @@
// The balance of each token the user tracks is read with balanceOf through the
// RPC endpoint, for every address. Blockscout's answer gives a tracked token its
// holder count and decimals, and adds the tokens the user has not tracked
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
//
// The real refreshBalances() runs against a fake JSON-RPC node, answered
// through ethers' own request hook, and a fake Blockscout, answered through
// fetch().
const { AbiCoder, FetchRequest } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const FIRST = "0x1111111111111111111111111111111111111111";
const SECOND = "0x2222222222222222222222222222222222222222";
// A token the user tracks, outside the bundled list, at 6 decimals.
const TOKEN = "0x7777777777777777777777777777777777777777";
const TRACKED = {
address: TOKEN,
symbol: "TRK",
name: "Tracked Token",
decimals: 6,
};
// USDC, from the bundled list, which the user does not track.
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
// Neither on the bundled list nor tracked.
const OTHER = "0x8888888888888888888888888888888888888888";
const ZERO_WORD = "0x" + "0".repeat(64);
const BALANCE_OF = "0x70a08231";
// What balanceOf answers, by token contract and then by holder. A pair that is
// not here makes balanceOf fail.
let holdings;
// What Blockscout answers for every address; null makes the request fail.
let explorerRows;
let logged;
// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup,
// answered with no resolver, so no name.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return { ...envelope, result: "0x0" };
}
if (req.method !== "eth_call") {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
const { to, data } = req.params[0];
if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD };
const holder = "0x" + data.slice(-40);
const amount = (holdings[to.toLowerCase()] || {})[holder];
if (amount === undefined) {
return {
...envelope,
error: { code: -32000, message: "fixture: balanceOf failed" },
};
}
return {
...envelope,
result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]),
};
}
const realFetch = globalThis.fetch;
beforeEach(() => {
holdings = {};
explorerRows = null;
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
globalThis.fetch = jest.fn(async () => {
if (explorerRows === null) throw new TypeError("Failed to fetch");
return { ok: true, status: 200, json: async () => explorerRows };
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
jest.restoreAllMocks();
});
// One Blockscout token-balances row.
function explorerRow(address, symbol, value, holders = "50000") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: address,
symbol: symbol,
name: symbol,
decimals: "6",
holders_count: holders,
},
};
}
// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the
// decimals and holder count Blockscout reported for it, if any.
function trackedRow(balance, decimals = null, holders = null) {
return {
address: TOKEN,
name: "Tracked Token",
symbol: "TRK",
decimals: decimals,
balance: balance,
holders: holders,
};
}
async function refresh(addresses, trackedTokens) {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
"mainnet",
new AbortController().signal,
);
return addresses;
}
test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => {
holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n };
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("1234.5")]);
// Exact, never cut: one base unit is not zero.
expect(second.tokenBalances).toEqual([trackedRow("0.000001")]);
});
test("with Blockscout failing, the untracked rows from before stay", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const usdc = {
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [usdc] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]);
});
test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [
explorerRow(TOKEN, "TRK", 9000000),
explorerRow(USDC, "USDC", 5000000),
// Too few holders, and neither on the bundled list nor tracked.
explorerRow(OTHER, "SPAM", 5000000, "10"),
// A known symbol from a contract it does not belong to.
explorerRow(OTHER, "USDC", 5000000),
];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
trackedRow("2.0", 6, 50000),
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
]);
});
test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
// Fewer holders than "Hide tokens with fewer than 1,000 holders" lets
// through to Send's token list.
explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("a failed balanceOf keeps the token's entry from before, and is logged", async () => {
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const before = trackedRow("7.0", 6, 50000);
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([before]);
expect(logged.some((line) => line.includes("token balance failed"))).toBe(
true,
);
});
test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => {
// balanceOf fails at FIRST and answers at SECOND.
holdings[TOKEN] = { [SECOND]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
{
address: TOKEN,
name: "TRK",
symbol: "TRK",
decimals: 6,
balance: "9.0",
holders: 50000,
},
]);
// Where balanceOf answers, its figure stands.
expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => {
holdings[TOKEN] = { [FIRST]: 0n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0")] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([]);
});
test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => {
holdings[TOKEN] = { [FIRST]: 5n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow(null)]);
});
test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a tracked token wearing a known symbol from another contract is not listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
// Its entry from before goes too, although Blockscout fails.
const before = {
address: OTHER,
name: "Fake",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }],
);
expect(first.tokenBalances).toEqual([]);
});
-373
View File
@@ -1,373 +0,0 @@
// Tracked tokens are kept per network, and the two add-token screens offer the
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on
// the screen "Back" lands on after a switch made from the Settings gear and for
// a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// Tracked on neither network.
const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
const NETWORK_CHANGED_FLASH =
"The active network changed before the lookup finished, so the token was" +
" not added.";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto SettingsAddToken, on `networkId`.
function bootOnAddToken(networkId, extra) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "settings-addtoken",
...extra,
}),
);
}
// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
// there and TST tracked on Sepolia.
function bootOnAddress() {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
}
// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
// it, over the same tracked tokens as bootOnAddress().
function bootOnToken(token) {
return bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: token,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(boot, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await boot.settle();
}
// Presses `addButton` with a contract lookup that answers only after the
// Settings gear and a switch to `networkId`.
async function addAcrossSwitch(boot, addButton, networkId) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click(addButton);
await boot.click("btn-settings");
await chooseNetwork(boot, networkId);
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await adding;
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(boot, networkId) {
const select = boot.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await boot.settle();
}
// The Settings gear, a switch to Sepolia, and Back.
async function gearSwitchBack(boot) {
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await chooseNetwork(boot, "sepolia");
await boot.click("btn-settings-back");
}
describe.each([
["mainnet", false],
["sepolia", true],
])("on %s", (networkId, hidden) => {
test(`the common tokens are hidden: ${hidden}`, async () => {
const boot = await bootOnAddToken(networkId);
expect(boot.pageErrors).toEqual([]);
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
require("../src/popup/views/addToken").show();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.hidden("add-token-common")).toBe(hidden);
});
});
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
const boot = await bootOnAddToken("sepolia", {
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const stored = () => boot.storage.read("autistmask");
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: SEPOLIA_TOKEN.symbol,
decimals: SEPOLIA_TOKEN.decimals,
name: SEPOLIA_TOKEN.name,
});
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
expect(lookupTokenInfo).toHaveBeenCalledWith(
TOKEN_ADDRESS,
expect.any(String),
"sepolia",
expect.anything(),
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
expect(onMainnet).toContain("DAI");
expect(onMainnet).not.toContain("TST");
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.hidden("settings-addtoken-common")).toBe(false);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
expect(boot.hidden("settings-addtoken-common")).toBe(true);
});
test("AddressDetail lists the new network's tracked tokens", async () => {
const boot = await bootOnAddress();
expect(boot.node("address-balances").innerHTML).toContain("DAI");
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
const balances = boot.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
});
// Neither can be rendered from saved state, so neither is landed on.
test.each([
["btn-add-token", "add-token"],
["btn-send", "send"],
])("from over %s, it lands on AddressDetail", async (button, view) => {
const boot = await bootOnAddress();
await boot.click(button);
expect(boot.visibleViews()).toEqual([view]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
});
// DAI was picked on Ethereum Mainnet, where alone it is tracked.
test.each([
[
"opened from AddressDetail",
async () => {
const boot = await bootOnAddress();
await openToken(boot, MAINNET_TOKEN.address);
return boot;
},
],
["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
])(
"from AddressToken for DAI %s, it lands on AddressDetail",
async (_how, open) => {
const boot = await open();
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("DAI");
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
const balances = boot.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
},
);
// The stack is saved cut with the switch, so a popup closed straight after
// it and reopened onto Settings lands where this one would. Loading a
// saved stack cuts it at AddToken and Send as well, but not at
// AddressToken.
test.each([
[
"AddressToken for DAI",
(boot) => openToken(boot, MAINNET_TOKEN.address),
],
["AddToken", (boot) => boot.click("btn-add-token")],
["Send", (boot) => boot.click("btn-send")],
])(
"from %s, a popup reopened after the switch lands on AddressDetail",
async (_view, open) => {
const boot = await bootOnAddress();
await open(boot);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const reopened = await bootPopup(boot.storage.read("autistmask"));
expect(reopened.visibleViews()).toEqual(["settings"]);
await reopened.click("btn-settings-back");
expect(reopened.visibleViews()).toEqual(["address"]);
const balances = reopened.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
},
);
test("AddressToken for the native token is drawn again for the new network", async () => {
const boot = await bootOnToken("ETH");
expect(boot.visibleViews()).toEqual(["address-token"]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("SepoliaETH");
});
// A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
// ConfirmTx again from the saved transaction. It is drawn for the network
// the transaction was reviewed on, and Sign & Send refuses it on the new
// one.
test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "sepolia",
currentView: "confirm-tx",
selectedWallet: 0,
selectedAddress: 0,
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: NETWORKS.sepolia.chainId,
},
},
}),
);
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
globalThis.fetch.mockClear();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
// Drawn again: its Etherscan label check asks Sepolia's explorer.
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
]);
expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(
boot.node("confirm-network-changed-error").style.visibility,
).toBe("visible");
// Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe("");
});
test("without a wallet, it lands on Welcome", async () => {
const boot = await bootPopup(undefined);
expect(boot.visibleViews()).toEqual(["welcome"]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["welcome"]);
});
test("a later Back onto Settings keeps what was typed there", async () => {
const boot = await bootOnAddress();
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
boot.node("settings-rpc").value = "https://typed.example";
await boot.click("btn-settings-add-token");
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
await boot.click("btn-settings-addtoken-back");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.value("settings-rpc")).toBe("https://typed.example");
});
});
describe("a contract lookup that answers after a switch adds nothing", () => {
test("from SettingsAddToken", async () => {
const boot = await bootOnAddToken("sepolia", {
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const stored = () => boot.storage.read("autistmask");
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
});
test("from AddToken", async () => {
const boot = await bootOnAddress();
const stored = () => boot.storage.read("autistmask");
await boot.click("btn-add-token");
boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
});
+3 -176
View File
@@ -29,13 +29,8 @@ jest.mock("../src/shared/log", () => ({
}));
const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId) => {
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
refreshBalances: jest.fn(),
}));
@@ -112,7 +107,6 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
@@ -124,7 +118,6 @@ const TX_INFO = {
amount: "0.0050",
token: "ETH",
tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
};
// True when a view element is not hidden.
@@ -144,14 +137,8 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -362,14 +349,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView().
{
hash: TX_HASH,
txInfo: { ...TX_INFO, to: 42 },
txInfo: { to: 42, amount: "0.0050" },
broadcastTime: Date.now(),
},
// Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present.
{
hash: TX_HASH,
txInfo: Object.assign([], TX_INFO),
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
broadcastTime: Date.now(),
},
{
@@ -377,18 +364,6 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT },
broadcastTime: Date.now(),
},
// Otherwise complete but for a chain id naming no network: there
// is no node to ask for the receipt, and no explorer to link to.
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: undefined },
broadcastTime: Date.now(),
},
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: "0x5" },
broadcastTime: Date.now(),
},
]) {
state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false);
@@ -524,154 +499,6 @@ describe("WaitTx when the popup closes", () => {
});
});
// The popup can reopen onto WaitTx after Settings switched the network. The
// wait asked the active network's node for the receipt, never found it, and
// reported a timeout, and its links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
beforeEach(() => {
state.rpcUrl = "https://mainnet.node.example.invalid";
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
});
test("the receipt is asked of the Sepolia node Settings has", async () => {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
txInfo: sepoliaTx,
hash: TX_HASH,
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(mockNodes).toEqual([
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
]);
expect(state.currentView).toBe("success-tx");
});
test("the recipient and hash link to the Sepolia explorer", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(sepoliaTx, TX_HASH);
expect(getElement("wait-tx-to").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
);
expect(getElement("wait-tx-hash").innerHTML).toContain(
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
);
});
});
// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
// their links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
// screen again after rendering it from its stored view data alone, as a
// reopened popup does.
describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
expect(getElement("success-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("success-tx-block").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/block/21000000"`,
);
expect(getElement("success-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait(sepoliaTx, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
);
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
}
const decoded = {
name: "Transfer",
details: [
{ label: "Token", address: TOKEN, isToken: true },
{ label: "Recipient", address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
// https://git.eeqj.de/sneak/AutistMask/issues/629
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
);
}
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectTokenNamed();
elements.clear();
txStatus.renderSuccess();
expectTokenNamed();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("error-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
expectSepoliaLinks();
elements.clear();
txStatus.renderError();
expectSepoliaLinks();
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills
+87 -2301
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -105
View File
@@ -7,12 +7,6 @@
// only when the transaction is sent to a Universal Router deployment on the
// network it is for; anywhere else it is left to the raw data.
//
// The transaction history labelled any call named `execute` or `multicall` a
// Swap, whatever address it went to
// (https://git.eeqj.de/sneak/AutistMask/issues/580). It now does so only for
// such a call sent to a Universal Router deployment on the network the
// transaction is on; any other is labelled by its method name.
//
// The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it.
@@ -39,7 +33,6 @@ globalThis.chrome = {
};
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -54,8 +47,6 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
// A contract that is not a router, such as a multisig.
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
@@ -138,13 +129,8 @@ describe("execute() calldata on the approval screen's decoder", () => {
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null;
// The transactions the explorer reports for RECIPIENT's history.
let mockHistory = [];
function mockExplorerAnswer(url) {
if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) {
return { items: mockHistory };
}
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
to: { hash: mockSentTo },
@@ -200,126 +186,57 @@ function node(id) {
return globalThis.document.getElementById(id);
}
// Open the transaction detail screen with `activeNetworkId` active, for the
// history row `row`, and wait for its decoded details.
async function showDetail(row, activeNetworkId) {
mockSentTo = row.to;
// Open the transaction detail screen with Sepolia active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo) {
mockSentTo = sentTo;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId;
state.networkId = "sepolia";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show(row);
transactionDetail.show({
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Open the transaction detail screen with `activeNetworkId` active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo, activeNetworkId) {
await showDetail(
{
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
},
activeNetworkId,
);
}
describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia");
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/623
test("sent to a router on its network, with the other network active, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "mainnet");
await showDetailSentTo(SEPOLIA_ROUTER);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY, "sepolia");
await showDetailSentTo(ORDINARY);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER, "sepolia");
await showDetailSentTo(MAINNET_ROUTER);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
});
// The history row made from the explorer's answer for RECIPIENT's `method`
// call sent to the contract `sentTo` on the network with `chainId`. The
// explorer gives the address in checksummed form.
async function historyRow(method, sentTo, chainId) {
mockHistory = [
{
hash: TX_HASH,
block_number: 7000000,
timestamp: "2026-09-01T00:00:00Z",
from: { hash: RECIPIENT },
to: { hash: getAddress(sentTo), is_contract: true },
value: "0",
method,
status: "ok",
},
];
const rows = await fetchRecentTransactions(
RECIPIENT,
"https://explorer.example/api/v2",
chainId,
);
return rows[0];
}
// The history row's label and TransactionDetail's Type line, for a Sepolia
// transaction. The active network does not decide either.
describe.each([
["execute", "Execute"],
["multicall", "Multicall"],
])("a %s() call in the history", (method, methodLabel) => {
test("sent to a router on its network, it is a Swap", async () => {
const row = await historyRow(method, SEPOLIA_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe("Swap");
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Swap");
});
test("sent to another contract, it is labelled by its method name", async () => {
const row = await historyRow(method, OTHER_CONTRACT, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "sepolia");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
test("sent to a router of the other network only, it is labelled by its method name", async () => {
const row = await historyRow(method, MAINNET_ROUTER, SEPOLIA);
expect(row.directionLabel).toBe(methodLabel);
await showDetail(row, "mainnet");
expect(node("tx-detail-type").textContent).toBe("Contract Call");
});
});
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) {
+1 -8
View File
@@ -53,14 +53,7 @@ function unwrapData() {
}
function detail(data, label, sources) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
data,
ROUTER,
"0x1",
sources || {},
RECIPIENT,
);
const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {});
return decoded.details.find((d) => d.label === label);
}
+12 -14
View File
@@ -71,8 +71,6 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
// Its owner is USER, the transaction's sender: a check of any other address
// names no output token.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
@@ -80,13 +78,8 @@ function balanceCheck(token, minBalance) {
);
}
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) {
const decoded = decode(data);
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -94,7 +87,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a minimum
// BALANCE_CHECK still names the output, so the screen has a Min. received
// figure and a Token Out, and previously claimed the user was paying ETH
// for them.
const data = () =>
@@ -120,7 +113,9 @@ describe("an execute() whose input currency never decoded", () => {
});
test("does not name ETH in the swap title either", () => {
expect(decode(data()).name).toBe("Uniswap Swap");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
});
});
@@ -130,13 +125,14 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(decode(data()).name).toBe("Uniswap Swap");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
});
test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT");
// No step pays anyone, so the minimum is not said to be received.
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
});
});
@@ -178,7 +174,9 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(decode(data).name).toBe("Swap ETH → USDT");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
"Swap ETH → USDT",
);
});
test("WRAP_ETH is still ETH at 18 decimals", () => {
+2 -4
View File
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER);
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -134,9 +134,7 @@ describe("a V4 step that states a Min. received but no output currency", () => {
});
test("refuses to scale Min. received rather than assuming 18", () => {
// With no TAKE, no step pays the user either, so the line is not
// labelled received.
expect(detail(data(), "Min. paid out").value).toBe(
expect(detail(data(), "Min. received").value).toBe(
unknownDecimalsAmount(MIN_OUT),
);
});
+1 -2
View File
@@ -82,8 +82,7 @@ function walletsHolding(token, decimals) {
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
const decoded = decodeCalldata(data, ROUTER, "0x1");
return decoded.details.find((d) => d.label === label);
}
+39 -52
View File
@@ -39,8 +39,7 @@ jest.mock("../src/shared/log", () => ({
// 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
// 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.
// in these tests is a failure of the scale and not of the stub.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -49,14 +48,7 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
@@ -120,9 +112,9 @@ global.document = {
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator.clipboard = { writeText() {} };
global.navigator = { clipboard: { writeText() {} } };
const { AbiCoder, parseUnits } = require("ethers");
const { parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
@@ -163,9 +155,8 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. The node reports the holding the explorer does.
// refreshBalances() puts them.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -190,7 +181,6 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -249,12 +239,6 @@ function errors() {
return el("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
@@ -286,6 +270,7 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18);
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 () => {
@@ -322,10 +307,11 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(sendDisabled()).toBe(true);
});
});
@@ -369,8 +355,7 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance.
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
expect(txInfo.tokenBalance).toBeNull();
});
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -382,7 +367,8 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).not.toBe("");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it
@@ -448,20 +434,31 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
// A send of 1.5 NOVEL from an address the node reports none of, with the
// token's scale `tokenDecimals`, or null for a scale nobody knows.
async function render(tokenDecimals) {
mockProvider.tokenUnits = 0n;
function txInfo(tokenBalance) {
return {
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [
{
name: "Wallet 1",
@@ -472,40 +469,30 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
confirmTx.show(txInfo(tokenBalance));
await settle();
return { balance: text("confirm-balance"), message: balanceError() };
return { balance: text("confirm-balance"), errors: errors() };
}
test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null);
const zero = await render(18);
const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "your balance does not
// cover this amount" is a claim about the holding, and this one has no
// established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
// about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => {
const unknown = await render(null);
const zero = await render(18);
expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.message).not.toContain("does not cover");
expect(zero.message).toBe(
"Your NOVEL balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
const zero = await render("0.0");
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
});
});
+1 -1
View File
@@ -475,7 +475,7 @@ describe("every way to a signature or the private key refuses a defective wallet
to: RECIPIENT,
value: "0x0",
data: "0x",
chainId: "0x1",
chainId: 1,
nonce: 0,
gasLimit: "21000",
maxFeePerGas: "1000000000",