1 Commits
Author SHA1 Message Date
sneak 981fbdb42c 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's save now applies the switch to the stored record it has just read, as well as to the page's state. A save writes only the fields that the page changed since its last save. When the page had last saved "0" for an address, the cleared "0" was no change. A balance that the background's refresh stored in between then stayed under the new network. The background's own switch already applies the switch to the record it writes whole, so it cannot keep such a balance. A test lands the background's save while the switch waits for the lock.

Model: opus-5-5
2026-10-27 06:34:33 +00:00
8 changed files with 66 additions and 187 deletions
+4 -8
View File
@@ -3566,14 +3566,10 @@ Currently supported:
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
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. For a move of the zero
address, `Moved from the sender` gives the amount in base units and
`Token moved` gives the zero address. For such a move into the router, the
`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
where it goes, and so its amount is shown once. 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`,
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
+11 -19
View File
@@ -2,10 +2,8 @@
//
// A network switch reaches the popup's `state` in one of two ways:
//
// - onChainSwitch() below makes the switch the user picks in Settings. It
// makes the switch on the stored record with applyChainSwitchFields(), and
// copies the switched fields into `state` with copyChainSwitchFields()
// (chainSwitchFields.js).
// - onChainSwitch() below makes the switch the user picks in Settings, with
// applyChainSwitchFields() (chainSwitchFields.js).
// - followStoredSettings() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
@@ -29,10 +27,7 @@
// against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js).
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("./chainSwitchFields");
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
@@ -53,22 +48,19 @@ function clearPopupCaches() {
// waits for the lock (followStoredSettings() in src/popup/index.js): that
// 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).
// The switch is also applied to the stored record that the save has just read,
// as the background applies its own switch. A save writes only the fields that
// this page changed since its last save. A cleared balance of "0" is no change
// when this page last saved "0" too. The balance that the background stored
// since then would otherwise stay under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
let net = null;
await saveState((stored) => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(stored, newNetworkId);
copyChainSwitchFields(state, stored);
net = applyChainSwitchFields(state, newNetworkId);
applyChainSwitchFields(stored, newNetworkId);
clearPopupCaches();
});
return net;
+4 -4
View File
@@ -3,10 +3,10 @@
//
// 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
// still goes through onChainSwitch() (chainSwitch.js). That function applies
// this to the stored record that the switch's save reads, and copies the
// result into the singleton. The background applies it to the detached record
// of its own read-modify-write (src/background/state.js).
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
// the singleton and to the stored record that its save reads; 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
// given: no storage, no caches, no imports beyond the network table. That is
+45 -36
View File
@@ -479,17 +479,6 @@ 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
// 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`
@@ -508,18 +497,16 @@ function amountText(raw, info) {
// 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
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
// ETH has no address.
function tokenMovedLine(label, info) {
// one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol
? info.symbol + " (" + info.address + ")"
: info.address,
address: info.address,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
}
@@ -2342,9 +2329,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({
label: "Moved to",
value: "The swap step that spends it",
@@ -2355,12 +2340,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, info).display,
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
});
details.push(tokenMovedLine("Token moved", info));
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
@@ -2390,15 +2375,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
const info = permit2TokenInfo(p.token, sources);
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, info).display,
});
details.push(tokenMovedLine("Allowance token", info));
// Permit2 has no allowance of ETH. An allowance of the zero
// address reads as one of a token nothing knows: its amount in
// base units, and the zero address as its token.
if (p.token === "0x0000000000000000000000000000000000000000") {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: 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({
label: "Allowance spender",
value: p.spender,
@@ -2456,7 +2462,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, sources);
const info = tokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
@@ -2464,7 +2470,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(tokenMovedLine("Token moved into the router", info));
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2481,7 +2489,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
tokenInfo(getAddress(token), sources),
getAddress(token),
sources,
),
);
}
+2 -3
View File
@@ -174,9 +174,8 @@ test("a background refresh saved while a switch in Settings waits for the lock l
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.
// The background takes the lock first. Once let go, the background saves
// balances as backgroundRefresh() in src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
-22
View File
@@ -47,7 +47,6 @@ function loadModuleWith(persisted) {
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
storage,
written: () => storage.read("autistmask"),
};
}
@@ -106,27 +105,6 @@ describe("a custom endpoint survives a chain switch", () => {
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 () => {
const first = loadModuleWith({
wallets: walletFixture(),
-19
View File
@@ -177,25 +177,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
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", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
-76
View File
@@ -3440,82 +3440,6 @@ 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
// 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