2 Commits
Author SHA1 Message Date
sneak a7af9cb80a fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A V4 step's input side is now the currency its SETTLE or SETTLE_ALL
names, or else the one currency that some swap action spends and none
buys; its output side is the currency TAKE or TAKE_ALL names, or else
the one that some swap action buys and none spends. Amount and Min.
received come from the actions that spend the input and buy the output,
summed when several exact-in actions each state one. A step with an
unread swap action is named only by SETTLE, SETTLE_ALL, TAKE and
TAKE_ALL, and shows no figure. Before, the first swap action set both
sides, SETTLE_ALL and TAKE_ALL were not read, and a SWAP_EXACT_OUT was
read as spending its path's last currency.

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

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

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

Model: opus-5-5
2026-10-18 23:59:24 +02:00
13 changed files with 1132 additions and 243 deletions
+51 -25
View File
@@ -571,9 +571,12 @@ the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is
connected and the RPC endpoint set through the extension's own screens, against
the loopback dApp page and stub node the Firefox suite also uses
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors.
connected, without "Remember my choice for this site", and the RPC endpoint set
through the extension's own screens, against the loopback dApp page and stub
node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
stopped, the run requires the site still to be told the address
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
above, it does not fail on page errors.
### Firefox (`make test-e2e-firefox`)
@@ -857,7 +860,11 @@ on the next event. Two consequences shape every recurring job in the background:
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage. `localStorage` does not exist in a
service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such.
runs only in the popup and is marked as such. The sites allowed without
"Remember my choice for this site" are kept in the browser's session storage
(`storage.session`), which outlives the worker but not the browser, so such a
connection lasts until it is ended or the browser closes. Firefox has session
storage from version 115, the minimum `manifest/firefox.json` declares.
Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the
@@ -1164,8 +1171,8 @@ read:
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
`amountInMax`, and a V4 swap step the `amountIn` of the swap action that
spends its input currency, as described below.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1186,17 +1193,35 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
the transaction could not be read (see Transaction Decoding below). A V4 step's
sides follow three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
currency that some swap action buys and none spends. If there is not exactly
one such currency, that side is unknown. A step that buys WETH with USDT and
USDC with that WETH, with nothing naming its sides, reads `Swap USDT → USDC`.
2. `Amount` is the `amountIn` of the swap action that spends the input currency.
If several exact-in actions spend it, each with a stated amount, it is their
sum; in any other case there is no `Amount` line. The decoder reads no amount
from an exact-out action, and an open delta is not a stated amount.
`Min. received` is found the same way, from the `amountOutMin` of the actions
that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and there is no `Amount`
or `Min. received` line. Such a step still sets each side as a swap step
does, and no other step's token or figure is shown for a side they did not
name.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1936,7 +1961,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
holds these, in session storage, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the origins remembered as denied, under any address, with
remove buttons
@@ -1955,9 +1980,10 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The
same host under another scheme or port is another site and is left alone.
Only the extension's own pages may send either message
`accountsChanged` with an empty list to the open tabs of that origin.
Settings waits for the background's answer before it draws the lists
again. The same host under another scheme or port is another site and is
left alone. Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
@@ -2018,8 +2044,8 @@ view would leave a wallet one click from deletion.
(`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops
on `AUTISTMASK_ADDRESSES_REMOVED`
without "Remember", which only the background holds, in session storage,
and drops on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
@@ -2132,8 +2158,8 @@ view would leave a wallet one click from deletion.
one in this wallet follows the splice.
- The address loses its site permissions of both kinds, whether or not it was
the active one: the remembered ones in storage, and any connection approved
without "Remember", which only the background holds, in memory, and drops on
`AUTISTMASK_ADDRESSES_REMOVED`.
without "Remember", which only the background holds, in session storage, and
drops on `AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
+2 -1
View File
@@ -27,7 +27,8 @@
"web_accessible_resources": ["src/content/inpage.js"],
"browser_specific_settings": {
"gecko": {
"id": "autistmask@sneak.berlin"
"id": "autistmask@sneak.berlin",
"strict_min_version": "115.0"
}
}
}
+78 -40
View File
@@ -49,6 +49,9 @@ const {
actionApi,
getPlatformInfo,
runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -61,12 +64,31 @@ const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
// Connections the user allowed without "Remember my choice for this site".
// Each is one item in the browser's session storage, key
// origin + ":" + address and value true, and session storage holds nothing
// else. Session storage outlives this worker being stopped when idle and
// started again, so such a connection lasts until it is ended below or the
// browser closes. One item per connection means that making one is a single
// write, and two never overwrite each other.
//
// A site is its full origin (scheme://host[:port]), here and in the
// remembered allowedSites/deniedSites lists alike: a grant to
// https://dapp.example says nothing about http://dapp.example or another port.
const connectedSites = {};
// An origin can carry a port, so the address is what follows the key's last
// colon and the origin is what precedes it.
async function isConnected(origin, address) {
const key = origin + ":" + address;
const items = await sessionStorageGet(key);
return items[key] === true;
}
// End every connection allowed without "Remember" whose key `ends` returns
// true for.
async function endConnections(ends) {
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
if (keys.length > 0) await sessionStorageRemove(keys);
}
// Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {};
@@ -764,10 +786,10 @@ async function handleConnectionRequest(origin) {
};
}
// Check allowed list or in-memory connected
// Check allowed list or a connection allowed without "Remember"
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -804,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin);
} else {
connectedSites[origin + ":" + activeAddress] = true;
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
}
return { result: [activeAddress] };
} else {
@@ -858,7 +880,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -894,7 +916,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -987,10 +1009,10 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
const hasPermission =
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
(await isConnected(origin, activeAddress));
if (!hasPermission || !activeAddress) {
return { result: [] };
}
return {
@@ -1017,7 +1039,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1071,7 +1093,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1142,7 +1164,7 @@ async function handleSendTransaction(params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1254,10 +1276,6 @@ async function broadcastChainChanged(chainId) {
// Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// Reject and close any pending approval popups so they don't hang. An
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
@@ -1276,6 +1294,10 @@ async function broadcastAccountsChanged() {
}
}
resetPopupUrl();
// An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives.
await endConnections(() => true);
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
@@ -1290,7 +1312,7 @@ async function broadcastAccountsChanged() {
const hasPermission =
activeAddress &&
(allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]);
(await isConnected(origin, activeAddress)));
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
@@ -1917,41 +1939,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// The popup removed these addresses, so no site stays connected to them.
// A connectedSites key is origin + ":" + address, and an origin can carry
// a port, so the address is what follows the last colon.
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
for (const key of Object.keys(connectedSites)) {
endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) {
delete connectedSites[key];
}
}
return removed.some((a) => sameAddress(a, address));
}).catch((err) => {
log.errorf(
"ending the removed addresses' connections failed:",
err,
);
});
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
// Settings lists the sites connected without "Remember", which only
// session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
);
return false;
sessionStorageGet(null)
.then((items) =>
Object.keys(items).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
)
.catch((err) => {
log.errorf("listing the connected sites failed:", err);
return [];
})
.then(sendResponse);
return true;
}
// Settings removed this site (msg.origin) and has already dropped its
// remembered entries. Its connections approved without "Remember" end
// here, under every address, and its open tabs are told it has no account.
// The answer goes back once they have ended, and Settings waits for it
// before it lists the connected sites again.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.origin);
return false;
endConnections(
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
)
.catch((err) => {
log.errorf(
"ending the removed site's connections failed:",
err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
sendResponse(null);
});
return true;
}
// Settings switched the network and has saved it. Open tabs are told the
+6 -4
View File
@@ -76,11 +76,13 @@ function forgetOrigin(siteMap, origin) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
// connections approved without "Remember" and tells its open tabs. The
// background answers once those have ended, so the lists drawn after it no
// longer show the site.
async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
@@ -230,8 +232,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
// Sites allowed without "Remember" are held only in session storage, which
// the background keeps, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
+56 -5
View File
@@ -174,9 +174,9 @@ function notify(message) {
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
function storageUnavailable(area, method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
new Error("extension storage." + area + " is not available: " + method),
);
}
@@ -187,7 +187,7 @@ function storageUnavailable(method) {
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys));
}
@@ -198,7 +198,7 @@ function storageGet(keys) {
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items));
}
@@ -215,7 +215,55 @@ function storageSet(items) {
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
if (!storage) return storageUnavailable("local", "remove");
return Promise.resolve(storage.remove(keys));
}
// `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background
// scheduling). The background keeps the connections allowed without
// "Remember" there; see src/background/index.js. Firefox has it from version
// 115, the minimum manifest/firefox.json declares.
//
// The three below reject where it is absent, as the three above do where
// `storage.local` is: a write that silently did nothing would tell a site it
// is connected and then refuse its next request.
function storageSession() {
const storage = namespaceMember("storage");
return (storage && storage.session) || null;
}
/**
* @param {string|string[]|null} keys null for every item.
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageGet(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageSet(items) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "set");
return Promise.resolve(storage.set(items));
}
/**
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageRemove(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "remove");
return Promise.resolve(storage.remove(keys));
}
@@ -307,6 +355,9 @@ module.exports = {
notify,
runtimeApi,
sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet,
storageLocal,
storageRemove,
+188 -117
View File
@@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -318,10 +320,126 @@ function decodeExactly(types, data) {
return decoded;
}
// Decode one V4 swap action's parameters into the currency it spends
// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action,
// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts.
// A field the action does not state is null. Throws when the parameters are
// not in the deployed shape (see decodeExactly()).
function decodeV4SwapAction(actionId, param) {
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
const path = s[0][1];
return {
tokenIn: s[0][0],
tokenOut: path.length > 0 ? path[path.length - 1][0] : null,
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_IN_SINGLE) {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_OUT) {
// ExactOutputParams: (address currencyOut,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountOut, uint128 amountInMaximum)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
// The router works through the path from its last entry back to its
// first, starting from currencyOut, so the first entry is what the
// action spends.
const path = s[0][1];
return {
tokenIn: path.length > 0 ? path[0][0] : null,
tokenOut: s[0][0],
amountIn: null,
amountOutMin: null,
};
}
// V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above,
// bool zeroForOne, uint128 amountOut, uint128 amountInMaximum,
// bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: null,
amountOutMin: null,
};
}
// The currencies in `list` that `other` does not hold, each listed once.
function currenciesNotIn(list, other) {
return [...new Set(list.filter((c) => !other.includes(c)))];
}
// The amount that the swap actions on one side of a V4 step state together:
// the one action's own amount, or the sum when there are several and each
// states a quantity. Exact-out actions state none here, and the open delta is
// not a quantity. Otherwise null.
function totalAmount(amounts) {
if (amounts.length === 1) return amounts[0];
if (amounts.length === 0) return null;
if (!amounts.every((a) => typeof a === "bigint")) return null;
return amounts.reduce((sum, a) => sum + a, 0n);
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
// The input is ABI-encoded as (bytes actions, bytes[] params). Each swap
// action gives the currency it spends and the currency it buys (see
// decodeV4SwapAction()). The step's sides then follow three rules:
//
// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one
// currency that some swap action spends and none buys. Token Out is the
// currency TAKE or TAKE_ALL names; otherwise the one currency that some
// swap action buys and none spends. If there is not exactly one such
// currency, that side is unknown.
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
//
// A currency a swap action does not name, such as what an exact-in action
// with an empty path buys, is null and counts in rules 1 and 2 as a currency
// like any other: a step of only that action has an unknown Token Out and
// the action's amountOutMin as Min. received.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +448,12 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE
// and TAKE may still name both currencies.
// (see decodeExactly()), so nothing was read from that action. SETTLE,
// SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) {
@@ -347,6 +466,10 @@ function decodeV4Swap(input) {
params[i],
);
settleToken = s[0];
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -354,107 +477,20 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
swapUndecoded = true;
}
} else {
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
swapUndecoded = true;
}
try {
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
swapUndecoded = true;
}
}
} catch {
@@ -462,13 +498,47 @@ function decodeV4Swap(input) {
}
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn,
amountOutMin,
swapUndecoded,
};
// Rule 3.
if (swapUndecoded) {
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
swapUndecoded,
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
let tokenIn = null;
let amountIn = null;
const spentOnly = currenciesNotIn(spent, bought);
if (present(settleToken) || spentOnly.length === 1) {
tokenIn = present(settleToken) ? settleToken : spentOnly[0];
amountIn = totalAmount(
swaps
.filter((s) => s.tokenIn === tokenIn)
.map((s) => s.amountIn),
);
}
// Rules 1 and 2 for Token Out.
let tokenOut = null;
let amountOutMin = null;
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
return { tokenIn, tokenOut, amountIn, amountOutMin, swapUndecoded };
} catch {
return null;
}
@@ -597,11 +667,12 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies
// and amounts, so the step is marked and still sets
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
// A swap action of this step went unread, so the step
// is marked and still sets both sides as a swap step
// does, with no figure. A side its SETTLE, SETTLE_ALL,
// TAKE or TAKE_ALL did not name is set undetermined,
// not left to an earlier or later step's token and
// figure.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
if (!inputEstablished) {
+1 -1
View File
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
// Forget every site permission held against the given addresses: the
// remembered ones in `state`, and the connections approved without
// "Remember", which only the background holds, in memory.
// "Remember", which only the background holds, in session storage.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
+8 -8
View File
@@ -321,9 +321,10 @@ function loadBackground(options) {
require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse.
function send(msg, sender) {
const sendResponse = jest.fn();
// handler passed to sendResponse. `onResponse`, when given, receives the
// answer when the handler sends it, as the sender's callback does.
function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept };
}
@@ -2615,9 +2616,9 @@ async function siteAccounts(bg, origin) {
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself.
// A site connected without "Remember" is held only in the background's session
// storage, keyed to the address it was connected to. Removing that address, or
// the wallet holding it, must end the connection as part of the removal itself.
// The accountsChanged broadcast the views send afterwards also clears it, but
// only when the active address moved, and nothing waits for it to arrive.
//
@@ -2796,8 +2797,7 @@ describe("removing a site in Settings disconnects it", () => {
const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
bg.send(msg, bg.fromPopup, callback);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
+354
View File
@@ -0,0 +1,354 @@
// A site allowed without "Remember my choice for this site" stays connected
// when the background worker is stopped and started again
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// Chrome stops the worker after about 30 seconds idle and evaluates the
// module afresh on the next event, so nothing held at module scope survives
// it. Such a connection used to be held exactly there, and so lasted until the
// first idle spell. Each test here makes the connection in one load of the
// background, loads the module afresh over the same storage, as the browser
// does, and asks the new one. The paths that end a connection are driven after
// that restart, so each is shown to end what the earlier worker stored.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const SITE = "https://dapp.example";
const OTHER_SITE = "https://other.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One wallet with two addresses, so that one of them can be removed, and no
// remembered sites, so that every connection here is one made without
// "Remember".
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: {},
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
// Load the background module afresh, as the browser does when it starts the
// worker, over `storage`. The storage stub stands in for the browser's own
// storage, local and session, so it outlives each load.
function startWorker(storage) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
let connectListener = null;
// Every URL the toolbar popup was set to open: a site's connection prompt
// is shown there, so this is where its approval id appears.
const toolbarPopups = [];
const windowsCreated = [];
const sentToTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
getPlatformInfo: (cb) => cb({ os: "linux" }),
// What the popup sends reaches the worker from the extension's own
// page.
sendMessage: (msg, callback) => {
messageListener(msg, FROM_POPUP, callback || (() => {}));
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
windowsCreated.push(options);
cb({ id: windowsCreated.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: {
setPopup: (o) => {
toolbarPopups.push(o.popup);
},
openPopup: () => Promise.resolve(),
},
};
require("../src/background/index");
// A request from a site, answered once the worker has had time to answer.
async function rpc(method, origin, params) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin },
(r) => {
response = r;
},
);
await settle();
return response;
}
// A message from the popup, and the worker's answer to it.
async function fromPopup(msg) {
let response;
messageListener(msg, FROM_POPUP, (r) => {
response = r;
});
await settle();
return response;
}
// The site asks to connect, and the user allows it in the prompt with
// "Remember my choice for this site" left unchecked.
async function connectWithoutRemember(origin) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
{ origin },
(r) => {
response = r;
},
);
await settle();
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
"approval",
);
const decisionListeners = [];
const port = {
name: "approval:" + id,
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
onDisconnect: { addListener: () => {} },
};
connectListener(port);
for (const fn of decisionListeners) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved: true,
remember: false,
},
port,
);
}
await settle();
return response;
}
return {
rpc,
fromPopup,
connectWithoutRemember,
windowsCreated,
sentToTabs,
};
}
// A site connected without "Remember" in one worker, and the worker started
// again over the same storage, where the site is still connected.
async function connectedThenRestarted() {
const storage = makeStorageStub({ autistmask: storedProfile() });
const first = startWorker(storage);
expect(await first.connectWithoutRemember(SITE)).toEqual({
result: [ADDRESS],
});
const worker = startWorker(storage);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
return { storage, worker };
}
describe("a site allowed without Remember, after the worker restarts", () => {
test("is still told the address, and is not asked again", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
result: [ADDRESS],
});
});
test("is still listed under Connected Sites in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([SITE]);
});
test("may still ask for a signature", async () => {
// Refused with 4100 before the fix. Allowed, it raises the signing
// prompt and waits on the user.
const { worker } = await connectedThenRestarted();
const response = await worker.rpc("personal_sign", SITE, [
"0x48656c6c6f",
ADDRESS,
]);
expect(response).toBeNull();
expect(worker.windowsCreated).toHaveLength(1);
});
test("is not connected after the browser restarts", async () => {
// The browser empties session storage when it closes; the wallet's
// own storage keeps nothing of the connection.
const { storage } = await connectedThenRestarted();
const afterBrowserRestart = startWorker(
makeStorageStub({ autistmask: storage.read("autistmask") }),
);
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
result: [],
});
});
});
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
test("removing the site in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
result: [ADDRESS],
});
await worker.fromPopup({
type: "AUTISTMASK_REMOVE_SITE",
origin: SITE,
});
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(worker.sentToTabs).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([OTHER_SITE]);
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
result: [ADDRESS],
});
});
test("switching to another address", async () => {
// Switched away and back again, so the answer turns on the connection
// having ended rather than on which address is active.
const { storage, worker } = await connectedThenRestarted();
const switched = { ...storage.read("autistmask") };
switched.activeAddress = OTHER_ADDRESS;
storage.write("autistmask", switched);
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
switched.activeAddress = ADDRESS;
storage.write("autistmask", switched);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([]);
});
// The two below ask while storage still names the removed address as
// active, because the popup has not saved yet, so the answer turns on the
// connection alone.
test("removing the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing the wallet holding the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
removeWalletFromState(storage.read("autistmask"), 0);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing a different address leaves it connected", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
});
});
+2 -4
View File
@@ -3861,10 +3861,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
// Remembered, because the sign and transaction tests below all
// require the origin to still be authorized.
await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve");
+22 -3
View File
@@ -22,6 +22,11 @@
// Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass.
//
// The site is connected without "Remember", and once the worker has stopped
// the program requires the site still to be told the address: such a
// connection used to be held in the worker's memory and ended with it
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// The dApp page and the stub node are the loopback ones the Firefox suite also
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage
@@ -311,8 +316,8 @@ async function main() {
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so
// the connection is stored rather than held by the worker.
// The site, connected through the real prompt without "Remember", so
// the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
@@ -329,7 +334,7 @@ async function main() {
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = true",
"document.getElementById('approve-remember').checked = false",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
@@ -365,6 +370,20 @@ async function main() {
"s",
);
// The site's request starts the worker again, and the new worker still
// has the connection allowed without "Remember"
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
const stillConnected = await dapp.evaluate(
"window.ethereum.request({ method: 'eth_accounts' })",
);
check(
"a site allowed without Remember is still connected after the worker stopped",
Array.isArray(stillConnected) &&
stillConnected.length === 1 &&
getAddress(stillConnected[0]) === address,
"eth_accounts answered " + JSON.stringify(stillConnected),
);
// A transaction approval and a signature approval, both open at once.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
+23 -15
View File
@@ -1,4 +1,4 @@
// A chrome.storage.local stub that behaves like the real one.
// A chrome.storage stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
@@ -17,13 +17,8 @@
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return { get, set, remove };
}
// `initial` is the starting contents of `storage.local`, keyed as storage is:
// { autistmask: {...} }. `storage.session` starts empty.
// `onOp` runs before each operation on either area, for a test that needs to
// advance a clock or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
local,
session: makeArea({}, tick),
get: local.get,
set: local.set,
remove: local.remove,
// What is stored in `storage.local`, cloned on the way out: a test can
// neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
+341 -20
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
@@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -358,10 +388,9 @@ describe("uniswap decoder", () => {
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded",
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => {
const data = buildExecute(
"0x10",
@@ -381,6 +410,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
@@ -394,7 +444,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output",
"a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +453,7 @@ describe("uniswap decoder", () => {
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
],
9999999999n,
);
@@ -423,15 +470,12 @@ describe("uniswap decoder", () => {
// The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input",
"a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
@@ -458,10 +502,7 @@ describe("uniswap decoder", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
@@ -1124,6 +1165,286 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...params],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
1000000000n,
],
],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and