1 Commits
Author SHA1 Message Date
sneak 292ebe544e fix: a permit shows on its own lines unless its amount is the Amount figure (closes #370)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A PERMIT2_PERMIT read after a step had set the input side named Token
In, and its amount could stand as the Amount figure, so the approval
screen could show an allowance where the swap's own input belonged. A
permit before the swap steps whose spender was not the router, or that
the router may let fail, had no line naming its spender.

A permit after the input side now leaves Token In and Amount to the
swap. Every permit whose amount does not stand as the Amount figure
gets three lines of its own: Permit2 allowance, Allowance token and
Allowance spender. README Transaction Decoding and the TxApproval
element list match.

Model: opus-5-5
2026-10-20 20:19:08 +00:00
9 changed files with 105 additions and 400 deletions
+58 -60
View File
@@ -1129,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
@@ -2094,11 +2092,8 @@ view would leave a wallet one click from deletion.
none, and the tokens tracked on that network, and sends `chainChanged` none, and the tokens tracked on that network, and sends `chainChanged`
with the new chain id to every open tab. A token is tracked only on the with the new chain id to every open tab. A token is tracked only on the
network it was added on, because a contract address names a token on one network it was added on, because a contract address names a token on one
network only. Switching also clears every address's balances. A balance network only. Choosing the network already active changes nothing and
refresh, by the popup or the background, stores what it fetched only if sends nothing
the stored network is still the one it fetched on when it returns, so the
previous network's balances never reach the new one. Choosing the network
already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -3220,42 +3215,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 before the step that sets the input side, when there is one. Its
which it does only in the plain shape of Uniswap's own routes: it comes before amount, `Unlimited` included, stands as the `Amount` figure in place of the
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the total only in the plain shape of Uniswap's own routes: it is the only
transaction, its spender is the router, the router may not let it fail, no `PERMIT2_PERMIT` in the transaction, its spender is the router, the router may
step before it takes its token or a token the decoder does not know, and the not let it fail, no step before it takes its token or a token the decoder does
decoder reads every step after it in full. Permit2 then lets the router take not know, and the decoder reads every step after it in full. Permit2 then lets
no more of the token than that amount, so the permit covers every take of it. the router take no more of the token than that amount, so the permit covers
A step the decoder does not read in full may raise what Permit2 lets the every take of it. A step the decoder does not read in full may raise what
router take, as a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can: a command the
read (listed at the end of this section), a step whose input it cannot read, decoder does not read (listed at the end of this section), a step whose input
in whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, it cannot read, in whole or in part, and a step it fails on. In any other case
before the step that sets the input side or after it, leaves `Token In` and the line gives the total, as with no permit. When the step that sets the input
the `Amount` line as they read with no permit: a permit of 5,000 DAI to side spends a token other than the permit's, or one the decoder does not know,
another address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` its amount gets lines of its own, unless it states no amount and the user pays
with `Amount 600.0000 USDT`, and so do that exact-in and then a permit of it none of its input token, and the total has no figure to start from: unless
5,000 DAI to the router. When a permit's amount stands and the step that sets the permit's amount stands there is no `Amount` line, and each take of the
the input side spends a token other than the permit's, or one the decoder does permit's token gets lines of its own. Each take the total does not count gives
not know, that step's amount gets lines of its own, unless it states no amount three lines after the `Amount` line (after `Token In` when there is none): one
and the user pays it none of its input token. Each take the total does not of a token other than `Token In`; one of `Token In` after an amount that is
count gives three lines after the `Amount` line (after `Token In` when there not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
is none): one of a token other than `Token In`; one of `Token In` after an the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep whose amount the decoder does not know. When the step that sets the input side
their wording on the `Amount` line, or no stated amount, as of a V4 exact-out states no amount, the user pays it some of its input token, and there is no
step); and one whose amount the decoder does not know. When the step that sets `Amount` line, that step gets them too whenever another take's lines give a
the input side states no amount, the user pays it some of its input token, and figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines
there is no `Amount` line, that step gets them too whenever another take's below or, into the router, on its `Paid to` line, so that figure cannot read
lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on as all that leaves. When it states no amount and the user pays it none of its
the lines below or, into the router, on its `Paid to` line, so that figure input token, as when the router pays it, that step takes none of that token
cannot read as all that leaves. When it states no amount and the user pays it from the user's wallet and never gets them, with a permit or without. They
none of its input token, as when the router pays it, that step takes none of come in step order, the step that sets the input side first when it has them:
that token from the user's wallet and never gets them, with a permit or `Moved from the sender`, its 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
@@ -3276,19 +3269,24 @@ 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. A `PERMIT2_PERMIT` after the
amount does not stand as the `Amount` figure, before the step that sets the step that sets the input side names neither `Token In` nor the `Amount`
input side or after it, gives three lines, in step order, after those for each figure, which keep that step's token and the total: a V3 exact-in of 600 USDT
followed by a permit of 5,000 DAI reads `Swap USDT → USDC` with
`Amount 600.0000 USDT`. Every `PERMIT2_PERMIT` whose amount does not stand as
the `Amount` figure, before the step that sets the input side or after it,
gives three lines, in step order, after those for each
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose `Allowance spender`, the address it lets take the token. So does one whose
amount stands gives none: the `Amount` line gives its amount, `Token In` its spender is not the router, or that the router may let fail. Only a permit
token, and its spender is the router. These lines change neither the `Paid to` whose amount stands gives none: the `Amount` line gives its amount, `Token In`
lines nor the minimum's label. A step goes unread when its recipient does not its token, and its spender is the router. These lines change neither the
decode as an address, when it is a V3 swap step whose path is shorter than one `Paid to` lines nor the minimum's label. A step goes unread when its recipient
token address or has bytes after its last hop, or when it is a command the does not decode as an address, when it is a V3 swap step whose path is shorter
decoder does not read: every command other than `PERMIT2_PERMIT`, 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`,
-8
View File
@@ -1379,13 +1379,6 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// balances that came back — by address, onto whatever storage holds NOW. // balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a // Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip. // wallet or address added, renamed or deleted during the round trip.
//
// Nothing is applied when the network stored NOW is not the one the refresh
// ran on. A network switch during the round trip cleared every address's
// balances (applyChainSwitchFields()), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network.
async function backgroundRefresh() { async function backgroundRefresh() {
const s = await getState(); const s = await getState();
const now = Date.now(); const now = Date.now();
@@ -1409,7 +1402,6 @@ async function backgroundRefresh() {
} }
await updateState((fresh) => { await updateState((fresh) => {
if (fresh.networkId !== s.networkId) return;
for (const wallet of fresh.wallets) { for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) { for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase()); const got = refreshed.get(String(addr.address).toLowerCase());
+4 -35
View File
@@ -68,54 +68,23 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// changes only the stored record. A switch while the refresh ran cleared every
// address's balances (applyChainSwitchFields() in
// src/shared/chainSwitchFields.js), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped
// refresh is not recorded as done. Results are copied by address, so an address
// added or removed meanwhile is left as it is.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
try { try {
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, state.wallets,
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
networkId, state.networkId,
pageClosed.signal, pageClosed.signal,
), ),
]); ]);
const refreshed = new Map(); state.lastBalanceRefresh = Date.now();
for (const wallet of wallets) { await saveState();
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
}
}
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
const got = refreshed.get(addr.address);
if (!got) continue;
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList(); renderWalletList();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // Every call site fires this and walks away — the boot below, the ten
+9 -16
View File
@@ -467,7 +467,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// (caught by tests/txStatus.test.js). A page's live picture of a field it // (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. // before this fix; only the persisted record is guaranteed current.
async function saveStateOnce(beforeWrite) { async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -481,12 +485,6 @@ async function saveStateOnce(beforeWrite) {
// come from storage in its loaded (self-healed) shape. See // come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js. // normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask); const fresh = normalizePersisted(result.autistmask);
if (beforeWrite && beforeWrite(fresh) === false) return;
// A copy, so what this save compares and writes is the page's state as it
// stood once the stored record was read. A change made while it waits on
// the write is left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const merged = { ...fresh }; const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) { for (const key of PERSISTED_FIELDS) {
@@ -581,15 +579,10 @@ function reportSaveFailure(err) {
// The same lock orders this page's own saves. showView() calls saveState() on // The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be // every navigation without awaiting it, so two saves from one page can be
// asked for at once; they take the lock in the order they were asked, and each // asked for at once; they take the lock in the order they were asked, and each
// compares the page's state, as it stands once the stored record has been read // compares the page's state as it stands when its turn begins against the
// and `beforeWrite` has run, against the baseline the save before it left. // baseline the save before it left.
// function saveState() {
// `beforeWrite`, when given, is called inside the lock with the stored record const turn = withLock("autistmask", saveStateOnce);
// as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. When it returns false, nothing is
// written; any other return lets the save go ahead.
function saveState(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
// one. The returned promise still rejects, so a caller that DOES await // one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling. // keeps its own error handling.
+24 -26
View File
@@ -1274,8 +1274,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 before a step set the input side names
// and the Amount line is formed there, in one place (see // Token In, and the Amount line is 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;
@@ -1477,12 +1477,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
stepsUnread.push(i); stepsUnread.push(i);
} }
// Only a permit whose amount stands as the Amount figure // After a step set the input side, a permit names
// names Token In (see permitStands). Every other permit, // neither Token In nor the Amount figure: the swap's own
// before the step that sets the input side or after it, // input token and amount stay on those lines.
// leaves Token In and Amount as they read without it 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) { 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: one in `userPaid`, when its token is the
@@ -1955,21 +1952,22 @@ 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 before a step set the input side names
// Amount figure in place of the total, which it does only in the // Token In. Its amount stands as the Amount figure in place of the
// plain shape of Uniswap's own routes: it comes before the step that // total only in the plain shape of Uniswap's own routes: it is the
// set the input side, it is the only PERMIT2_PERMIT in the // only PERMIT2_PERMIT in the transaction, its spender is the router,
// transaction, its spender is the router, the router may not let it // the router may not let it fail, no step before it took its token or
// fail, no step before it took its token or one not known, and this // one not known, and this decoder read every step after it in full
// decoder read every step after it in full (see stepsUnread). Permit2 // (see stepsUnread). Permit2 then lets the router take no more of the
// then lets the router take no more of the token than that amount, so // token than that amount, so the permit covers every take of it. A
// the permit covers every take of it. A later step this decoder did // later step this decoder did not read may raise what Permit2 lets
// not read may raise what Permit2 lets the router take, as a // the router take, as a PERMIT2_PERMIT_BATCH can. A step that may
// PERMIT2_PERMIT_BATCH can. Every other PERMIT2_PERMIT leaves Token In // take from the sender what this decoder did not read leaves no
// and the Amount line as they read without it, and gets lines of its // figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// own below. A step that may take from the sender what this decoder // step leaves both sides unknown (below). A PERMIT2_PERMIT after a
// did not read leaves no figure, permit or not (see spentUnread()), // step set the input side counts among the PERMIT2_PERMITs here.
// and an EXECUTE_SUB_PLAN step leaves both sides unknown (below). // Every PERMIT2_PERMIT but one whose amount stands gets lines of its
// own below.
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -1996,17 +1994,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;
+1 -49
View File
@@ -19,10 +19,7 @@
// that followed persisted the pre-refresh values — while still stamping // that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo. // lastBalanceRefresh, suppressing the redo.
// //
// A network switch landing inside a balance refresh's round trip is covered at // Both use the real persistence path over a cloning storage stub. Nothing here
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
//
// All use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it // asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right. // holds against any implementation that gets the outcome right.
@@ -398,48 +395,3 @@ describe("a balance refresh under another handler's state read", () => {
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE); expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
}); });
}); });
describe("a balance refresh under a network switch", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
// address's balances, and a refresh that started on the previous network
// must not write that network's back over them when it returns. The same
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// The user switches to Ethereum Mainnet in Settings while the round
// trip is out.
const during = bg.persisted();
applyChainSwitchFields(during, MAINNET.id);
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
expect(ranOn).toEqual(["sepolia", "mainnet"]);
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});
-160
View File
@@ -1,160 +0,0 @@
// A balance refresh the popup started on one network, and that returns after
// the network was switched to another, writes nothing: the switch cleared every
// address's balances, and the previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
// by the user in this popup's Settings, or by the background after the user
// approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
afterEach(() => {
cleanupPopup();
});
// Starts the ten-second refresh and holds it open. The balance stand-in writes
// Ethereum Mainnet's balances in place, on the records handed in, as the real
// refreshBalances() does, once the returned function is called. Resolves with
// that function and the refresh still running.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
});
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
// recorded as done.
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The stored record still holds Ethereum Mainnet while the refresh's save reads
// 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 writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
const refresh = await holdRefresh(page);
await page.click("btn-settings");
// The next read of the stored record, the refresh's save's, is held.
const get = page.storage.local.get;
const read = get.getMockImplementation();
let releaseRead;
get.mockImplementationOnce(async (key) => {
await new Promise((resolve) => {
releaseRead = resolve;
});
return read(key);
});
const finishing = refresh.finish();
await page.settle();
// The user picks Sepolia meanwhile; the switch's own save waits for the
// refresh's save to let the lock go.
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
releaseRead();
await finishing;
await switching;
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
test("a refresh that returns after the background switched the network writes nothing", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { saveState } = require("../src/shared/state");
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
await refresh.finish();
expectNothingStored(page);
// Nor does a later save by this page, such as every navigation makes.
await saveState();
expectNothingStored(page);
});
+7 -38
View File
@@ -2261,10 +2261,10 @@ describe("uniswap decoder", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_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 // 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. // 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 // The swap's own token and amount now stay on those lines. That
// In now. Every other, before the swap steps or after them, leaves // permit, and every other whose amount does not stand as the Amount
// Token In and Amount as they read without it and gets lines of its // figure, gets lines of its own; a permit before the swap steps still
// own. // names Token In.
describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => { describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => {
const DAI_5000 = 5000000000000000000000n; const DAI_5000 = 5000000000000000000000n;
const USDT_5000 = 5000000000n; const USDT_5000 = 5000000000n;
@@ -2336,40 +2336,8 @@ describe("uniswap decoder", () => {
ROUTER_ADDR, 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", "$kind shows the swap's 600 USDT and the permit's own lines",
({ commands, inputs, lines }) => { ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
@@ -2384,7 +2352,8 @@ describe("uniswap decoder", () => {
}, },
); );
// With two permits neither amount stands, so the Amount line // The permit before the swap steps still names Token In. With a
// second permit its amount does not stand, so the Amount line
// gives the total the steps take of USDT, the 400 DAI the last // 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. // 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", () => { 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", () => {
+2 -8
View File
@@ -2827,8 +2827,7 @@ describe("the allow-revert flag", () => {
// The permit names the router at ROUTER as its spender, so its // The permit names the router at ROUTER as its spender, so its
// amount stands as the Amount figure only there, and only without // amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines // the flag. With the flag it stands on no router and gets lines
// of its own, and the swap step names Token In: the same token, // of its own.
// its address in lowercase.
const permitLines = [ const permitLines = [
"Permit2 allowance", "Permit2 allowance",
"Allowance token", "Allowance token",
@@ -2837,14 +2836,9 @@ describe("the allow-revert flag", () => {
const swapLines = (r) => const swapLines = (r) =>
r.details.filter( r.details.filter(
(d) => (d) =>
!["Steps", "Token In", ...permitLines].includes( d.label !== "Steps" && !permitLines.includes(d.label),
d.label,
),
); );
expect(swapLines(withFlag)).toEqual(swapLines(plain)); expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(detail(withFlag, "Token In").address.toLowerCase()).toBe(
detail(plain, "Token In").address.toLowerCase(),
);
expect( expect(
labels(withFlag).filter((l) => permitLines.includes(l)), labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines); ).toEqual(permitLines);