2 Commits
Author SHA1 Message Date
sneak 1effa7c6eb fix: a network switch in Settings leaves no balance from the previous network stored (closes #653)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The switch is now made on the stored record that its save has just read. The switched fields are then copied into the page's state. A save writes only the fields that the page changed since its last save. A switch made on the page's state alone mishandled what another page stored since then:

- A balance from the previous network stayed under the new network.
- A token tracked on the previous network moved to the new network.
- An endpoint URL saved for the previous network was replaced by the page's older one.

Tests store each of these before the switch and check the stored record after it.

Model: opus-5-5
2026-10-27 07:22:50 +00:00
clawbot fc6cffcaba fix: a Permit2 transfer of the zero address never reads as ETH (closes #685)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A PERMIT2_TRANSFER_FROM of the zero address read Token moved: ETH, with an ETH figure, though Permit2 moves no ETH. Such a move now names the zero address as its token on Token moved and Token moved into the router, and gives its amount in base units on Moved from the sender and Paid to. The zero-address check from the permit lines is now one function, permit2TokenInfo(), shared by the move lines. A V4 step of ETH that states no amount still gets its own lines beside such a move, so no line about ETH leaving is dropped.

Model: opus-5-5
2026-10-27 07:53:05 +01:00
9 changed files with 240 additions and 59 deletions
+8 -4
View File
@@ -3566,10 +3566,14 @@ Currently supported:
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and `Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose where it goes, and so its amount is shown once. For a move of the zero
amount does not stand as the `Amount` figure, before the step that sets the address, `Moved from the sender` gives the amount in base units and
input side or after it, gives three lines, in step order, after those for each `Token moved` gives the zero address. For such a move into the router, the
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example `Paid to` line gives the amount in base units and
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
whose amount does not stand as the `Amount` figure, before the step that sets
the input side or after it, gives three lines, in step order, after those for
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose `Allowance spender`, the address it lets take the token. Only a permit whose
+21 -5
View File
@@ -2,8 +2,10 @@
// //
// A network switch reaches the popup's `state` in one of two ways: // A network switch reaches the popup's `state` in one of two ways:
// //
// - onChainSwitch() below makes the switch the user picks in Settings, with // - onChainSwitch() below makes the switch the user picks in Settings. It
// applyChainSwitchFields() (chainSwitchFields.js). // makes the switch on the stored record with applyChainSwitchFields(), and
// copies the switched fields into `state` with copyChainSwitchFields()
// (chainSwitchFields.js).
// - followStoredSettings() in src/popup/index.js takes a switch the background // - followStoredSettings() 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 // takeStoredNetwork() (state.js), which copies it with
@@ -27,7 +29,10 @@
// against storage, and the singleton is not reachable from the background // against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js). // bundle at all (enforced by the ESLint rule in eslint.config.js).
const { applyChainSwitchFields } = require("./chainSwitchFields"); const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific // The popup's own caches a network switch resets. Prices are chain-specific
@@ -47,12 +52,23 @@ function clearPopupCaches() {
// lock, so it is never switched while a follow of a switch saved elsewhere // lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that // waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it. // follow runs first, and this switch is made after it.
//
// The switch is made on the stored record that the save has just read, as the
// background makes its own switch. The switched fields are then copied from
// that record into `state`. So the switch also covers what another page stored
// since this page's last save:
//
// - A token tracked on the previous network stays tracked on that network.
// - An endpoint URL saved for the previous network stays with that network.
// - A balance from the previous network is cleared
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null; let net = null;
await saveState(() => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(stored, newNetworkId);
copyChainSwitchFields(state, stored);
clearPopupCaches(); clearPopupCaches();
}); });
return net; return net;
+4 -3
View File
@@ -3,9 +3,10 @@
// //
// Split out of chainSwitch.js so the background can perform a chain switch // Split out of chainSwitch.js so the background can perform a chain switch
// without the singleton being reachable from its bundle at all. The popup // without the singleton being reachable from its bundle at all. The popup
// still goes through onChainSwitch() (chainSwitch.js), which applies this to // still goes through onChainSwitch() (chainSwitch.js). That function applies
// the singleton and saves; the background applies it to the detached record of // this to the stored record that the switch's save reads, and copies the
// its own read-modify-write (src/background/state.js). // result into the singleton. The background applies it to the detached record
// of its own read-modify-write (src/background/state.js).
// //
// Everything here is synchronous and touches nothing but the objects it is // Everything here is synchronous and touches nothing but the objects it is
// given: no storage, no caches, no imports beyond the network table. That is // given: no storage, no caches, no imports beyond the network table. That is
+4 -2
View File
@@ -591,8 +591,10 @@ function reportSaveFailure(err) {
// //
// `beforeWrite`, when given, is called inside the lock with the stored record // `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change // as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is // `state`, and this save writes that change. It may also change the stored
// written; any other return lets the save go ahead. // record: a field that this page did not change is written as `beforeWrite`
// left it. When it returns false, nothing is written; any other return lets the
// save go ahead.
function saveState(beforeWrite) { function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite)); const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this // Every failed save is reported, whether or not the caller awaited this
+36 -45
View File
@@ -479,6 +479,17 @@ function tokenInfo(address, sources) {
}; };
} }
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
// The zero address there reads as a token nothing knows: its amount in base
// units, and the zero address as its token.
function permit2TokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address };
}
return tokenInfo(address, sources);
}
// A swap amount line. With a scale it is the token quantity; with none it is // A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the // the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display` // ERC-20 amount line makes, so the screen has one way of saying it. `display`
@@ -497,16 +508,18 @@ function amountText(raw, info) {
// The line giving a moved token, or the token of a permit, under `label`, in // The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that // the shape of the Token In line, so the screens show it as they show that
// one. ETH has no address. // one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
function tokenMovedLine(label, token, sources) { // ETH has no address.
const info = tokenInfo(token, sources); function tokenMovedLine(label, info) {
if (!present(info.address)) { if (!present(info.address)) {
return { label, value: info.symbol }; return { label, value: info.symbol };
} }
return { return {
label, label,
value: info.symbol ? info.symbol + " (" + token + ")" : token, value: info.symbol
address: token, ? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
isToken: true, isToken: true,
}; };
} }
@@ -2329,7 +2342,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources)); details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({ details.push({
label: "Moved to", label: "Moved to",
value: "The swap step that spends it", value: "The swap step that spends it",
@@ -2340,12 +2355,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own. // where it goes, an address on a line of its own.
for (const m of movedFromSender) { for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources)) value: amountText(m.amount, info).display,
.display,
}); });
details.push(tokenMovedLine("Token moved", m.token, sources)); details.push(tokenMovedLine("Token moved", info));
details.push( details.push(
present(m.to) present(m.to)
? { label: "Moved to", value: m.to } ? { label: "Moved to", value: m.to }
@@ -2375,36 +2390,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
// Permit2 has no allowance of ETH. An allowance of the zero const info = permit2TokenInfo(p.token, sources);
// address reads as one of a token nothing knows: its amount in details.push({
// base units, and the zero address as its token. label: "Permit2 allowance",
if (p.token === "0x0000000000000000000000000000000000000000") { value:
details.push({ p.amount >= MAX_UINT160
label: "Permit2 allowance", ? "Unlimited"
value: : amountText(p.amount, info).display,
p.amount >= MAX_UINT160 });
? "Unlimited" details.push(tokenMovedLine("Allowance token", info));
: unknownDecimalsAmount(p.amount),
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
@@ -2462,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
for (const m of movedIntoRouter) { for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources); const info = permit2TokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
label: "Paid to", label: "Paid to",
value: value:
@@ -2470,9 +2464,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " from the sender (later steps may not spend all of it)",
}); });
paidTo.push( paidTo.push(tokenMovedLine("Token moved into the router", info));
tokenMovedLine("Token moved into the router", m.token, sources),
);
} }
if (outputHeld) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2489,8 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push( paidTo.push(
tokenMovedLine( tokenMovedLine(
"Token moved into the router", "Token moved into the router",
getAddress(token), tokenInfo(getAddress(token), sources),
sources,
), ),
); );
} }
+50
View File
@@ -6,6 +6,9 @@
// approved a site's request, which changes only the stored record. Driven // approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same // through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js. // refresh in the background is in tests/backgroundStateIsolation.test.js.
//
// The last test covers a refresh by the background that is saved just before
// the switch in Settings (https://git.eeqj.de/sneak/AutistMask/issues/653).
const { const {
bootPopup, bootPopup,
@@ -159,3 +162,50 @@ test("a refresh that returns after the background switched the network writes no
expectNothingStored(page); expectNothingStored(page);
}); });
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// tokens, the same values the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
await page.click("btn-settings");
// The background takes the lock first and holds it until the test calls
// `letGo()`. It then saves balances as backgroundRefresh() in
// src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
letGo = resolve;
});
fresh.wallets[0].addresses[0].balance = "7.0";
fresh.wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
fresh.lastBalanceRefresh = Date.now();
});
await page.settle();
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
letGo();
await refreshSave;
await switching;
await page.settle();
expectNothingStored(page);
});
+22
View File
@@ -47,6 +47,7 @@ function loadModuleWith(persisted) {
return { return {
mod: require("../src/shared/state"), mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"), chainSwitch: require("../src/shared/chainSwitch"),
storage,
written: () => storage.read("autistmask"), written: () => storage.read("autistmask"),
}; };
} }
@@ -105,6 +106,27 @@ describe("a custom endpoint survives a chain switch", () => {
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
}); });
// Another page saves the user's own node for Ethereum Mainnet after this
// page's last save. This page's switch keeps that node for Ethereum
// Mainnet.
test("an endpoint another page saved for the network being left is remembered", async () => {
const { mod, chainSwitch, storage, written } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: MAINNET.defaultRpcUrl,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
});
await mod.loadState();
storage.write("autistmask", { ...written(), rpcUrl: CUSTOM_RPC });
await chainSwitch.onChainSwitch("sepolia");
expect(written().networkEndpoints.mainnet.rpcUrl).toBe(CUSTOM_RPC);
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
});
test("the remembered endpoints survive an extension restart", async () => { test("the remembered endpoints survive an extension restart", async () => {
const first = loadModuleWith({ const first = loadModuleWith({
wallets: walletFixture(), wallets: walletFixture(),
+19
View File
@@ -177,6 +177,25 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI"); expect(onSepolia).not.toContain("DAI");
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. This page's switch in Settings keeps DAI, tracked on Ethereum Mainnet.
test("a token another page tracked stays on its network through a switch in Settings", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
describe("Back after a switch from the Settings gear", () => { describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => { test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet"); const boot = await bootOnAddToken("mainnet");
+76
View File
@@ -3440,6 +3440,82 @@ describe("uniswap decoder", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE // https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
// of ETH from the router's own ETH, whichever payer the SETTLE names, // of ETH from the router's own ETH, whichever payer the SETTLE names,
// and the router holds the transaction's value. On next at 36b2002 a // and the router holds the transaction's value. On next at 36b2002 a