1 Commits
Author SHA1 Message Date
sneak 59d8f478ae 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 now
notices the stored record change and takes the new network from it,
holding the lock, inside a save. It then leaves the page as a switch in
Settings does: the stack is cut, every screen counts as not yet
rendered, and the screen on top is drawn again. The cut and the redraw
are now two functions in the Settings view that both paths call. A
record already on the popup's network, such as its own Settings switch,
changes nothing. The popup never switches the network itself.

Model: opus-5-5
2026-10-20 21:46:36 +00:00
9 changed files with 184 additions and 570 deletions
+74 -93
View File
@@ -461,15 +461,13 @@ address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, the stored network unchanged and send no `chainChanged` until it is approved, a
popup open all along is required to show the `[TESTNET]` banner once a switch to network switch in Settings is required to send the page `chainChanged` with the
Sepolia is approved and to drop it once the switch back is, a network switch in new chain id, a prompt raised while another approval window has focus is
Settings is required to send the page `chainChanged` with the new chain id, a required to open a window of its own, and the password is required to be absent
prompt raised while another approval window has focus is required to open a from every message the approval window sends to the background — with the
window of its own, and the password is required to be absent from every message message that would carry it required to be present, so that check cannot pass by
the approval window sends to the background — with the message that would carry observing nothing. That last one is the standing floor under
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -605,10 +603,9 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script, rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The popup open all along is required to background page and approval windows. The `personal_sign` prompt is also
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop required to fit its window without scrolling sideways, with the extension's
it once the switch back is. The `personal_sign` prompt is also required to fit pages at 170% zoom.
its window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -1132,15 +1129,13 @@ scale is not an approximation but a different number — 1,000 units of a
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen 6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
whose only job is to state what is being authorized. Both amount paths of that whose only job is to state what is being authorized. Both amount paths of that
screen take this rule: the ERC-20 `transfer`/`approve` line screen take this rule: the ERC-20 `transfer`/`approve` line
(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`, (`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of lines (`src/shared/uniswap.js`). The token permission warning on the signature
a move into the router (`src/shared/uniswap.js`). The token permission warning screen takes the same rule for its amounts. An unbounded allowance or permit
on the signature screen takes the same rule for its amounts. An unbounded needs no scale to describe and is still shown as `Unlimited`. A source's answer
allowance or permit needs no scale to describe and is still shown as counts only if it is a whole number from 0 to 80: `decimals()` returns a
`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80: `uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a
`decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80 token that reports 81 to 255 is shown as one whose scale nothing knows.
decimal places, so a token that reports 81 to 255 is shown as one whose scale
nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1570,23 +1565,20 @@ while the user was away from it.
A network switch leaves every screen on the page showing the previous network. A network switch leaves every screen on the page showing the previous network.
The user makes it in Settings, or approves a site's request and the background The user makes it in Settings, or approves a site's request and the background
saves it. Any other popup open meanwhile takes the switch from the stored saves it. Any other popup open meanwhile takes the switch from the stored
record, even one saved while it was opening, and never switches the network record, and never switches the network itself. After either, "Back" behaves as
itself. A switch the user picks in its Settings while it takes one is made after in a reopened popup: every screen on the stack counts as not yet rendered, and
it, not overwritten by it. An approval window does not take the switch. After the stack is cut at the first screen that cannot be rendered from saved state,
the switch, whichever way the page got it, "Back" behaves as in a reopened such as Send or AddToken, or at AddressToken for any token but the native one,
popup: every screen on the stack counts as not yet rendered, and the stack is because that token was picked on the previous network and is no longer selected.
cut at the first screen that cannot be rendered from saved state, such as Send The cut stack is saved with the popup's new network, so a popup closed straight
or AddToken, or at AddressToken for any token but the native one, because that after and reopened lands in the same place. The screen on top is drawn again for
token was picked on the previous network and is no longer selected. The cut the new network: Settings in place, and any other screen as a reopened popup
stack is saved with the popup's new network, so a popup closed straight after draws it, so Home takes the place of one that cannot be rendered from saved
and reopened lands in the same place. The screen on top is drawn again for the state or of AddressToken for a token no longer selected. "Back" then renders the
new network: Settings in place, and any other screen as a reopened popup draws screen it lands on for the new network, and anything typed there and not saved,
it, so Home takes the place of one that cannot be rendered from saved state or which belonged to the previous network, is dropped. ConfirmTx, WaitTx,
of AddressToken for a token no longer selected. "Back" then renders the screen SuccessTx, ErrorTx and TransactionDetail are rendered instead for the network of
it lands on for the new network, and anything typed there and not saved, which the transaction they show: the network it was reviewed on for ConfirmTx, and the
belonged to the previous network, is dropped. ConfirmTx, WaitTx, SuccessTx,
ErrorTx and TransactionDetail are rendered instead for the network of the
transaction they show: the network it was reviewed on for ConfirmTx, and the
network it was sent on for the others. Without a wallet the stack is not cut and network it was sent on for the others. Without a wallet the stack is not cut and
only Settings is drawn again, because the stack starts at Welcome, and Welcome only Settings is drawn again, because the stack starts at Welcome, and Welcome
and AddWallet show nothing from a network. and AddWallet show nothing from a network.
@@ -2439,11 +2431,10 @@ view would leave a wallet one click from deletion.
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give, amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, deadline, and
`PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender unless a swap pays only the From address, a `Paid to` line for each other
take, steps, deadline, and unless a swap pays only the From address, a address it pays, or saying that a token stays in the router or who it pays
`Paid to` line for each other address it pays, or saying that a token is unknown (see Transaction Decoding)
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -3232,42 +3223,40 @@ Currently supported:
added to the total while both are quantities: a V3 exact-in of 600 USDT and a added to the total while both are quantities: a V3 exact-in of 600 USDT and a
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
the decoder does not know a take's amount, the total is not known and there is the decoder does not know a take's amount, the total is not known and there is
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the
`Unlimited` included, stands as the `Amount` figure in place of the total, decoder reads, when there is one. Its amount, `Unlimited` included, stands as
which it does only in the plain shape of Uniswap's own routes: it comes before the `Amount` figure in place of the total only in the plain shape of Uniswap's
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the own routes: it is the only `PERMIT2_PERMIT`, its spender is the router, the
transaction, its spender is the router, the router may not let it fail, no router may not let it fail, no step before it takes its token or a token the
step before it takes its token or a token the decoder does not know, and the decoder does not know, and the decoder reads every step after it in full.
decoder reads every step after it in full. Permit2 then lets the router take Permit2 then lets the router take no more of the token than that amount, so
no more of the token than that amount, so the permit covers every take of it. the permit covers every take of it. A step the decoder does not read in full
A step the decoder does not read in full may raise what Permit2 lets the may raise what Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can:
router take, as a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not a command the decoder does not read (listed at the end of this section), a
read (listed at the end of this section), a step whose input it cannot read, step whose input it cannot read, in whole or in part, and a step it fails on.
in whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, In any other case the line gives the total, as with no permit. When the step
before the step that sets the input side or after it, leaves `Token In` and that sets the input side spends a token other than the permit's, or one the
the `Amount` line as they read with no permit: a permit of 5,000 DAI to decoder does not know, its amount gets lines of its own, unless it states no
another address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` amount and the user pays it none of its input token, and the total has no
with `Amount 600.0000 USDT`, and so do that exact-in and then a permit of figure to start from: unless the permit's amount stands there is no `Amount`
5,000 DAI to the router. When a permit's amount stands and the step that sets line, and each take of the permit's token gets lines of its own. Each take the
the input side spends a token other than the permit's, or one the decoder does total does not count gives three lines after the `Amount` line (after
not know, that step's amount gets lines of its own, unless it states no amount `Token In` when there is none): one of a token other than `Token In`; one of
and the user pays it none of its input token. Each take the total does not `Token In` after an amount that is not a quantity (a V4 open delta,
count gives three lines after the `Amount` line (after `Token In` when there `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
is none): one of a token other than `Token In`; one of `Token In` after an amount, as of a V4 exact-out step); and one whose amount the decoder does not
amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep know. When the step that sets the input side states no amount, the user pays
their wording on the `Amount` line, or no stated amount, as of a V4 exact-out it some of its input token, and there is no `Amount` line, that step gets them
step); and one whose amount the decoder does not know. When the step that sets too whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM`
the input side states no amount, the user pays it some of its input token, and of its token gives one, on the lines below or, into the router, on its
there is no `Amount` line, that step gets them too whenever another take's `Paid to` line, so that figure cannot read as all that leaves. When it states
lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on no amount and the user pays it none of its input token, as when the router
the lines below or, into the router, on its `Paid to` line, so that figure pays it, that step takes none of that token from the user's wallet and never
cannot read as all that leaves. When it states no amount and the user pays it gets them, with a permit or without. They come in step order, the step that
none of its input token, as when the router pays it, that step takes none of sets the input side first when it has them: `Moved from the sender`, its
that token from the user's wallet and never gets them, with a permit or amount, worded as the `Amount` line words one, or
without. They come in step order, the step that sets the input side first when `Unknown (could not be decoded)` when the decoder does not know it;
it has them: `Moved from the sender`, its amount, worded as the `Amount` line `Token moved`, giving the token's address as the `Token In` line does; and
words one, or `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
`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
does not know gives the `Moved from the sender` line alone. A step may also does not know gives the `Moved from the sender` line alone. A step may also
take from the user's wallet what the decoder did not read: a V4 step whose take from the user's wallet what the decoder did not read: a V4 step whose
@@ -3288,19 +3277,11 @@ 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. These lines change neither the
amount does not stand as the `Amount` figure, before the step that sets the `Paid to` lines nor the minimum's label. A step goes unread when its recipient
input side or after it, gives three lines, in step order, after those for each does not decode as an address, when it is a V3 swap step whose path is shorter
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example than one token address or has bytes after its last hop, or when it is a
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, command the decoder does not read: every command other than `PERMIT2_PERMIT`,
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
amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. These lines change neither the `Paid to`
lines nor the minimum's label. A step goes unread when its recipient does not
decode as an address, when it is a V3 swap step whose path is shorter than one
token address or has bytes after its last hop, or when it is a command the
decoder does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
+12 -20
View File
@@ -9,10 +9,9 @@ const {
} = 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, clearPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { copyChainSwitchFields } = require("../shared/chainSwitchFields"); const { copyChainSwitchFields } = require("../shared/chainSwitchFields");
const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
showView, showView,
@@ -221,21 +220,23 @@ function fallbackView() {
// When the background saves a network switch, once the user approves a site's // When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored // request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding // record. It takes the new network from the record as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and // the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own // then leaves the page as a switch in Settings leaves it. Only a record naming
// switch in Settings is made under the lock as well (onChainSwitch() in // a network other than this page's is followed: the one this page's own switch
// src/shared/chainSwitch.js), so a record naming a network other than this // in Settings saves names the network it already has, and a record naming none
// page's always holds a switch saved elsewhere, and a switch the user picks // is no switch.
// here while this waits for the lock is made after it. async function followNetworkSwitch(changes) {
async function followNetworkSwitch() { const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId === state.networkId) return;
let switched = false; let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure(). // A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => { await saveState((stored) => {
if (stored.networkId === state.networkId) return false; if (stored.networkId === state.networkId) return false;
settings.cutViewStack(); settings.cutViewStack();
copyChainSwitchFields(state, stored); copyChainSwitchFields(state, stored);
clearPopupCaches(); clearPrices();
switched = true; switched = true;
}).catch(() => {}); }).catch(() => {});
if (switched) settings.showNewNetwork(); if (switched) settings.showNewNetwork();
@@ -385,16 +386,7 @@ async function init() {
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx); deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the onStorageChanged(followNetworkSwitch);
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");
+1 -5
View File
@@ -365,14 +365,10 @@ function init(_ctx) {
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value; const newId = networkSelect.value;
if (newId === state.networkId) return;
cutViewStack(); cutViewStack();
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
+17 -36
View File
@@ -1,17 +1,8 @@
// The popup's network switch. // Consolidated chain-switch handler for the popup.
// //
// A network switch reaches the popup's `state` in one of two ways: // Every state change required when the active network changes is
// // performed here so that callers (settings UI, future chain additions) all go
// - onChainSwitch() below makes the switch the user picks in Settings, with // through a single code path.
// applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
// //
// Adding a new chain (e.g. ETC) requires only a new entry in // Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed. // networks.js — no per-caller wiring is needed.
@@ -29,32 +20,22 @@
const { applyChainSwitchFields } = require("./chainSwitchFields"); const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state. // Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain, or null when // Returns the network configuration object for the new chain.
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null;
await saveState(() => { const net = applyChainSwitchFields(state, newNetworkId);
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); // --- price cache ---
clearPopupCaches(); // Prices are chain-specific (testnet tokens are worthless,
}); // ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
return net; return net;
} }
module.exports = { onChainSwitch, clearPopupCaches }; module.exports = { onChainSwitch };
+37 -83
View File
@@ -494,9 +494,8 @@ 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 under `label`, in the shape of the Token In
// the shape of the Token In line, so the screens show it as they show that // line, so the screens show it as they show that one. ETH has no address.
// one. ETH has no address.
function tokenMovedLine(label, token, sources) { function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources); const info = tokenInfo(token, sources);
if (!present(info.address)) { if (!present(info.address)) {
@@ -597,14 +596,12 @@ function decodeV2SwapExactOut(input) {
// token(20) ...), or null when it is not one token followed by whole hops of // token(20) ...), or null when it is not one token followed by whole hops of
// a fee and a token (40 hex digits, then 46 per hop). The router reads only // a fee and a token (40 hex digits, then 46 per hop). The router reads only
// whole hops and ignores any bytes after the last one, so the last 20 bytes // whole hops and ignores any bytes after the last one, so the last 20 bytes
// of such a path are not the token it reads there. Both are checksummed, as // of such a path are not the token it reads there.
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) { function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null; if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = getAddress("0x" + hex.slice(0, 40)); const first = "0x" + hex.slice(0, 40);
const last = getAddress("0x" + hex.slice(-40)); const last = "0x" + hex.slice(-40);
return { first, last }; return { first, last };
} }
@@ -1211,14 +1208,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// its input currency (see decodeV4Swap()). Only a V4 step can set the // its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read. // input side with no amount, the one case in which this is read.
let inputPaidByUser = false; let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read, or null (see
// input side, or null (see permitStands after the loop). // permitStands after the loop).
let permit = null; let permit = null;
// Every PERMIT2_PERMIT this decoder read, before a step set the input
// side or after, in step order, as { token, amount, spender, step }.
// Each whose amount is not the Amount figure gets lines of its own
// (see after the PERMIT2_TRANSFER_FROM lines).
const permits = [];
// The index of each step this decoder did not read in full: a // The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in // command it does not read, a step whose input it cannot read, in
// whole or in part, and one it fails on (see permitStands). // whole or in part, and one it fails on (see permitStands).
@@ -1276,9 +1268,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// when no earlier hop set it. A half the establishing hop did not // when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in // state stays null and the line says so, rather than being filled in
// from a different hop. A PERMIT2_PERMIT sets no side here: after the // from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, one whose amount stands as the Amount figure names Token In, // loop, the last one read names Token In, and the Amount line is
// and the Amount line is formed there, in one place (see // formed there, in one place (see permitStands).
// permitStands).
const setInput = (token, amount) => { const setInput = (token, amount) => {
inputToken = present(token) ? token : null; inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null; inputAmount = present(amount) ? amount : null;
@@ -1474,33 +1465,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "PERMIT2_PERMIT") { } else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
if (p) { if (p) {
permits.push({ ...p, step: i });
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
// Only a permit whose amount stands as the Amount figure
// names Token In (see permitStands). Every other permit,
// before the step that sets the input side or after it,
// leaves Token In and Amount as they read with no permit
// and gets lines of its own. One after a step set the input
// side never stands, so only one before it is kept here.
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`, when its token is the // have: the step that set the input side or one in
// permit's or not known. No step has set the input // `userPaid`, when its token is the permit's or not
// side yet. // known.
const mayBeItsToken = (token) => const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token); !present(token) || sameAddress(token, p.token);
const tookBefore = userPaid.some((t) => const tookBefore =
mayBeItsToken(t.token), (inputEstablished && mayBeItsToken(inputToken)) ||
); userPaid.some((t) => mayBeItsToken(t.token));
permit = { permit = {
...p, ...p,
step: i, step: i,
allowedToFail, allowedToFail,
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
} }
} else if (command === "PERMIT2_TRANSFER_FROM") { } else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]); const p = decodePermit2TransferFrom(inputs[i]);
@@ -1957,21 +1939,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. // total unknown, with no figure.
// //
// A PERMIT2_PERMIT names Token In only when its amount stands as the // The last PERMIT2_PERMIT read names Token In. Its amount stands as
// Amount figure in place of the total, which it does only in the // the Amount figure in place of the total only in the plain shape of
// plain shape of Uniswap's own routes: it comes before the step that // Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is
// set the input side, it is the only PERMIT2_PERMIT in the // the router, the router may not let it fail, no step before it took
// transaction, its spender is the router, the router may not let it // its token or one not known, and this decoder read every step after
// fail, no step before it took its token or one not known, and this // it in full (see stepsUnread). Permit2 then lets the router take no
// decoder read every step after it in full (see stepsUnread). Permit2 // more of the token than that amount, so the permit covers every take
// then lets the router take no more of the token than that amount, so // of it. A later step this decoder did not read may raise what Permit2
// the permit covers every take of it. A later step this decoder did // lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that
// not read may raise what Permit2 lets the router take, as a // may take from the sender what this decoder did not read leaves no
// PERMIT2_PERMIT_BATCH can. Every other PERMIT2_PERMIT leaves Token In // figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// and the Amount line as they read with no permit, and gets lines of // step leaves both sides unknown (below).
// its own below. A step that may take from the sender what this decoder
// did not read leaves no figure, permit or not (see spentUnread()),
// and an EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -1998,17 +1977,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The take of the step that set the input side, when the total // The take of the step that set the input side, when the total
// starts from it and it stated no amount (see below), or null. // starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null; let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) { if (permit === null || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
} }
} else if (hasFirstTake) { } else if (hasFirstTake) {
// The step that set the input side spent a token other than the // The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own. // permit's, or one not known, so the total starts with no figure.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({ token: inputToken, amount: inputAmount });
} }
if (permitStands) inputToken = permit.token; if (permit !== null) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken); const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
@@ -2041,9 +2020,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step. What the other // or pay the user, before or after any other step, and a permit among
// steps state is then not the whole swap, so both sides are set // them would name Token In. What the other steps state is
// unknown, with no figure, wherever the step stands. // then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
if (hasSubPlan) { if (hasSubPlan) {
inputToken = null; inputToken = null;
inputAmount = null; inputAmount = null;
@@ -2201,32 +2181,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
); );
} }
// What each PERMIT2_PERMIT lets its spender take: its amount, its
// token and the spender, an address on a line of its own. Permit2's
// maximum is unbounded. Only a permit whose amount stands as the
// Amount figure (see permitStands) gets none: that line already gives
// its amount and Token In its token, and its spender is the router.
// Every other permit, one after the step that set the input side
// included, gets these lines, so nothing it lets anyone take goes
// unstated.
for (const p of permits) {
if (permitStands && p.step === permit.step) continue;
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,
address: p.spender,
});
}
// Keyed on the address, not the symbol: a token absent from the // Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it // bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is // entirely, leaving a Min. received figure with nothing saying what is
+3 -3
View File
@@ -97,9 +97,9 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
); );
}); });
// The switch is made only once the refresh's save, holding the lock, has // The stored record still holds Ethereum Mainnet while the refresh's save reads
// written Ethereum Mainnet's balances, and the switch then clears them. // it, so only this page's own network shows the switch.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => { test("a refresh whose save is reading the stored record when the user switches in Settings writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile()); const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]); expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
+12 -80
View File
@@ -12,7 +12,6 @@ const {
unversionedValidProfile, unversionedValidProfile,
TOKEN_ADDRESS, TOKEN_ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const { const {
applyChainSwitchFields, applyChainSwitchFields,
copyChainSwitchFields, copyChainSwitchFields,
@@ -37,22 +36,19 @@ const MAINNET_TOKEN = {
name: "Dai Stablecoin", name: "Dai Stablecoin",
}; };
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail, // The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
// with DAI tracked there and TST tracked on Sepolia. // there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() { function bootOnAddress() {
return bootPopup(profileOnAddress()); return bootPopup(
unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
} }
// The background saves a switch to `networkId`, as its updateState() does // The background saves a switch to `networkId`, as its updateState() does
@@ -156,70 +152,6 @@ test("a switch in Settings is not taken a second time when its save is reported"
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet."); expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
}); });
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("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.
+24 -231
View File
@@ -1,4 +1,4 @@
const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers"); const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2, // A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
@@ -696,8 +696,12 @@ describe("uniswap decoder", () => {
USER_ADDR, USER_ADDR,
); );
expect(result.name).toBe("Swap USDT → WETH"); expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(
expect(detail(result, "Token Out").address).toBe(WETH_ADDR); USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT"); expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -731,7 +735,9 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH"); expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined(); expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Paid to")).toBeUndefined(); expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
}); });
@@ -1400,14 +1406,6 @@ describe("uniswap decoder", () => {
return lines.slice(from + 1, to); return lines.slice(from + 1, to);
} }
// The lines of a PERMIT2_PERMIT whose amount does not stand as the
// Amount figure, of `amount` of `token` for `spender`.
const permitLines = (amount, token, symbol, spender) => [
["Permit2 allowance", amount],
["Allowance token", symbol + " (" + token + ")"],
["Allowance spender", getAddress(spender)],
];
// execute() of these command bytes and inputs, decoded as sent by // execute() of these command bytes and inputs, decoded as sent by
// the user. // the user.
function decodeSteps(commands, inputs) { function decodeSteps(commands, inputs) {
@@ -1713,7 +1711,6 @@ describe("uniswap decoder", () => {
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,", kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,",
commands: [0x10], commands: [0x10],
inputs: [V4_SETTLE_5000], inputs: [V4_SETTLE_5000],
permit: [],
}, },
{ {
kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,", kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,",
@@ -1722,12 +1719,6 @@ describe("uniswap decoder", () => {
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
V4_SETTLE_5000, V4_SETTLE_5000,
], ],
permit: permitLines(
"1.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
}, },
// The unread SWEEP keeps the permit's amount from standing. // The unread SWEEP keeps the permit's amount from standing.
{ {
@@ -1744,18 +1735,11 @@ describe("uniswap decoder", () => {
1, 1,
), ),
], ],
permit: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
}, },
])("$kind shows the 5,000 USDT", ({ commands, inputs, permit }) => { ])("$kind shows the 5,000 USDT", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"], ["Amount", "5000.0000 USDT"],
...permit,
]); ]);
}); });
@@ -2039,24 +2023,19 @@ describe("uniswap decoder", () => {
}); });
// Out of that shape the permit caps nothing the Amount line can rely // Out of that shape the permit caps nothing the Amount line can rely
// on, so the line gives the total the steps take, as with no permit, // on, so the line gives the total the steps take, as with no permit.
// and each permit gets lines of its own.
const ONE_USDT_PERMIT = (spender) => const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender); encodePermit2(USDT_ADDR, 1000000n, spender);
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([ test.each([
{ {
kind: "to another spender", kind: "to another spender",
commands: [0x0a], commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)], permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
}, },
{ {
kind: "that the router lets fail", kind: "that the router lets fail",
commands: [0x8a], commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)], permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
lines: ONE_USDT_LINES(ROUTER_ADDR),
}, },
{ {
kind: "followed by a second one", kind: "followed by a second one",
@@ -2065,14 +2044,10 @@ describe("uniswap decoder", () => {
ONE_USDT_PERMIT(ROUTER_ADDR), ONE_USDT_PERMIT(ROUTER_ADDR),
ONE_USDT_PERMIT(ROUTER_ADDR), ONE_USDT_PERMIT(ROUTER_ADDR),
], ],
lines: [
...ONE_USDT_LINES(ROUTER_ADDR),
...ONE_USDT_LINES(ROUTER_ADDR),
],
}, },
])( ])(
"a split after a PERMIT2_PERMIT $kind shows the total the steps take", "a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits, lines }) => { ({ commands, permits }) => {
const result = decodeSteps( const result = decodeSteps(
[...commands, 0x00, 0x08], [...commands, 0x00, 0x08],
[ [
@@ -2086,20 +2061,10 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"], ["Amount", "1000.0000 USDT"],
...lines,
]); ]);
}, },
); );
// The lines of a PERMIT2_PERMIT of 600 USDT to the router whose
// amount does not stand.
const USDT_600_LINES = permitLines(
"600.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise // A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again. // what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => { test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
@@ -2131,7 +2096,6 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"], ["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]); ]);
}); });
@@ -2177,7 +2141,6 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"], ["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]); ]);
}, },
); );
@@ -2205,9 +2168,8 @@ describe("uniswap decoder", () => {
]; ];
// What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts // What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts
// as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum. // as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum.
// The permit of 10,000 USDT stands. The one of 600 USDT comes after // The permit of 10,000 USDT stands; the one of 600 USDT does not,
// the step that set the input side, so it does not stand and gets // because the step before it took its token.
// lines of its own.
test.each([ test.each([
{ {
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT", kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT",
@@ -2239,185 +2201,17 @@ describe("uniswap decoder", () => {
V3_600, V3_600,
], ],
amount: "Up to 10000.0000 USDT", amount: "Up to 10000.0000 USDT",
permit: USDT_600_LINES,
}, },
])( ])(
"$kind, all paid by the user, shows the total as a maximum", "$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount, permit = [] }) => { ({ commands, inputs, amount }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", amount], ["Amount", amount],
...permit,
]); ]);
}, },
); );
// https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_PERMIT
// after the step that set the input side named Token In in place of
// that step's token, and its amount could stand as the Amount figure.
// Only a permit whose amount stands as the Amount figure names Token
// In now. Every other, before the swap steps or after them, leaves
// Token In and Amount as they read with no permit and gets lines of
// its own.
describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => {
const DAI_5000 = 5000000000000000000000n;
const USDT_5000 = 5000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 DAI to the router,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
],
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of unlimited DAI to another spender,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(DAI_ADDR, MAX_UINT160, OTHER_ADDR),
],
lines: permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT, the swap's own token, to the router,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
],
lines: USDT_600_LINES,
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,",
commands: [0x8a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to the router, a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT to the router,",
commands: [0x0a, 0x00, 0x0a],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
V3_600,
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
],
lines: [
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...USDT_600_LINES,
],
},
])(
"$kind shows the swap's 600 USDT and each permit's own lines",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
// The swap step names Token In, its address checksummed,
// as on a permit's Allowance token line.
expect(detail(result, "Token In").value).toBe(
"USDT (" + USDT_ADDR + ")",
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...lines,
]);
expect(detail(result, "Amount").rawValue).toBe("600.0000");
},
);
// With two permits neither amount stands, so the Amount line
// gives the total the steps take of USDT, the 400 DAI the last
// step takes gets lines of its own, and so does each permit.
test("a PERMIT2_PERMIT of USDT, a V3 exact-in of 600 USDT, a PERMIT2_PERMIT of DAI and a V2 exact-in of 400 DAI shows both tokens' amounts and each permit's own lines", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x0a, 0x08],
[
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
encodePermit2(DAI_ADDR, DAI_400, ROUTER_ADDR),
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
...permitLines(
"400.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
]);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be // A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
// read. It may pay any address and take from the sender, so it // read. It may pay any address and take from the sender, so it
// leaves no Amount figure. // leaves no Amount figure.
@@ -2463,7 +2257,7 @@ describe("uniswap decoder", () => {
// A V3 exact-out of 1,500 USDC for at most 0.5 WETH spends WETH, not // A V3 exact-out of 1,500 USDC for at most 0.5 WETH spends WETH, not
// SPK. After it, a PERMIT2_PERMIT of 1 WETH and a V3 exact-in of 0.5 // SPK. After it, a PERMIT2_PERMIT of 1 WETH and a V3 exact-in of 0.5
// WETH: the permit's amount does not stand, because the step before // WETH: the permit's amount does not stand, because the step before
// it also takes WETH, so the permit gets lines of its own. // it also takes WETH.
test("a V3_SWAP_EXACT_OUT whose path has bytes after its last hop, then a PERMIT2_PERMIT and a V3 exact-in, shows no Amount figure", () => { test("a V3_SWAP_EXACT_OUT whose path has bytes after its last hop, then a PERMIT2_PERMIT and a V3 exact-in, shows no Amount figure", () => {
const path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR); const path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR);
const result = decodeSteps( const result = decodeSteps(
@@ -2479,7 +2273,6 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]); ]);
expect(detail(result, "Paid to").value).toBe(UNDECODED); expect(detail(result, "Paid to").value).toBe(UNDECODED);
}); });
@@ -2650,11 +2443,7 @@ describe("uniswap decoder", () => {
V4_EXACT_OUT_600, V4_EXACT_OUT_600,
V2_EXACT_OUT_400, V2_EXACT_OUT_400,
], ],
lines: [ lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
...UNKNOWN_USDT,
...UP_TO_400_USDT,
...ONE_USDT_LINES(OTHER_ADDR),
],
}, },
{ {
kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT", kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT",
@@ -3280,7 +3069,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.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
@@ -3338,7 +3129,9 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT"); expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
}); });
+4 -19
View File
@@ -2809,7 +2809,7 @@ describe("the allow-revert flag", () => {
mark: " (allowed to fail)", mark: " (allowed to fail)",
}, },
])( ])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag", "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => { ({ address, mark }) => {
const steps = [ const steps = [
permit2Permit(USDT, USDT_2000), permit2Permit(USDT, USDT_2000),
@@ -2824,24 +2824,9 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe( expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)", "Permit2 Permit" + mark + " → V3 Swap (Exact In)",
); );
// The permit names the router at ROUTER as its spender, so its const withoutSteps = (r) =>
// amount stands as the Amount figure only there, and only without r.details.filter((d) => d.label !== "Steps");
// the flag. With the flag it stands on no router and gets lines expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
// of its own, and the swap step names Token In: the same token,
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
"Allowance spender",
];
const swapLines = (r) =>
r.details.filter(
(d) => !["Steps", ...permitLines].includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
}, },
); );