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
10 changed files with 114 additions and 668 deletions
+31 -39
View File
@@ -1266,8 +1266,7 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. When earlier swap steps also pay its `amountOut`, the exact amount it buys.
that token to the user, the line shows the sum (see Transaction Decoding).
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -1278,18 +1277,22 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair, step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero. such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
a V2, V3 or V4 swap's minimum of zero. The router passes a minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a `BALANCE_CHECK_ERC20` step's `minBalance`. Before
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both read `0.0000` when the token's scale was known. The router passes a balance
lines, as a swap step does. Only a check of the transaction's sender's own check whenever the balance is at least `minBalance`, so a zero `minBalance`
balance sets either line; a check of any other address sets neither (see guarantees nothing: it sets `Token Out` and `Min. received` only when the
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail output side holds no minimum, not even a zero one, at the point the check is
without reverting the transaction (see Transaction Decoding) guarantees reached, and otherwise leaves the current token and figure in place. A nonzero
nothing either, whatever its `minBalance`, and is read as one whose `minBalance` sets both lines, as a swap step does. Only a check of the
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail transaction's sender's own balance sets either line; a check of any other
is likewise read as zero. address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each 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 reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -3394,8 +3397,7 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode, `Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. A check with a zero `minBalance` as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a `TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out paid it into ETH, that token stays in the router unless a later step pays out
@@ -3460,29 +3462,19 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. The step that sets the output side gives the minimum reads `Min. paid out`. The `Amount` line gives the total the steps
minimum. When that step and earlier swap steps each pay the output token to take of `Token In`, from the step that sets the input side on: the first V2 or
the transaction's sender, the minimum is the sum of their minimums. There is V3 exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token
no minimum when the decoder reads none from one of them, or when one of them or amount. A V4 step with a swap action sets the input side when no earlier
pays part of that token elsewhere. There is also no minimum when the decoder step set it, even when it names neither (see Display Consistency). That step
did not read one of them, or a step between them, in full. A swap step that counts its own amount, whoever pays it: a V2 or V3 exact-out step its
spends the output token drops earlier steps from the sum. A V4 step spends `amountInMax`, and a V4 step what it takes of its input token from the user's
each token that its swap actions spend. A V4 `SETTLE` or `SETTLE_ALL` of the wallet, as below, or, when the user pays it none of that token, the `amountIn`
output token also drops earlier steps. A `PERMIT2_TRANSFER_FROM` of the output of the swap action that spends it. Each later V2 or V3 exact-in or exact-out
token drops earlier steps too. A step after the step that sets the output side step whose `payerIsUser` is true takes its amount, found in the same way, from
drops nothing from the sum. The `Amount` line gives the total the steps take the user's wallet. Every V4 step, with a swap action or without, takes from
of `Token In`, from the step that sets the input side on: the first V2 or V3 the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and
exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token or its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the
amount. A V4 step with a swap action sets the input side when no earlier step
set it, even when it names neither (see Display Consistency). That step counts
its own amount, whoever pays it: a V2 or V3 exact-out step its `amountInMax`,
and a V4 step what it takes of its input token from the user's wallet, as
below, or, when the user pays it none of that token, the `amountIn` of the
swap action that spends it. Each later V2 or V3 exact-in or exact-out step
whose `payerIsUser` is true takes its amount, found in the same way, from the
user's wallet. Every V4 step, with a swap action or without, takes from the
user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and its
`SETTLE_ALL`s pay, for each token on its own; what the step that sets the
input side takes of any other token counts as a later step's take. A `SETTLE` input side takes of any other token counts as a later step's take. A `SETTLE`
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
+18 -20
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
@@ -48,29 +53,22 @@ function clearPopupCaches() {
// 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 save writes only the fields that differ from what this page last saved. // The switch is made on the stored record that the save has just read, as the
// When this page last saved a balance of "0", clearing that balance is no // background makes its own switch. The switched fields are then copied from
// change. So the switch also resets these fields in the stored record: // that record into `state`. So the switch also covers what another page stored
// since this page's last save:
// //
// - every address's balance and token balances; // - A token tracked on the previous network stays tracked on that network.
// - the time of the last balance refresh. // - An endpoint URL saved for the previous network stays with that network.
// // - A balance from the previous network is cleared
// A balance that the background or another page stored since this page's last // (https://git.eeqj.de/sneak/AutistMask/issues/653).
// save is then not kept under the new network
// (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((stored) => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(stored, newNetworkId);
stored.lastBalanceRefresh = 0; copyChainSwitchFields(state, stored);
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
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
+3 -3
View File
@@ -592,9 +592,9 @@ 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. It may also change the stored // `state`, and this save writes that change. It may also change the stored
// record. A field that this page did not change is then written as // record: a field that this page did not change is written as `beforeWrite`
// `beforeWrite` left it. When it returns false, nothing is written; any other // left it. When it returns false, nothing is written; any other return lets the
// return lets the save go ahead. // 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
+16 -118
View File
@@ -988,9 +988,6 @@ function totalAmount(amounts) {
// pays it of Token In as the step's amount in place of the rules' Amount, or // pays it of Token In as the step's amount in place of the rules' Amount, or
// no amount when this decoder cannot tell what that is. // no amount when this decoder cannot tell what that is.
// //
// `spent` lists the currency that each swap action spends, for each swap
// action that this decoder could read.
//
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes // `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does. // a share of the currency, so it does not name the output side as TAKE does.
@@ -1166,8 +1163,6 @@ function decodeV4Swap(input) {
} }
} }
const spent = swaps.map((s) => s.tokenIn);
// Rule 3. // Rule 3.
if (swapUndecoded) { if (swapUndecoded) {
return { return {
@@ -1175,7 +1170,6 @@ function decodeV4Swap(input) {
tokenOut: takeToken, tokenOut: takeToken,
amountIn: null, amountIn: null,
amountOutMin: null, amountOutMin: null,
spent,
takes, takes,
settledFromRouter, settledFromRouter,
paidByUser, paidByUser,
@@ -1186,6 +1180,7 @@ function decodeV4Swap(input) {
}; };
} }
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut); const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In. // Rules 1 and 2 for Token In.
@@ -1221,7 +1216,6 @@ function decodeV4Swap(input) {
tokenOut, tokenOut,
amountIn, amountIn,
amountOutMin, amountOutMin,
spent,
takes, takes,
settledFromRouter, settledFromRouter,
paidByUser, paidByUser,
@@ -1313,9 +1307,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const commandNames = []; const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come // THE INVARIANT: an amount and the token it is counted in always come
// from the same hop, or from hops that all spend that token or all buy // from the same hop, or from hops that all spend that token. A figure
// it. A figure is never rendered against a token that did not supply // is never rendered against a token that did not supply it.
// it.
// //
// Both sides are therefore set as a PAIR — never field by field, and // Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an // never on truthiness. An address is never falsy once set but an
@@ -1430,10 +1423,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase();
}; };
const isSender = (recipient) => {
const r = recipient.toLowerCase();
return r === MSG_SENDER || r === sender;
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's // A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity // own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all // counts against the amount a WRAP_ETH stated, and the steps pay all
@@ -1466,73 +1455,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A step pays `recipient`. `output` is the token a swap step or a V4 // A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE or TAKE_PORTION pays; other steps pass none. // TAKE or TAKE_PORTION pays; other steps pass none.
const addPayee = (recipient, output) => { const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) { if (isRouter(recipient)) {
if (present(output)) { if (present(output)) {
heldByRouter.set(output.toLowerCase(), null); heldByRouter.set(output.toLowerCase(), null);
} }
} else if (isSender(recipient)) { } else if (r === MSG_SENDER || r === sender) {
paidToSender = true; paidToSender = true;
} else { } else {
addPaidElsewhere(recipient); addPaidElsewhere(recipient);
} }
}; };
// Each swap step that paid the sender some of the token it bought,
// as { step, token, amount, onlyToSender }:
// - `step` is the step's index;
// - `token` is that token;
// - `amount` is the step's stated minimum;
// - `onlyToSender` is whether the step paid all of that token to the
// sender.
// A later step that spends that token takes the step off this list
// (see dropFromSum()).
let swapsPayingSender = [];
// The step being read spends `token`, so it may spend what an earlier
// swap step paid the sender. Each step on swapsPayingSender that
// bought `token` leaves the list. A step spends a token as follows:
// - a V2 or V3 swap step spends its input token;
// - a V4 step spends each token that one of its swap actions spends,
// and each token that one of its SETTLEs or SETTLE_ALLs pays;
// - a PERMIT2_TRANSFER_FROM spends the token it moves.
const dropFromSum = (token) => {
swapsPayingSender = swapsPayingSender.filter(
(s) => !sameAddress(s.token, token),
);
};
// The minimum the output side shows for the swap step at `step`. The
// step buys `token` with the stated minimum `amount`, and pays
// `token` to `recipients`. The minimum is the step's own, unless this
// step and an earlier one on swapsPayingSender both pay `token` to
// the sender, as a route split across steps does. The minimum is then
// the total of all such steps' minimums. There is none when this
// decoder reads no minimum from one of them, or when one of them pays
// some of `token` elsewhere. There is also none when this decoder did
// not read one of them, or a step between them, in full (see
// stepsUnread).
const swapMinimum = (step, token, amount, recipients) => {
const toSender = recipients.filter(
(r) => present(r) && isSender(r),
);
if (toSender.length === 0) return amount;
swapsPayingSender.push({
step,
token,
amount,
onlyToSender: toSender.length === recipients.length,
});
const split = swapsPayingSender.filter((s) =>
sameAddress(s.token, token),
);
if (split.length < 2) return amount;
if (
split.some(
(s) => !s.onlyToSender || typeof s.amount !== "bigint",
) ||
stepsUnread.some((unread) => unread >= split[0].step)
) {
return null;
}
return split.reduce((sum, s) => sum + s.amount, 0n);
};
// A step that pays `token` to `recipient` is a hop, not a payout, // A step that pays `token` to `recipient` is a hop, not a payout,
// when the very next step is a V2 exact-in whose input is already // when the very next step is a V2 exact-in whose input is already
// paid, whose path starts at `token`, and whose pair (see v2PairFor()) // paid, whose path starts at `token`, and whose pair (see v2PairFor())
@@ -1674,7 +1607,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: null, : null,
}); });
} }
if (p) dropFromSum(p.token);
} else if (command === "BALANCE_CHECK_ERC20") { } else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
if (!b) { if (!b) {
@@ -1697,13 +1629,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
// The router passes this check whenever the owner // The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees // holds at least minBalance, so a zero one guarantees
// nothing. A check the router lets fail guarantees // nothing and does not replace a minimum an earlier
// nothing either, so it is read as a zero one. A zero // step stated. A check the router lets fail guarantees
// check sets neither Token Out nor the minimum. Any // nothing either, so it is read as a zero one. Any
// other minBalance sets the output side as a swap // other minBalance sets the output side as a swap
// does. The check pays nobody. // does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (minBalance !== 0n) { if (!(minBalance === 0n && present(minOutput))) {
setOutput(i, b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true; keepsRouterWeth = true;
@@ -1717,18 +1649,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
dropFromSum(s.tokenIn);
// Always update output: in multi-step swaps (V3 → V4), // Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output // the last swap step determines the final output token
// token. swapMinimum() gives the minimum. // and minimum received amount.
setOutput( setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1749,15 +1673,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
dropFromSum(s.tokenIn); setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1789,15 +1705,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax); addUserPaid(s.tokenIn, s.amountInMax);
} }
dropFromSum(s.tokenIn); setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOut, [
s.recipient,
]),
[s.recipient],
);
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1969,11 +1877,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(t.recipient, t.currency); addPayee(t.recipient, t.currency);
} }
} }
for (const token of v4.spent) dropFromSum(token);
for (const s of v4.paidByUser) dropFromSum(s.currency);
for (const s of v4.settledFromRouter) {
dropFromSum(s.currency);
}
} }
if (!v4 || v4.recipientUnread) { if (!v4 || v4.recipientUnread) {
payeeUnread = true; payeeUnread = true;
@@ -2022,12 +1925,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInput(v4.tokenIn, amountIn); setInput(v4.tokenIn, amountIn);
} }
outputToken = v4.tokenOut; outputToken = v4.tokenOut;
minOutput = swapMinimum( minOutput = v4.amountOutMin;
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
unwrapIntoRouterStep = null; unwrapIntoRouterStep = null;
+3 -4
View File
@@ -7,9 +7,8 @@
// 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 balance refresh by the background that is saved while // The last test covers a refresh by the background that is saved just before
// a switch in Settings waits for the lock // the switch in Settings (https://git.eeqj.de/sneak/AutistMask/issues/653).
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const { const {
bootPopup, bootPopup,
@@ -166,7 +165,7 @@ test("a refresh that returns after the background switched the network writes no
// The background's refresh saves Ethereum Mainnet's balances while the switch // 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 // in Settings waits for the lock. This page last saved a balance of "0" and no
// token balances, the values that the switch sets. // 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 () => { test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile(); const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0"; profile.wallets[0].addresses[0].balance = "0";
+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(),
+3 -56
View File
@@ -178,9 +178,8 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last // Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch's save writes only the token lists that this page changed. // save. This page's switch in Settings keeps DAI, tracked on Ethereum Mainnet.
// So DAI stays in `trackedTokens`, the active network's list. test("a token another page tracked stays on its network through a switch in Settings", async () => {
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
const boot = await bootPopup(unversionedValidProfile()); const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]); expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", { boot.storage.write("autistmask", {
@@ -191,62 +190,10 @@ test("a token another page tracked before a switch in Settings stays in trackedT
await boot.click("btn-settings"); await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia"); await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask"); const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia"); expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]); expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([ expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
}); });
describe("Back after a switch from the Settings gear", () => { describe("Back after a switch from the Settings gear", () => {
+9 -365
View File
@@ -2871,9 +2871,8 @@ describe("uniswap decoder", () => {
...USDT_400_GROUP, ...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR), ...ONE_USDT_LINES(OTHER_ADDR),
], ],
// Both swap steps pay USDC to the user, so the minimum // The last swap step's minimum.
// is the sum of their two minimums. minimum: "390.0000 USDC",
minimum: "980.0000 USDC",
}, },
])( ])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received", "$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
@@ -3639,355 +3638,6 @@ describe("uniswap decoder", () => {
]); ]);
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/647 — on next at
// f550d8c, a split whose steps each pay WETH to the user showed only
// the last step's minimum.
describe("the minimum of a split that pays WETH to the user", () => {
const WETH_01 = 100000000000000000n;
const WETH_02 = 200000000000000000n;
const WETH_03 = 300000000000000000n;
// A V3 exact-in of 600 USDT for at least 0.3 WETH, and a V2
// exact-in of 400 USDT for at least 0.2 WETH, each paid to the
// user.
const V3_TO_WETH = encodeV3SwapExactIn(
USER_ADDR,
USDT_600,
WETH_03,
[USDT_ADDR, WETH_ADDR],
);
const V2_TO_WETH = encodeV2SwapExactIn(
USER_ADDR,
USDT_400,
WETH_02,
[USDT_ADDR, WETH_ADDR],
);
// A V4 step that swaps 400 USDT for at least 0.2 WETH, with these
// TAKEs of the WETH.
const v4ToWeth = (...takes) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
...takes.map(() => V4_TAKE_ID),
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_02,
),
encodeV4Settle(USDT_ADDR), // paid by the user
...takes,
],
);
// The swap's minimum line as [label, value], or undefined.
const minimum = (result) =>
result.details
.filter(
(d) =>
d.label === "Min. received" ||
d.label === "Min. paid out",
)
.map((d) => [d.label, d.value])[0];
test("a V3 and a V2 exact-in show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x08],
[V3_TO_WETH, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
test("a V3 exact-in and a V4 step show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_TO_WETH, v4ToWeth(encodeV4Take(WETH_ADDR))],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The second route buys DAI for the router, then swaps all of it
// from the router for at least 0.2 WETH.
test("a route that hops through DAI counts its last hop's minimum", () => {
const result = decodeSteps(
[0x00, 0x08, 0x00],
[
V3_TO_WETH,
encodeV2SwapExactIn(ROUTER_ADDR, USDT_400, DAI_400, [
USDT_ADDR,
DAI_ADDR,
]),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
WETH_02,
encodeV3Path([DAI_ADDR, WETH_ADDR]),
false, // paid from the router
],
),
],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The middle step swaps 0.3 of the user's WETH for USDC, so the
// sum of minimums starts again after it.
test.each([
{
kind: "a V3 exact-in",
command: 0x00,
input: encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-in",
command: 0x08,
input: encodeV2SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-out",
command: 0x09,
input: encodeV2SwapExactOut(USER_ADDR, 1n, WETH_03, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V3 exact-out",
command: 0x01,
input: encodeV3SwapExactOut(USER_ADDR, 1n, WETH_03, [
USDC_ADDR,
WETH_ADDR,
]),
},
])(
"$kind that spends WETH between them leaves the last step's minimum",
({ command, input }) => {
const result = decodeSteps(
[0x00, command, 0x08],
[V3_TO_WETH, input, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step swaps 0.3 WETH and 400 DAI for USDC, both paid
// by the user. It spends WETH whichever SETTLE comes last.
test.each([
{ order: "WETH first", settled: [WETH_ADDR, DAI_ADDR] },
{ order: "DAI first", settled: [DAI_ADDR, WETH_ADDR] },
])(
"a V4 step that spends WETH and DAI between them leaves the last step's minimum, with the SETTLE of $order",
({ settled }) => {
const wethAndDaiForUsdc = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
WETH_03,
1n,
),
encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
1n,
),
...settled.map((token) => encodeV4Settle(token)),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethAndDaiForUsdc, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step's swap actions buy WETH with the user's USDT,
// then swap all of that WETH for USDC.
test("a V4 step that swaps through WETH between them leaves the last step's minimum", () => {
const usdtToUsdcThroughWeth = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_01,
),
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, usdtToUsdcThroughWeth, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
});
// The middle step has no swap action. Its SETTLE or SETTLE_ALL
// pays 0.3 WETH, and its TAKE pays that WETH to another address.
test.each([
{
payment: "a SETTLE of WETH paid by the user",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, true),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE of WETH paid from the router",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, false),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE_ALL of WETH",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(WETH_ADDR, OTHER_ADDR, WETH_03),
encodeV4SettleAll(WETH_ADDR, WETH_03),
],
},
])(
"a V4 step with $payment between them leaves the last step's minimum",
({ actions, params }) => {
const wethToOther = encodeV4Swap(
new Uint8Array(actions),
params,
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethToOther, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
},
);
test("a PERMIT2_TRANSFER_FROM of WETH between them leaves the last step's minimum", () => {
const result = decodeSteps(
[0x00, 0x02, 0x08],
[
V3_TO_WETH,
permit2TransferFrom(WETH_ADDR, OTHER_ADDR, WETH_03),
V2_TO_WETH,
],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
});
// The decoder reads no minimum from a V4 exact-out action.
test("a step that states no minimum leaves no figure", () => {
const exactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
WETH_02,
USDT_400,
),
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x00],
[exactOut, V3_TO_WETH],
);
expect(minimum(result)).toBeUndefined();
});
test("a step that pays some of its WETH to the router leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x10],
[
V3_TO_WETH,
v4ToWeth(
take(WETH_ADDR, USER_ADDR, WETH_01),
take(WETH_ADDR, ROUTER_ADDR, 0n),
),
],
);
expect(minimum(result)).toBeUndefined();
});
// The router still runs a swap step whose recipient cannot be
// read, and that step may spend the WETH the first step paid.
test("a step between them that cannot be read leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x00, 0x08],
[
V3_TO_WETH,
dirtyWord(
encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
0,
),
V2_TO_WETH,
],
);
expect(minimum(result)).toBeUndefined();
});
});
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
@@ -4730,9 +4380,10 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC"); expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
}); });
// A zero balance check sets neither Token Out nor the minimum. The route // With no minimum stated before it, a zero balance check is what sets the
// pays only the WRAP_ETH's WETH, to the router, where it stays. // output side, and it guarantees nothing. No step pays anyone, so the
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => { // minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[ [
@@ -4745,16 +4396,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toContain("USDT");
"Unknown (not named in the calldata)", expect(detail(result, "Min. paid out").value).toBe(
); "None (no minimum guaranteed)",
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
); );
}); });
+5 -60
View File
@@ -688,62 +688,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]); ]);
}); });
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user. // A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => { test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode( const result = decode(
@@ -2876,16 +2820,17 @@ describe("the allow-revert flag", () => {
}, },
); );
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => { test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH); const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe( expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH", "1.0000 WETH",
); );
const withFlag = decode(execute(flagged(check))); const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED); expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out")).toBeUndefined(); expect(detail(withFlag, "Min. paid out").value).toBe(
expect(detail(withFlag, "Min. received")).toBeUndefined(); "None (no minimum guaranteed)",
);
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.