Compare commits
2
Commits
5575e8cfc5
...
a7af9cb80a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7af9cb80a | ||
|
|
8b0a1bff02 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,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', [" +
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user