Compare commits
2
Commits
6a1fb7da39
...
195e2b3e1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195e2b3e1c | ||
|
|
4472746b89 |
No files matched your search
@@ -1389,6 +1389,19 @@ carries a `schemaVersion` — `STATE_SCHEMA_VERSION` in
|
|||||||
`saveState()` and the background's `updateState()` both do, so whichever context
|
`saveState()` and the background's `updateState()` both do, so whichever context
|
||||||
wrote last, the record says which build's shape it is in.
|
wrote last, the record says which build's shape it is in.
|
||||||
|
|
||||||
|
Each write reads the record, changes it and writes it back, holding a lock from
|
||||||
|
the read to the write: the browser's own (`navigator.locks`), taken through
|
||||||
|
`withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
|
||||||
|
background share it, except a page in a Firefox private window, which Firefox
|
||||||
|
gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646).
|
||||||
|
The popup's `saveState()`, the background's `updateState()` and the
|
||||||
|
StateRecovery reset all take it, so among the pages and the background that
|
||||||
|
share it, no write lands between another write's read and its write, where it
|
||||||
|
would be lost. Chrome has it from version 69, older than any Chrome that runs a
|
||||||
|
Manifest V3 extension, in pages and in the service worker; Firefox from version
|
||||||
|
96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
|
||||||
|
write fails rather than going ahead without it.
|
||||||
|
|
||||||
Version 1 is the shape that shipped before versions existed, so a stored record
|
Version 1 is the shape that shipped before versions existed, so a stored record
|
||||||
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
|
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
|
||||||
is migrated in place by being stamped on the first write. An upgrade never shows
|
is migrated in place by being stamped on the first write. An upgrade never shows
|
||||||
@@ -1999,7 +2012,11 @@ view would leave a wallet one click from deletion.
|
|||||||
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
||||||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
|
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
|
||||||
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
|
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
|
||||||
the token reports.
|
the token reports. A call named `execute` or `multicall` is a Swap, here
|
||||||
|
and on its history row, only when it was sent to one of the Universal
|
||||||
|
Router addresses Uniswap publishes for the network it was sent on (see
|
||||||
|
Transaction Decoding); sent to any other contract, its history row shows
|
||||||
|
the method name and this line reads Contract Call.
|
||||||
- Status: "Success" or "Failed"
|
- Status: "Success" or "Failed"
|
||||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||||
ENS name if available
|
ENS name if available
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
"fmt-check": "prettier --check ."
|
"fmt-check": "prettier --check ."
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"setupFiles": [
|
||||||
|
"<rootDir>/tests/support/navigatorLocks.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@tailwindcss/cli": "4.3.1",
|
"@tailwindcss/cli": "4.3.1",
|
||||||
|
|||||||
+18
-27
@@ -16,17 +16,19 @@
|
|||||||
// across any number of awaits and no concurrent work can
|
// across any number of awaits and no concurrent work can
|
||||||
// move it.
|
// move it.
|
||||||
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
||||||
// back — all inside a queue, so two background writes
|
// back — all holding the lock every write of the stored
|
||||||
// never interleave, and the read is one storage round trip
|
// record takes, so no other write, by the background or a
|
||||||
// ahead of the write rather than a page lifetime ahead of
|
// page, lands in between, and the read is one storage
|
||||||
// it (which is what made the popup's saveState() need a
|
// round trip ahead of the write rather than a page
|
||||||
// per-field merge against a baseline at all).
|
// 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
|
// A handler that must both read and write therefore does its network work
|
||||||
// against a snapshot it owns, and applies the RESULT inside updateState().
|
// against a snapshot it owns, and applies the RESULT inside updateState().
|
||||||
// It never publishes an object other in-flight work is holding.
|
// It never publishes an object other in-flight work is holding.
|
||||||
|
|
||||||
const { storageGet, storageSet } = require("../shared/browserApi");
|
const { storageGet, storageSet, withLock } = require("../shared/browserApi");
|
||||||
const { normalizePersisted } = require("../shared/persistedState");
|
const { normalizePersisted } = require("../shared/persistedState");
|
||||||
const {
|
const {
|
||||||
STATE_SCHEMA_VERSION,
|
STATE_SCHEMA_VERSION,
|
||||||
@@ -51,11 +53,6 @@ async function getState() {
|
|||||||
return normalizePersisted(result.autistmask);
|
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) {
|
async function updateStateOnce(mutate) {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
await mutate(s);
|
await mutate(s);
|
||||||
@@ -70,27 +67,21 @@ async function updateStateOnce(mutate) {
|
|||||||
|
|
||||||
// Apply `mutate` to a record read fresh from storage and write the result
|
// Apply `mutate` to a record read fresh from storage and write the result
|
||||||
// back. `mutate` receives a detached, normalized profile and mutates it in
|
// back. `mutate` receives a detached, normalized profile and mutates it in
|
||||||
// place; it may be async, but it must not do anything slow — the window
|
// place; it may be async, but it must not do anything slow. The write is the
|
||||||
// between the read and the write is the window in which another context's
|
// WHOLE record, which is safe only because the lock (withLock() in
|
||||||
// write is lost, and keeping it to one storage round trip is what makes a
|
// src/shared/browserApi.js) is held from the read to the write: two
|
||||||
// whole-record write safe here. Concretely: the write is the WHOLE record, so
|
// background turns, or a turn and a page's save, never interleave
|
||||||
// a popup write that lands inside that window is reverted, in every field, by
|
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
||||||
// the record this turn read before it. That is accepted because the window is
|
// while it is held.
|
||||||
// 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
|
// `mutate` must also not call updateState() itself, directly or through
|
||||||
// anything it awaits: the queue is strictly serial, so the inner turn waits on
|
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
||||||
// the outer one, which is waiting on the inner one. That deadlocks the whole
|
// and the outer one is waiting on the inner one. That deadlocks every write of
|
||||||
// background, not just the caller. Mutate the record you were handed.
|
// the stored record, not just the caller. Mutate the record you were handed.
|
||||||
//
|
//
|
||||||
// Resolves with the record that was written.
|
// Resolves with the record that was written.
|
||||||
function updateState(mutate) {
|
function updateState(mutate) {
|
||||||
const turn = updateQueue.then(() => updateStateOnce(mutate));
|
return withLock("autistmask", () => 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 };
|
module.exports = { getState, updateState };
|
||||||
@@ -24,7 +24,11 @@
|
|||||||
// than written again locally so that tests/popupElementIds.test.js sees these
|
// than written again locally so that tests/popupElementIds.test.js sees these
|
||||||
// lookups and holds every id below against the markup.
|
// lookups and holds every id below against the markup.
|
||||||
const { $, VIEWS } = require("./helpers");
|
const { $, VIEWS } = require("./helpers");
|
||||||
const { storageGet, storageRemove } = require("../../shared/browserApi");
|
const {
|
||||||
|
storageGet,
|
||||||
|
storageRemove,
|
||||||
|
withLock,
|
||||||
|
} = require("../../shared/browserApi");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
// Typed in full before anything is erased, in the same spirit as the wallet
|
// Typed in full before anything is erased, in the same spirit as the wallet
|
||||||
@@ -137,7 +141,9 @@ async function resetProfile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await storageRemove("autistmask");
|
// 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.
|
||||||
|
await withLock("autistmask", () => storageRemove("autistmask"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("state recovery: reset failed:", e);
|
log.errorf("state recovery: reset failed:", e);
|
||||||
setFlash("The saved data could not be erased. Nothing was changed.");
|
setFlash("The saved data could not be erased. Nothing was changed.");
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
// Everything is resolved on use rather than captured at module load. The MV3
|
// 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
|
// service worker is torn down and re-evaluated repeatedly, and the unit
|
||||||
// suite installs its stubs on `global.chrome` around a require().
|
// 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.
|
// The extension API namespace, preferring `browser.*` where it exists.
|
||||||
//
|
//
|
||||||
@@ -219,6 +224,33 @@ function storageRemove(keys) {
|
|||||||
return Promise.resolve(storage.remove(keys));
|
return Promise.resolve(storage.remove(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// `storage.session`, the browser's session storage: what is kept there lasts
|
||||||
// until the browser closes. It survives the background worker being stopped
|
// until the browser closes. It survives the background worker being stopped
|
||||||
// and started again, which module-level variables do not (README, Background
|
// and started again, which module-level variables do not (README, Background
|
||||||
@@ -373,4 +405,5 @@ module.exports = {
|
|||||||
windowsGetLastFocused,
|
windowsGetLastFocused,
|
||||||
windowsRemove,
|
windowsRemove,
|
||||||
windowsUpdate,
|
windowsUpdate,
|
||||||
|
withLock,
|
||||||
};
|
};
|
||||||
+19
-26
@@ -35,7 +35,7 @@ const {
|
|||||||
migrationNeeded,
|
migrationNeeded,
|
||||||
} = require("./stateSchema");
|
} = require("./stateSchema");
|
||||||
|
|
||||||
const { storageGet, storageSet } = require("./browserApi");
|
const { storageGet, storageSet, withLock } = require("./browserApi");
|
||||||
const { log } = require("./log");
|
const { log } = require("./log");
|
||||||
|
|
||||||
// The live record the proxy below guards. Everything inside this module reads
|
// The live record the proxy below guards. Everything inside this module reads
|
||||||
@@ -458,8 +458,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// This page's own live `state` is deliberately NOT rehydrated from a field
|
// This page's own live `state` is deliberately NOT rehydrated from a field
|
||||||
// another page changed — only the record written to storage is merged.
|
// another page changed — only the record written to storage is merged.
|
||||||
// showView() fires saveState() on every navigation without awaiting it,
|
// showView() fires saveState() on every navigation without awaiting it,
|
||||||
// which is what makes the queue above necessary in the first place, and a
|
// which is why saves from one page wait for each other (see saveState()), and
|
||||||
// save that is slow to come back has no way to tell whether the field it
|
// 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
|
// 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
|
// overtaken by something this very page did in the meantime; writing it
|
||||||
// into `state` regardless reintroduced exactly the clobber this function
|
// into `state` regardless reintroduced exactly the clobber this function
|
||||||
@@ -535,29 +535,15 @@ async function saveStateOnce() {
|
|||||||
baseline = current;
|
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
|
// Where a failed save is REPORTED, set once by the context that has a screen
|
||||||
// to say it on (src/popup/index.js).
|
// to say it on (src/popup/index.js).
|
||||||
//
|
//
|
||||||
// A save that fails must not fail silently. showView() fires saveState() on
|
// A save that fails must not fail silently. showView() fires saveState() on
|
||||||
// every navigation without awaiting it, and the queue below has to attach a
|
// every navigation without awaiting it, and `await saveState()` inside an
|
||||||
// rejection handler to keep advancing — so a failing save was swallowed
|
// unguarded event handler is no better: either way, without this, a failure
|
||||||
// entirely: no throw, no message, nothing on screen. The wallet kept running
|
// would reach only the console, where the user never looks, and the wallet
|
||||||
// against storage that was rejecting every write, which is the data-loss half
|
// would keep running against storage that rejects every write. That is the
|
||||||
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
|
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362.
|
||||||
// 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
|
// This is the "tell the user" half; the other half is the floor in
|
||||||
// normalizePersisted(), which stops the malformed-record cause from arising in
|
// normalizePersisted(), which stops the malformed-record cause from arising in
|
||||||
@@ -583,11 +569,18 @@ 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).
|
||||||
|
//
|
||||||
|
// 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 when its turn begins against the
|
||||||
|
// baseline the save before it left.
|
||||||
function saveState() {
|
function saveState() {
|
||||||
const turn = saveQueue.then(saveStateOnce);
|
const turn = withLock("autistmask", 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
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
// one. The returned promise still rejects, so a caller that DOES await
|
// one. The returned promise still rejects, so a caller that DOES await
|
||||||
// keeps its own error handling.
|
// keeps its own error handling.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const { toDecimals } = require("./transferAmount");
|
|||||||
// truncation without the approval screens' nonzero floor: the transaction
|
// truncation without the approval screens' nonzero floor: the transaction
|
||||||
// detail view is the authoritative record and already shows exact precision.
|
// detail view is the authoritative record and already shows exact precision.
|
||||||
const { truncateAmount: formatTxValue } = require("./amountDisplay");
|
const { truncateAmount: formatTxValue } = require("./amountDisplay");
|
||||||
|
const { routerRelease } = require("./uniswap");
|
||||||
|
|
||||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||||
// over the address, not part of its identity. Every address comparison in
|
// over the address, not part of its identity. Every address comparison in
|
||||||
@@ -51,7 +52,6 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
}
|
}
|
||||||
// Map known DEX methods to "Swap" for cleaner display
|
// Map known DEX methods to "Swap" for cleaner display
|
||||||
const SWAP_METHODS = new Set([
|
const SWAP_METHODS = new Set([
|
||||||
"execute",
|
|
||||||
"swap",
|
"swap",
|
||||||
"swapExactTokensForTokens",
|
"swapExactTokensForTokens",
|
||||||
"swapTokensForExactTokens",
|
"swapTokensForExactTokens",
|
||||||
@@ -59,9 +59,16 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
"swapTokensForExactETH",
|
"swapTokensForExactETH",
|
||||||
"swapExactTokensForETH",
|
"swapExactTokensForETH",
|
||||||
"swapETHForExactTokens",
|
"swapETHForExactTokens",
|
||||||
"multicall",
|
|
||||||
]);
|
]);
|
||||||
const label = SWAP_METHODS.has(method)
|
// 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
|
||||||
? "Swap"
|
? "Swap"
|
||||||
: method.charAt(0).toUpperCase() + method.slice(1);
|
: method.charAt(0).toUpperCase() + method.slice(1);
|
||||||
direction = "contract";
|
direction = "contract";
|
||||||
|
|||||||
@@ -2237,4 +2237,4 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { decode };
|
module.exports = { decode, routerRelease };
|
||||||
@@ -158,7 +158,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Interface,
|
Interface,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
|
|||||||
+4
-1
@@ -367,11 +367,14 @@ async function leaveTransactionDetail(page) {
|
|||||||
await openAddressDetail(page);
|
await openAddressDetail(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back out to Home from wherever the previous test finished.
|
// 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.
|
||||||
async function goHome(page) {
|
async function goHome(page) {
|
||||||
await leaveTransactionDetail(page);
|
await leaveTransactionDetail(page);
|
||||||
await page.click("#btn-address-back");
|
await page.click("#btn-address-back");
|
||||||
await visible(page, "#view-main");
|
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
|
// One field of the popup's state as it was actually persisted, read out of
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const send = require("../src/popup/views/send");
|
const send = require("../src/popup/views/send");
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const { Transaction, Wallet, formatEther } = require("ethers");
|
const { Transaction, Wallet, formatEther } = require("ethers");
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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 one 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 extension's pages and its
|
||||||
|
// background 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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// `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 every page of the extension and its background share the
|
||||||
|
// browser's.
|
||||||
|
//
|
||||||
|
// 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,6 +7,12 @@
|
|||||||
// only when the transaction is sent to a Universal Router deployment on the
|
// 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.
|
// 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
|
// The transaction detail screen and the approval screen are driven against a
|
||||||
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
|
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
|
||||||
// approval screen opened the way tests/swapStatusAmount.test.js opens it.
|
// approval screen opened the way tests/swapStatusAmount.test.js opens it.
|
||||||
@@ -33,6 +39,7 @@ globalThis.chrome = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||||
const approval = require("../src/popup/views/approval");
|
const approval = require("../src/popup/views/approval");
|
||||||
const { decodeCalldata } = approval;
|
const { decodeCalldata } = approval;
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||||
@@ -47,6 +54,8 @@ const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
|
|||||||
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
||||||
// The address with no code from the issue.
|
// The address with no code from the issue.
|
||||||
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
|
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
|
||||||
|
// A contract that is not a router, such as a multisig.
|
||||||
|
const OTHER_CONTRACT = "0x5afe000000000000000000000000000000005afe";
|
||||||
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
||||||
@@ -129,8 +138,13 @@ describe("execute() calldata on the approval screen's decoder", () => {
|
|||||||
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
|
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
|
||||||
const TX_HASH = "0x" + "5".repeat(64);
|
const TX_HASH = "0x" + "5".repeat(64);
|
||||||
let mockSentTo = null;
|
let mockSentTo = null;
|
||||||
|
// The transactions the explorer reports for RECIPIENT's history.
|
||||||
|
let mockHistory = [];
|
||||||
|
|
||||||
function mockExplorerAnswer(url) {
|
function mockExplorerAnswer(url) {
|
||||||
|
if (url.endsWith("/addresses/" + RECIPIENT + "/transactions")) {
|
||||||
|
return { items: mockHistory };
|
||||||
|
}
|
||||||
if (url.endsWith("/transactions/" + TX_HASH)) {
|
if (url.endsWith("/transactions/" + TX_HASH)) {
|
||||||
return {
|
return {
|
||||||
to: { hash: mockSentTo },
|
to: { hash: mockSentTo },
|
||||||
@@ -187,10 +201,9 @@ function node(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open the transaction detail screen with `activeNetworkId` active, for the
|
// Open the transaction detail screen with `activeNetworkId` active, for the
|
||||||
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
|
// history row `row`, and wait for its decoded details.
|
||||||
// decoded details.
|
async function showDetail(row, activeNetworkId) {
|
||||||
async function showDetailSentTo(sentTo, activeNetworkId) {
|
mockSentTo = row.to;
|
||||||
mockSentTo = sentTo;
|
|
||||||
globalThis.document = makeDocument();
|
globalThis.document = makeDocument();
|
||||||
globalThis.window = { location: { search: "" } };
|
globalThis.window = { location: { search: "" } };
|
||||||
state.networkId = activeNetworkId;
|
state.networkId = activeNetworkId;
|
||||||
@@ -198,22 +211,32 @@ async function showDetailSentTo(sentTo, activeNetworkId) {
|
|||||||
state.viewData = {};
|
state.viewData = {};
|
||||||
state.viewStack = [];
|
state.viewStack = [];
|
||||||
state.currentView = null;
|
state.currentView = null;
|
||||||
transactionDetail.show({
|
transactionDetail.show(row);
|
||||||
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));
|
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", () => {
|
describe("execute() calldata on the transaction detail screen", () => {
|
||||||
test("sent to a router on the active network, it is a swap", async () => {
|
test("sent to a router on the active network, it is a swap", async () => {
|
||||||
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia");
|
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia");
|
||||||
@@ -245,6 +268,58 @@ describe("execute() calldata on the transaction detail screen", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
|
||||||
// transaction that sends SWAP_DATA to `sentTo`.
|
// transaction that sends SWAP_DATA to `sentTo`.
|
||||||
async function showApprovalSentTo(sentTo) {
|
async function showApprovalSentTo(sentTo) {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ global.document = {
|
|||||||
body: { prepend() {}, appendChild() {} },
|
body: { prepend() {}, appendChild() {} },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
};
|
};
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
global.navigator.clipboard = { writeText() {} };
|
||||||
|
|
||||||
const { AbiCoder, parseUnits } = require("ethers");
|
const { AbiCoder, parseUnits } = require("ethers");
|
||||||
const { fetchTokenBalances } = require("../src/shared/balances");
|
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||||
|
|||||||
Reference in new issue
Block a user