1 Commits
Author SHA1 Message Date
sneak 482cd42ebb fix: an open popup follows a network switch the background saves (closes #650)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the background saves a network switch after the user approves a
site's request, or another popup page saves one, an open popup takes the
new network from the stored record inside a save, holding the lock, and
checks the record once at start-up for a switch saved before it
listened. It then leaves the page as a switch in Settings does: the cut
and the redraw are two Settings functions both paths call, and both
reset the popup's caches through one function. A switch in Settings is
now made inside its own save too, so one picked while a follow waits for
the lock is made after it. The popup never switches the network itself.

Model: opus-5-5
2026-10-21 00:26:46 +00:00
8 changed files with 52 additions and 205 deletions

No files matched your search

+30 -35
View File
@@ -3238,41 +3238,36 @@ Currently supported:
which it does only in the plain shape of Uniswap's own routes: it comes before which it does only in the plain shape of Uniswap's own routes: it comes before
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
the router may not let it fail, no step before it takes its token, a the router may not let it fail, no step before it takes its token or a token
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, the decoder does not know, and the decoder reads every step after it in full.
and the decoder reads every step after it in full. Permit2 then lets the Permit2 then lets the router take no more of the token than that amount, so
router take no more of the token than that amount, so the permit covers every the permit covers every take of it. A later permit or batch may raise what
take of it. A step before the permit takes under whatever allowance the router Permit2 lets the router take, and so may a step the decoder does not read in
already had, so it may take more than the permit's amount, which would then full: a command the decoder does not read (listed at the end of this section),
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of a step whose input it cannot read, in whole or in part, and a step it fails
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 on. Every other `PERMIT2_PERMIT`, before the step that sets the input side or
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A after it, leaves `Token In` and the `Amount` line as they read with no permit:
later permit or batch may raise what Permit2 lets the router take, and so may a permit of 5,000 DAI to another address and then a V3 exact-in of 600 USDT
a step the decoder does not read in full: a command the decoder does not read read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do that exact-in
(listed at the end of this section), a step whose input it cannot read, in and then a permit of 5,000 DAI to the router. When a permit's amount stands
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before and the step that sets the input side spends a token other than the permit's,
the step that sets the input side or after it, leaves `Token In` and the or one the decoder does not know, that step's amount gets lines of its own,
`Amount` line as they read with no permit: a permit of 5,000 DAI to another unless it states no amount and the user pays it none of its input token. Each
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with take the total does not count gives three lines after the `Amount` line (after
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI `Token In` when there is none): one of a token other than `Token In`; one of
to the router. When a permit's amount stands and the step that sets the input `Token In` after an amount that is not a quantity (a V4 open delta,
side spends a token other than the permit's, or one the decoder does not know, `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
that step's amount gets lines of its own, unless it states no amount and the amount, as of a V4 exact-out step); and one whose amount the decoder does not
user pays it none of its input token. Each take the total does not count gives know. When the step that sets the input side states no amount, the user pays
three lines after the `Amount` line (after `Token In` when there is none): one it some of its input token, and there is no `Amount` line, that step gets them
of a token other than `Token In`; one of `Token In` after an amount that is too whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM`
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on of its token gives one, on the lines below or, into the router, on its
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one `Paid to` line, so that figure cannot read as all that leaves. When it states
whose amount the decoder does not know. When the step that sets the input side no amount and the user pays it none of its input token, as when the router
states no amount, the user pays it some of its input token, and there is no pays it, that step takes none of that token from the user's wallet and never
`Amount` line, that step gets them too whenever another take's lines give a gets them, with a permit or without. They come in step order, the step that
figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines sets the input side first when it has them: `Moved from the sender`, its
below or, into the router, on its `Paid to` line, so that figure cannot read amount, worded as the `Amount` line words one, or
as all that leaves. When it states no amount and the user pays it none of its
input token, as when the router pays it, that step takes none of that token
from the user's wallet and never gets them, with a permit or without. They
come in step order, the step that sets the input side first when it has them:
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. A token the decoder
+2 -2
View File
@@ -5,13 +5,13 @@ const {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { copyChainSwitchFields } = require("../shared/chainSwitchFields");
const { clearPopupCaches } = require("../shared/chainSwitch"); const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
@@ -234,7 +234,7 @@ async function followNetworkSwitch() {
await saveState((stored) => { await saveState((stored) => {
if (stored.networkId === state.networkId) return false; if (stored.networkId === state.networkId) return false;
settings.cutViewStack(); settings.cutViewStack();
takeStoredNetwork(stored); copyChainSwitchFields(state, stored);
clearPopupCaches(); clearPopupCaches();
switched = true; switched = true;
}).catch(() => {}); }).catch(() => {});
-1
View File
@@ -6,7 +6,6 @@
// applyChainSwitchFields() (chainSwitchFields.js). // applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background // - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with // or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js). // copyChainSwitchFields() (chainSwitchFields.js).
// //
// Both change `state` inside a save, holding the lock, and both reset the // Both change `state` inside a save, holding the lock, and both reset the
+2 -3
View File
@@ -78,9 +78,8 @@ function applyChainSwitchFields(s, newNetworkId) {
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it: // Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
// the popup takes a switch the background saved from the stored record this // the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in // way, rather than performing it again (src/popup/index.js). Balances are
// src/shared/state.js). Balances are taken by address, and an address // taken by address, and an address `switched` does not have keeps none.
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) { function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints; s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens; s.networkTrackedTokens = switched.networkTrackedTokens;
+2 -17
View File
@@ -23,7 +23,6 @@
// guarantee. // guarantee.
const { networkById } = require("./networks"); const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const { const {
DEFAULT_STATE, DEFAULT_STATE,
PERSISTED_FIELDS, PERSISTED_FIELDS,
@@ -126,10 +125,8 @@ function currentNetwork() {
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began. saveState() diffs
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // the live state against this to find only the fields THIS page changed since.
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -602,17 +599,6 @@ function saveState(beforeWrite) {
return turn; return turn;
} }
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -658,7 +644,6 @@ module.exports = {
state, state,
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+16 -20
View File
@@ -1524,16 +1524,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// side never stands, so only one before it is kept here. // side never stands, so only one before it is kept here.
if (p && !inputEstablished) { if (p && !inputEstablished) {
// Whether a step before it took its token, or may // Whether a step before it took its token, or may
// have: one in `userPaid`, `movedIntoRouter` or // have: one in `userPaid`, when its token is the
// `movedFromSender`, when its token is the permit's // permit's or not known. No step has set the input
// or not known. No step has set the input side yet. // side yet.
const mayBeItsToken = (token) => const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token); !present(token) || sameAddress(token, p.token);
const tookBefore = [ const tookBefore = userPaid.some((t) =>
...userPaid, mayBeItsToken(t.token),
...movedIntoRouter, );
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = { permit = {
...p, ...p,
step: i, step: i,
@@ -2011,18 +2009,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// set the input side, it is the only PERMIT2_PERMIT in the // set the input side, it is the only PERMIT2_PERMIT in the
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is // transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took // the router, the router may not let it fail, no step before it took
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token // its token or one not known, and this decoder read every step after
// included), and this decoder read every step after it in full (see // it in full (see stepsUnread). Permit2 then lets the router take no
// stepsUnread). Permit2 then lets the router take no more of the token // more of the token than that amount, so the permit covers every take
// than that amount, so the permit covers every take of it. A step // of it. A later permit or batch, or a later step this decoder did
// before it takes under whatever allowance the router already had, so // not read, may raise what Permit2 lets the router take. Every other
// it may take more. A later permit or batch, or a later step this // PERMIT2_PERMIT leaves Token In and the Amount line as they read with
// decoder did not read, may raise what Permit2 lets the router take. // no permit, and gets lines of its own below. A step that may take
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as // from the sender what this decoder did not read leaves no figure,
// they read with no permit, and gets lines of its own below. A step // permit or not (see spentUnread()), and so does a permit or batch
// that may take from the sender what this decoder did not read leaves // whose input it cannot read (see permitUnread()). An
// no figure, permit or not (see spentUnread()), and so does a permit
// or batch whose input it cannot read (see permitUnread()). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below). // EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
-33
View File
@@ -220,39 +220,6 @@ test("a switch in Settings made while a follow waits for the lock is not overwri
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId); expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
}); });
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads // Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as // or writes the stored record for it: the popup's next save finds it, as
// before. // before.
-94
View File
@@ -2196,100 +2196,6 @@ describe("uniswap decoder", () => {
}, },
); );
// https://git.eeqj.de/sneak/AutistMask/issues/655 — a
// PERMIT2_TRANSFER_FROM of the permit's token before the permit moves
// it under whatever allowance the router already had, so it may move
// more than the permit's amount. The permit's amount then does not
// stand, and the Amount line gives the total the steps take, as with
// no permit. On next at f53b503 the first two rows read the permit's
// 600.0000 USDT as the Amount figure, with no lines for the permit.
// One after the permit, as in Uniswap's own V2 routes, moves no more
// than the permit's amount, and one of another token before it moves
// none of the permit's token, so in the last two rows the permit's
// amount stands.
const USDT_2000 = 2000000000n;
// The V2 pair of WETH and USDC on Ethereum Mainnet.
const WETH_USDC_PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// A PERMIT2_TRANSFER_FROM of `amount` of `token` to `recipient`.
const permit2TransferFrom = (token, recipient, amount) =>
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT into the router, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 2,000 USDT paid from the router, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, ROUTER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_2000,
1n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "2000.0000 USDT"], ...USDT_600_LINES],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, OTHER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "2000.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", OTHER_ADDR],
...USDT_600_LINES,
],
},
{
kind: "a PERMIT2_PERMIT of 1 WETH, then a PERMIT2_TRANSFER_FROM of 1 WETH to the V2 pair and a V2 exact-in of ALREADY_PAID, shows the permit's amount",
commands: [0x0a, 0x02, 0x08],
inputs: [
encodePermit2(WETH_ADDR, ONE_WETH, ROUTER_ADDR),
permit2TransferFrom(WETH_ADDR, WETH_USDC_PAIR, ONE_WETH),
encodeV2SwapExactIn(USER_ADDR, 0n, 1990000000n, [
WETH_ADDR,
USDC_ADDR,
]),
],
lines: [
["Amount", "1.0000 WETH"],
["Moved from the sender", "1.0000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The V2 pair the next swap spends"],
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 400 DAI to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the permit's amount",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(DAI_ADDR, OTHER_ADDR, DAI_400),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. // USDT for USDC.
const v3ExactOut = (amountInMax) => const v3ExactOut = (amountInMax) =>