1 Commits
Author SHA1 Message Date
sneak f30b77e1d4 fix: a permit on the zero address never reads as ETH (closes #660)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Permit2 has no allowance of ETH, but the swap decoder read a permit's
zero-address token as ETH. Its Allowance token line read ETH. A permit of
1 wei of the zero address before a V4 step of 5 ETH stood as the Amount
figure, which then read 0.000000000000000001 ETH. With that permit to
another spender, there was no Amount line.

Such a permit now never stands as the Amount figure. To another spender,
it no longer removes the Amount line either. Its lines give the amount in
base units and name the zero address, as for a token nothing knows.

Model: opus-5-5
2026-10-27 04:02:26 +00:00
13 changed files with 212 additions and 1517 deletions
+98 -110
View File
@@ -1277,18 +1277,22 @@ see Transaction Decoding), do not always read as a number. They can read:
step pays nothing itself and swaps whatever an earlier step sent to the pair, step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero. such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
a V2, V3 or V4 swap's minimum of zero. The router passes a minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a `BALANCE_CHECK_ERC20` step's `minBalance`. Before
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both read `0.0000` when the token's scale was known. The router passes a balance
lines, as a swap step does. Only a check of the transaction's sender's own check whenever the balance is at least `minBalance`, so a zero `minBalance`
balance sets either line; a check of any other address sets neither (see guarantees nothing: it sets `Token Out` and `Min. received` only when the
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail output side holds no minimum, not even a zero one, at the point the check is
without reverting the transaction (see Transaction Decoding) guarantees reached, and otherwise leaves the current token and figure in place. A nonzero
nothing either, whatever its `minBalance`, and is read as one whose `minBalance` sets both lines, as a swap step does. Only a check of the
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail transaction's sender's own balance sets either line; a check of any other
is likewise read as zero. address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -2277,11 +2281,6 @@ view would leave a wallet one click from deletion.
- Endpoint URL input + "Save" button. "Save" asks the URL for `/stats` - Endpoint URL input + "Save" button. "Save" asks the URL for `/stats`
and saves the URL only if that request succeeds. The request carries and saves the URL only if that request succeeds. The request carries
no user data and is made whether or not "Use Blockscout" is ticked. no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -3396,8 +3395,7 @@ Currently supported:
`Balance Check (of another address)`. A check whose input does not decode, `Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. A check with a zero `minBalance` as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a `TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out paid it into ETH, that token stays in the router unless a later step pays out
@@ -3479,101 +3477,91 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step that pays rest taken to another address, reads `5000.0000 USDT`. A later step paid from
an ERC-20 token from the router takes none of that token from the user's the router spends what the router holds and takes nothing, and so does a V2
wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either. exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is
A V4 `SETTLE` of ETH paid from the router still takes from the user's wallet, added to the total while both are quantities: a V3 exact-in of 600 USDT and a
since the router holds the transaction's value. When the decoder does not know V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts the decoder does not know a take's amount, the total is not known and there is
as a later step's take. When an earlier step left ETH in the router, that ETH no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount,
may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then `Unlimited` included, stands as the `Amount` figure in place of the total,
gets lines of its own, with `Up to` before its amount. A later take of which it does only in the plain shape of Uniswap's own routes: it comes before
`Token In` that only the user's wallet pays is added to the total while both the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
paid by the user, read `1000.0000 USDT`. When the decoder does not know a the router may not let it fail, no step before it takes its token, a
take's amount, the total is not known and there is no `Amount` line. A `PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know,
`PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included, and the decoder reads every step after it in full. Permit2 then lets the
stands as the `Amount` figure in place of the total, which it does only in the router take no more of the token than that amount, so the permit covers every
plain shape of Uniswap's own routes: it comes before the step that sets the take of it. A step before the permit takes under whatever allowance the router
input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no already had, so it may take more than the permit's amount, which would then
`PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of
fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it 2,000 USDT into the router, then a permit of 600 USDT to the router and a V3
included, or a token the decoder does not know, and the decoder reads every exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A
step after it in full. Permit2 then lets the router take no more of the token later permit or batch may raise what Permit2 lets the router take, and so may
than that amount, so the permit covers every take of it. A step before the a step the decoder does not read in full: a command the decoder does not read
permit takes under whatever allowance the router already had, so it may take (listed at the end of this section), a step whose input it cannot read, in
more than the permit's amount, which would then read lower than what leaves whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router, the step that sets the input side or after it, leaves `Token In` and the
then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid `Amount` line as they read with no permit, unless it lets a spender other than
from the router, read `Amount 2000.0000 USDT`. A later permit or batch may the router take `Token In` (see below): a permit of 5,000 DAI to another
raise what Permit2 lets the router take, and so may a step the decoder does address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
not read in full: a command the decoder does not read (listed at the end of `Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
this section), a step whose input it cannot read, in whole or in part, and a to the router. A permit on the zero address never stands as the `Amount`
step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the figure, because Permit2 has no allowance of ETH. When a permit's amount stands
input side or after it, leaves `Token In` and the `Amount` line as they read and the step that sets the input side spends a token other than the permit's,
with no permit, unless it lets a spender other than the router take `Token In` or one the decoder does not know, that step's amount gets lines of its own,
(see below): a permit of 5,000 DAI to another address and then a V3 exact-in unless it states no amount and the user pays it none of its input token. Each
of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do take the total does not count gives three lines after the `Amount` line (after
that exact-in and then a permit of 5,000 DAI to the router. A permit on the `Token In` when there is none): one of a token other than `Token In`; one of
zero address never stands as the `Amount` figure, because Permit2 has no `Token In` after an amount that is not a quantity (a V4 open delta,
allowance of ETH. When a permit's amount stands and the step that sets the `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
input side spends a token other than the permit's, or one the decoder does not amount, as of a V4 exact-out step); and one whose amount the decoder does not
know, that step's amount gets lines of its own, unless it states no amount and know. When there is no `Amount` line, each take the total counts gets them
the user pays it none of its input token. Each take the total does not count too, so that every amount the decoder reads is shown: the amount the step that
gives three lines after the `Amount` line (after `Token In` when there is sets the input side states, when it is a quantity and the user pays that step
none): one of a token other than `Token In`; one of `Token In` after an amount (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
wording on the `Amount` line, or no stated amount, as of a V4 exact-out step); value), and each later take of `Token In` added to it. A step that sets the
an ETH take whose line reads `Up to` (see above); and one whose amount the input side and is paid from the router takes nothing from the user's wallet
decoder does not know. When there is no `Amount` line, each take the total and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
counts gets them too, so that every amount the decoder reads is shown: the of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
amount the step that sets the input side states, when it is a quantity and the the step that sets the input side states no amount, the user pays it some of
user pays that step (a V2 or V3 step whose `payerIsUser` is true, a V4 step its input token, and there is no `Amount` line, that step gets them too
the user pays some of its input token, or a `WRAP_ETH`, whose ETH comes from whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of
the transaction's value), and each later take of `Token In` added to it. A its token gives one, on the lines below or, into the router, on its `Paid to`
step that sets the input side gets none when the user pays it none of its line, so that figure cannot read as all that leaves. When it states no amount
input token. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins of and the user pays it none of its input token, as when the router pays it, that
600 USDT and 400 DAI paid by the user, give lines for both amounts. When the step takes none of that token from the user's wallet and never gets them, with
step that sets the input side states no amount, the user pays it some of its a permit or without. They come in step order, except that the step that sets
input token, and there is no `Amount` line, that step gets them too whenever the input side comes first when it has them, followed, when there is no
another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token `Amount` line, by the other takes the total counts: `Moved from the sender`,
gives one, on the lines below or, into the router, on its `Paid to` line, so its amount, worded as the `Amount` line words one, or
that figure cannot read as all that leaves. When it states no amount and the `Unknown (could not be decoded)` when the decoder does not know it;
user pays it none of its input token, that step never gets them, with a permit `Token moved`, giving the token's address as the `Token In` line does; and
or without. They come in step order, except that the step that sets the input `Moved to`, which reads `The swap step that spends it`. A token the decoder
side comes first when it has them, followed, when there is no `Amount` line, does not know gives the `Moved from the sender` line alone. A step may also
by the other takes the total counts: `Moved from the sender`, its amount, take from the user's wallet what the decoder did not read: a V4 step whose
worded as the `Amount` line words one, or `Unknown (could not be decoded)` input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the
when the decoder does not know it; `Token moved`, giving the token's address router still runs; a V2 or V3 exact-in or exact-out step or a
as the `Token In` line does; and `Moved to`, which reads `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
`The swap step that spends it`. A token the decoder does not know gives the path is shorter than one token address or has bytes after its last hop; every
`Moved from the sender` line alone. A step may also take from the user's `PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
wallet what the decoder did not read: a V4 step whose input or one of whose and every step it fails on. Each such step gives one `Moved from the sender`
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 line among those, reading `Unknown (could not be decoded)`. That line, or any
or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it line for a token the decoder does not know, leaves no `Amount` line, whose
cannot read, such as a V3 step whose path is shorter than one token address or figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the
has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such the router, in step order, gives three lines: `Moved from the sender`, the
step gives one `Moved from the sender` line among those, reading amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the
`Unknown (could not be decoded)`. That line, or any line for a token the token's address as the `Token In` line does; and `Moved to`, which reads
decoder does not know, leaves no `Amount` line, whose figure might be too low,
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
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. For a move of the zero where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
address, `Moved from the sender` gives the amount in base units and amount does not stand as the `Amount` figure, before the step that sets the
`Token moved` gives the zero address. For such a move into the router, the input side or after it, gives three lines, in step order, after those for each
`Paid to` line gives the amount in base units and `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
whose amount does not stand as the `Amount` figure, before the step that sets
the input side or after it, gives three lines, in step order, after those for
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose `Allowance spender`, the address it lets take the token. Only a permit whose
+7 -55
View File
@@ -44,12 +44,6 @@ let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved
// elsewhere sets `state` to the new network first. Settings is drawn again only
// once the follow's write returns. So both "Save" buttons take this network,
// not the one in `state`.
let shownNetwork = null;
// One row per site origin, however many addresses it appears under, each with // One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove. // an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) { function renderSiteList(containerId, origins, onRemove) {
@@ -205,7 +199,6 @@ function renderWalletListSettings() {
} }
function show() { function show() {
shownNetwork = currentNetwork();
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
@@ -313,7 +306,6 @@ function showNewSettings() {
state.checkRecipientsOnEtherscan; state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") { if (state.currentView === "settings") {
markViewRendered("settings"); markViewRendered("settings");
shownNetwork = currentNetwork();
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
@@ -327,48 +319,6 @@ function showNewSettings() {
updateDebugBanner(state.currentView); updateDebugBanner(state.currentView);
} }
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The
// URL is set in `state` inside the save, holding the lock. So the page cannot
// follow a switch saved elsewhere (followStoredSettings() in
// src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
//
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
let refused = false;
let inputChanged = false;
await saveState((stored) => {
refused =
state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url;
if (refused || inputChanged) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else {
showFlash("Saved.");
}
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
@@ -380,8 +330,6 @@ function init(_ctx) {
showFlash("Please enter an RPC URL."); showFlash("Please enter an RPC URL.");
return; return;
} }
// The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url, { const resp = await debugFetch(url, {
@@ -400,6 +348,7 @@ function init(_ctx) {
showFlash("Endpoint returned an error."); showFlash("Endpoint returned an error.");
return; return;
} }
const net = currentNetwork();
if (json.result !== net.chainId) { if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + "."); showFlash("Wrong network: expected " + net.name + ".");
return; return;
@@ -415,7 +364,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id); state.rpcUrl = url;
await saveState();
showFlash("Saved.");
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -424,7 +375,6 @@ function init(_ctx) {
showFlash("Please enter a Blockscout API URL."); showFlash("Please enter a Blockscout API URL.");
return; return;
} }
const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url + "/stats"); const resp = await debugFetch(url + "/stats");
@@ -439,7 +389,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id); state.blockscoutUrl = url;
await saveState();
showFlash("Saved.");
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
-12
View File
@@ -311,21 +311,10 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. Opening another transaction replaces the one TransactionDetail
// holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx;
return tx?.hash === txHash && tx?.chainId === chainId;
}
// Reads the block, network details and calldata from Blockscout, and decodes // Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not // the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown. // (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has opened another transaction. That
// answer is dropped.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
@@ -344,7 +333,6 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+2 -33
View File
@@ -47,43 +47,12 @@ function clearPopupCaches() {
// lock, so it is never switched while a follow of a switch saved elsewhere // lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followStoredSettings() in src/popup/index.js): that // waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it. // follow runs first, and this switch is made after it.
//
// The save writes only the fields that differ from what this page last saved.
// When this page last saved a balance of "0", clearing that balance is no
// change. So the switch also resets these fields in the stored record:
//
// - every address's balance and token balances;
// - the time of the last balance refresh;
// - the fraud contracts.
//
// A balance or a fraud contract that the background or another page stored
// since this page's last save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState, takeStoredTrackedTokens } = require("./state"); const { state, saveState } = require("./state");
let net = null; let net = null;
await saveState((stored) => { await saveState(() => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
}
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches(); clearPopupCaches();
}); });
return net; return net;
+9 -37
View File
@@ -125,12 +125,11 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// `baseline` holds the persisted fields as this page held them when its last // The persisted fields as this page held them when its last loadState()
// loadState() finished, or when its last successful saveState() began. // finished, or when its last successful saveState() began, with any network
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what // switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// this page has taken from the stored record since then. saveState() diffs the // saveState() diffs the live state against this to find only the fields THIS
// live state against `baseline` to find only the fields THIS page changed // page changed since.
// since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -443,10 +442,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// milder than the other two. // milder than the other two.
// //
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element // `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in // per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves // `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above, // together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset // but nothing in src/ ever writes an entry into it — it is only ever reset
@@ -594,10 +591,8 @@ function reportSaveFailure(err) {
// //
// `beforeWrite`, when given, is called inside the lock with the stored record // `beforeWrite`, when given, is called inside the lock with the stored record
// as just read, before this page's state is compared against it. It may change // as just read, before this page's state is compared against it. It may change
// `state`, and this save writes that change. It may also change the stored // `state`, and this save writes that change. When it returns false, nothing is
// record. A field that this page did not change is then written as // written; any other return lets the save go ahead.
// `beforeWrite` left it. When it returns false, nothing is written; any other
// return lets the save go ahead.
function saveState(beforeWrite) { function saveState(beforeWrite) {
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite)); const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
// Every failed save is reported, whether or not the caller awaited this // Every failed save is reported, whether or not the caller awaited this
@@ -618,28 +613,6 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
} }
// Give this page the tracked tokens of `stored`, a record on this page's
// network that a save of this page has just read (onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`):
//
// - A token that another page tracked or removed since this page's last save
// is tracked or removed in `state` too.
// - A token that this page tracked or removed since then stays as this page
// has it.
//
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -686,7 +659,6 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
takeStoredTrackedTokens,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+67 -113
View File
@@ -479,17 +479,6 @@ function tokenInfo(address, sources) {
}; };
} }
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
// The zero address there reads as a token nothing knows: its amount in base
// units, and the zero address as its token.
function permit2TokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address };
}
return tokenInfo(address, sources);
}
// A swap amount line. With a scale it is the token quantity; with none it is // A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the // the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display` // ERC-20 amount line makes, so the screen has one way of saying it. `display`
@@ -508,18 +497,16 @@ function amountText(raw, info) {
// The line giving a moved token, or the token of a permit, under `label`, in // The line giving a moved token, or the token of a permit, under `label`, in
// the shape of the Token In line, so the screens show it as they show that // the shape of the Token In line, so the screens show it as they show that
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token. // one. ETH has no address.
// ETH has no address. function tokenMovedLine(label, token, sources) {
function tokenMovedLine(label, info) { const info = tokenInfo(token, sources);
if (!present(info.address)) { if (!present(info.address)) {
return { label, value: info.symbol }; return { label, value: info.symbol };
} }
return { return {
label, label,
value: info.symbol value: info.symbol ? info.symbol + " (" + token + ")" : token,
? info.symbol + " (" + info.address + ")" address: token,
: info.address,
address: info.address,
isToken: true, isToken: true,
}; };
} }
@@ -1253,10 +1240,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputEstablished = false; let inputEstablished = false;
// Whether the user pays the step that set the input side: a V2 or V3 // Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of // swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see paidByUser in the loop), or a WRAP_ETH, // its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// whose ETH comes from the transaction's value. A step that pays an // comes from the transaction's value. A step paid from the router
// ERC-20 token from the router takes none of that token from the // takes nothing from the sender.
// sender.
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 before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
@@ -1352,9 +1338,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount }, in step order. // that sets the input side, as { token, amount }, in step order.
// After the loop each joins the Amount line or gets lines of its own // After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than // (see permitStands).
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
@@ -1629,13 +1613,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
// The router passes this check whenever the owner // The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees // holds at least minBalance, so a zero one guarantees
// nothing. A check the router lets fail guarantees // nothing and does not replace a minimum an earlier
// nothing either, so it is read as a zero one. A zero // step stated. A check the router lets fail guarantees
// check sets neither Token Out nor the minimum. Any // nothing either, so it is read as a zero one. Any
// other minBalance sets the output side as a swap // other minBalance sets the output side as a swap
// does. The check pays nobody. // does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (minBalance !== 0n) { if (!(minBalance === 0n && present(minOutput))) {
setOutput(i, b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true; keepsRouterWeth = true;
@@ -1831,41 +1815,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished; const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer the SETTLE names. The router holds
// the transaction's value, and any ETH an earlier step
// left it (see heldByRouter). So the user also pays a
// SETTLE of ETH that names the router as payer. The user
// pays all of its amount when the router holds no other
// ETH, and up to its amount otherwise. CONTRACT_BALANCE
// pays all the router holds, an amount the calldata does
// not state.
const routerHeldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
const ethFromRouter = (v4?.settledFromRouter ?? [])
.filter(
(s) =>
s.currency ===
"0x0000000000000000000000000000000000000000",
)
.map((s) => ({
currency: s.currency,
amount:
s.amount === CONTRACT_BALANCE ? null : s.amount,
}));
// What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router of a known
// amount when the router holds no other ETH.
const paidByUser = v4
? v4.paidByUser.concat(
routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
)
: [];
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount // Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays. // decodeV4Swap() says it pays.
@@ -1896,10 +1845,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
} }
// The step's amount, if it sets the input side: what the // The step's amount, if it sets the input side: what the
// user pays it of its input currency, or, when the user // user pays it of its input currency (see
// pays none of it, what its swap actions spend of it, // decodeV4Swap()), or, when the user pays none of it,
// whoever pays that. // what its swap actions spend of it, whoever pays that.
const paidIn = paidByUser.find( const paidIn = v4?.paidByUser.find(
(p) => p.currency === v4.tokenIn, (p) => p.currency === v4.tokenIn,
); );
const amountIn = paidIn ? paidIn.amount : v4?.amountIn; const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1953,25 +1902,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it. // router still runs it.
const setInputHere = inputEstablished && !inputSetBefore; const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined; if (setInputHere) inputPaidByUser = paidIn !== undefined;
for (const p of paidByUser) { if (v4) {
for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue; if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount); addUserPaid(p.currency, p.amount);
} }
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
// the input side. Either this decoder does not know its
// amount, or ETH an earlier step left in the router may
// pay part of that amount (see userPaid).
for (const p of ethFromRouter) {
if (!present(p.amount)) {
addUserPaid(p.currency, null);
} else if (routerHeldEth) {
userPaid.push({
token: p.currency,
amount: p.amount,
atMost: true,
});
}
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
spentUnread(); spentUnread();
@@ -2078,10 +2013,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The total is what the steps take of Token In: the amount of the // The total is what the steps take of Token In: the amount of the
// step that set the input side, with each later take of that token // step that set the input side, with each later take of that token
// added to it while both are quantities. A take that cannot be added, // added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a // being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// `atMost` set, gets lines of its own. One whose amount is not known // lines of its own. One whose amount is not known also leaves the
// 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 // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2125,14 +2060,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as // The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of // { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of the step that set the input side // its own (see below). The amount of a step that set the input side
// counts in `total` whoever pays that step. That amount is among them // and is paid from the router counts in `total` but is not among them.
// only when the user pays the step (see inputPaidByUser).
const inTotal = []; const inTotal = [];
// Whether the step that set the input side counts as a take from the // Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts // sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it // whoever pays it. Stating none, it counts only when the user pays it
// some of its input token. // some of its input token: paid from the router, it takes nothing
// from the sender.
const hasFirstTake = const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser); inputEstablished && (present(inputAmount) || inputPaidByUser);
// 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
@@ -2158,8 +2093,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if ( if (
ofTokenIn && ofTokenIn &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" && typeof t.amount === "bigint"
!t.atMost
) { ) {
total += t.amount; total += t.amount;
inTotal.push(t); inTotal.push(t);
@@ -2262,9 +2196,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see // V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be // tests come first — the sentinels are not bigints and cannot be
// compared with one. `atMost` marks an amount that is only the most a // compared with one.
// take may be (see userPaid). const spentText = (amount, info) => {
const spentText = (amount, info, atMost) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} }
@@ -2277,7 +2210,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
if (hasV2OrV3ExactOut || atMost) { if (hasV2OrV3ExactOut) {
// A V2 or V3 exact-out swap spends at most this figure, // A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a // whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it // swap paid in ETH, a permit, or several steps' total), so it
@@ -2337,14 +2270,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost) ? spentText(s.amount, tokenInfo(s.token, sources)).display
.display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push( details.push(tokenMovedLine("Token moved", s.token, sources));
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({ details.push({
label: "Moved to", label: "Moved to",
value: "The swap step that spends it", value: "The swap step that spends it",
@@ -2355,12 +2285,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own. // where it goes, an address on a line of its own.
for (const m of movedFromSender) { for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: amountText(m.amount, info).display, value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
}); });
details.push(tokenMovedLine("Token moved", info)); details.push(tokenMovedLine("Token moved", m.token, sources));
details.push( details.push(
present(m.to) present(m.to)
? { label: "Moved to", value: m.to } ? { label: "Moved to", value: m.to }
@@ -2390,15 +2320,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
const info = permit2TokenInfo(p.token, sources); // Permit2 has no allowance of ETH. An allowance of the zero
// address reads as one of a token nothing knows: its amount in
// base units, and the zero address as its token.
if (p.token === "0x0000000000000000000000000000000000000000") {
details.push({ details.push({
label: "Permit2 allowance", label: "Permit2 allowance",
value: value:
p.amount >= MAX_UINT160 p.amount >= MAX_UINT160
? "Unlimited" ? "Unlimited"
: amountText(p.amount, info).display, : unknownDecimalsAmount(p.amount),
}); });
details.push(tokenMovedLine("Allowance token", info)); details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
@@ -2456,7 +2407,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
for (const m of movedIntoRouter) { for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, sources); const info = tokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
label: "Paid to", label: "Paid to",
value: value:
@@ -2464,7 +2415,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " from the sender (later steps may not spend all of it)",
}); });
paidTo.push(tokenMovedLine("Token moved into the router", info)); paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
} }
if (outputHeld) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -2481,7 +2434,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push( paidTo.push(
tokenMovedLine( tokenMovedLine(
"Token moved into the router", "Token moved into the router",
tokenInfo(getAddress(token), sources), getAddress(token),
sources,
), ),
); );
} }
-51
View File
@@ -6,10 +6,6 @@
// approved a site's request, which changes only the stored record. Driven // approved a site's request, which changes only the stored record. Driven
// through the real popup over a stored profile on Ethereum Mainnet. The same // through the real popup over a stored profile on Ethereum Mainnet. The same
// refresh in the background is in tests/backgroundStateIsolation.test.js. // refresh in the background is in tests/backgroundStateIsolation.test.js.
//
// The last test covers a balance refresh by the background that is saved while
// a switch in Settings waits for the lock
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
const { const {
bootPopup, bootPopup,
@@ -163,50 +159,3 @@ test("a refresh that returns after the background switched the network writes no
expectNothingStored(page); expectNothingStored(page);
}); });
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
await page.click("btn-settings");
// The background takes the lock first and holds it until the test calls
// `letGo()`. It then saves balances as backgroundRefresh() in
// src/background/index.js does.
let letGo;
const refreshSave = background.updateState(async (fresh) => {
await new Promise((resolve) => {
letGo = resolve;
});
fresh.wallets[0].addresses[0].balance = "7.0";
fresh.wallets[0].addresses[0].tokenBalances = [
{
address: TOKEN_ADDRESS,
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: null,
},
];
fresh.lastBalanceRefresh = Date.now();
});
await page.settle();
const select = page.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await page.settle();
letGo();
await refreshSave;
await switching;
await page.settle();
expectNothingStored(page);
});
-265
View File
@@ -253,271 +253,6 @@ test("a switch back in Settings after a follow whose write failed is stored", as
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId); expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
}); });
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
// URL is stored, the background saves a switch to Sepolia. Another write holds
// the lock, so the popup's follow of that switch and the URL's save both wait
// for the lock. Either may take it first. The URL is stored for neither
// network. The flash message says that the network changed.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "follow",
},
{
button: "btn-save-blockscout",
field: "settings-blockscout",
first: "save",
},
])(
"$button saves nothing when the network changes before the URL is stored ($first first)",
async ({ button, field, first }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks. The RPC check reads
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The click handler runs until its save waits for the lock.
const tapSave = async () => {
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
return saving;
};
let saving;
if (first === "save") saving = await tapSave();
await backgroundSwitch(page, "sepolia");
if (first === "follow") saving = await tapSave();
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The popup's follow of the background's switch to Sepolia has set `state` to
// Sepolia. The follow's write has not returned yet, so Settings still shows
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
// Mainnet, so the save stores the URL for neither network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing when tapped while the follow's write waits",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
const { set } = page.storage.local;
const write = set.getMockImplementation();
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
// The background's write lands; the next, the follow's, waits.
set.mockImplementationOnce(write).mockImplementationOnce(
async (items) => {
await writeHeld;
await write(items);
},
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(page.value("settings-network")).toBe("mainnet");
page.node(field).value = url;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseWrite();
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
// the node's answer, the popup follows the background's switch to Sepolia. The
// node answers with Sepolia's chain id. So the check refuses the node for
// Ethereum Mainnet, and nothing is stored.
test("the RPC check compares the answer with the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://sepolia-node.example.invalid";
page.node("settings-rpc").value = url;
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"Wrong network: expected Ethereum Mainnet.",
);
});
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
// waits for Blockscout's answer, the popup follows the background's switch to
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
test("the Blockscout save is for the network at the tap", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const url = "https://own-explorer.example.invalid/api/v2";
page.node("settings-blockscout").value = url;
const saving = page
.node("btn-save-blockscout")
.listeners.click.map((fn) => fn());
await page.settle();
await backgroundSwitch(page, "sepolia");
expect(page.value("settings-network")).toBe("sepolia");
answer({ ok: true });
await Promise.all(saving);
await page.settle();
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
});
// Settings is open when the network changes to Sepolia. The popup follows the
// background's switch, or the user picks Sepolia in the network selector. Each
// "Save" after the switch stores its URL for Sepolia.
test.each([
["a follow", (page) => backgroundSwitch(page, "sepolia")],
[
"the network selector",
async (page) => {
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
},
],
])(
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
async (_how, switchToSepolia) => {
const page = await bootOnAddress();
await page.click("btn-settings");
await switchToSepolia(page);
// This answer passes both endpoint checks on Sepolia. The RPC check
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.sepolia.chainId }),
});
const rpcUrl = "https://own-node.example.invalid";
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
page.node("settings-rpc").value = rpcUrl;
await page.click("btn-save-rpc");
expect(page.text("flash-msg")).toBe("Saved.");
page.node("settings-blockscout").value = blockscoutUrl;
await page.click("btn-save-blockscout");
expect(page.text("flash-msg")).toBe("Saved.");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.rpcUrl).toBe(rpcUrl);
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
},
);
// The user picks Sepolia in Settings, but the switch's write fails. The page is
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
// after the failed switch stores nothing for either network.
test.each([
{ button: "btn-save-rpc", field: "settings-rpc" },
{ button: "btn-save-blockscout", field: "settings-blockscout" },
])(
"$button saves nothing after a switch in Settings whose write failed",
async ({ button, field }) => {
const page = await bootOnAddress();
await page.click("btn-settings");
page.storage.local.set.mockRejectedValueOnce(
new Error("quota exceeded"),
);
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) {
await expect(fn()).rejects.toThrow("quota exceeded");
}
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
// This answer passes both endpoint checks on Ethereum Mainnet.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
const url = "https://own-node.example.invalid";
page.node(field).value = url;
await page.click(button);
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
url,
);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because the network changed.",
);
},
);
// 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.
-169
View File
@@ -1,169 +0,0 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
-161
View File
@@ -5,9 +5,6 @@
// a contract lookup that answers after one. Driven through the real popup over // a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in // a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js. // tests/coldWorkerChainSwitch.test.js.
//
// The tests of what another page stored before a switch in Settings cover a
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
const { const {
bootPopup, bootPopup,
@@ -180,164 +177,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
expect(onSepolia).not.toContain("DAI"); expect(onSepolia).not.toContain("DAI");
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
// last save. This page still holds DAI. The switch to Sepolia does not store
// DAI under Ethereum Mainnet again.
test("a token another page removed before a switch in Settings stays removed", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page removes DAI after this page's last save. That page then
// adds DAI again, typing the contract address in lower case. The two pages'
// entries for DAI match, because contract addresses are compared ignoring
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
// Mainnet.
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page saves a switch to Sepolia after this page's last save.
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
fraudContracts: [NEW_TOKEN_ADDRESS],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.fraudContracts).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
]);
});
describe("Back after a switch from the Settings gear", () => { describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => { test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet"); const boot = await bootOnAddToken("mainnet");
-202
View File
@@ -1,202 +0,0 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. An answer that lands while Settings is up
// must still fill TransactionDetail.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
// network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId,
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = {
[requestUrl(FIRST)]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[requestUrl(SECOND)]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in another.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
const FIRST_ON_SEPOLIA_SHOWN = [
"21000003",
"4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// The URLs of every request to Blockscout so far, in order.
function requestedUrls() {
return globalThis.fetch.mock.calls.map(([url]) => url);
}
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
const heldAnswer = new Promise((resolve) => {
answer = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
if (url === requestUrl(held)) await heldAnswer;
return { ok: true, status: 200, json: async () => ANSWERS[url] };
});
return { page, answer: () => answer() };
}
// The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(second);
await page.settle();
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
return page;
}
afterEach(() => {
cleanupPopup();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page);
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
});
+7 -212
View File
@@ -3439,205 +3439,6 @@ describe("uniswap decoder", () => {
]); ]);
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
// of ETH from the router's own ETH, whichever payer the SETTLE names,
// and the router holds the transaction's value. On next at 36b2002 a
// SETTLE of 1 ETH that names the router as payer gave no ETH line.
describe("a V4 SETTLE of ETH that names the router as payer", () => {
const ONE_ETH = 1000000000000000000n;
// A V4 swap of `amount` of ETH for USDC. The SETTLE names the
// router as payer.
const v4EthFromRouter = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, amount, false),
encodeV4TakeAll(USDC_ADDR, 1n),
],
);
test("after a V3 exact-in of 600 USDT, shows the 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, v4EthFromRouter(ONE_ETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// The UNWRAP_WETH leaves ETH in the router. The SETTLE may spend
// that ETH before the transaction's value.
test("after an UNWRAP_WETH into the router, shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x00, 0x0c, 0x10],
[
encodeV3SwapExactIn(ROUTER_ADDR, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
test("after a WRAP_ETH of 0.5 ETH, adds the SETTLE's 0.5 ETH to the Amount line", () => {
const result = decodeSteps(
[0x0b, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
v4EthFromRouter(ONE_ETH / 2n),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1.0000 ETH"],
]);
});
// The WRAP_ETH sets Token In to ETH, and the UNWRAP_WETH leaves
// ETH in the router. The SETTLE may spend that ETH before the
// transaction's value, so its 1 ETH never joins the Amount line.
test("after a WRAP_ETH and an UNWRAP_WETH into the router, keeps the Amount line and shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x0b, 0x0c, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "0.5000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// A SETTLE of CONTRACT_BALANCE pays all the router holds, an
// amount the calldata does not state.
test("of CONTRACT_BALANCE, on the step that sets the input side, shows an unknown amount moved from the sender", () => {
const result = decodeSteps(
[0x10],
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
ONE_ETH,
1n,
),
settle(ZERO_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
encodeV4TakeAll(USDC_ADDR, 1n),
],
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
});
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
@@ -4380,9 +4181,10 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("2.0000 USDC"); expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
}); });
// A zero balance check sets neither Token Out nor the minimum. The route // With no minimum stated before it, a zero balance check is what sets the
// pays only the WRAP_ETH's WETH, to the router, where it stays. // output side, and it guarantees nothing. No step pays anyone, so the
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => { // minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[ [
@@ -4395,16 +4197,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toContain("USDT");
"Unknown (not named in the calldata)", expect(detail(result, "Min. paid out").value).toBe(
); "None (no minimum guaranteed)",
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
); );
}); });
+14 -89
View File
@@ -688,62 +688,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]); ]);
}); });
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user. // A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => { test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode( const result = decode(
@@ -1717,18 +1661,9 @@ describe("any token left in the router is not received", () => {
), ),
min: "1990.0000 USDC", min: "1990.0000 USDC",
}, },
])("$kind reads as a swap paid to the user", ({ data, min }) => { {
const result = decode(data); kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
expect(labels(result)).toEqual(PAID_TO_USER); data: execute(
expect(detail(result, "Min. received").value).toBe(min);
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — the SETTLE spends all
// the router's ETH, the transaction's value included, so the sender pays
// an amount the calldata does not state.
test("a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router reads as a swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS), swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS), unwrapWeth(ADDRESS_THIS),
v4Swap( v4Swap(
@@ -1737,23 +1672,12 @@ describe("any token left in the router is not received", () => {
takeAll(USDC), takeAll(USDC),
), ),
), ),
); min: "1990.0000 USDC",
expect(labels(result)).toEqual([ },
"Protocol", ])("$kind reads as a swap paid to the user", ({ data, min }) => {
"Token In", const result = decode(data);
"Amount", expect(labels(result)).toEqual(PAID_TO_USER);
"Moved from the sender", expect(detail(result, "Min. received").value).toBe(min);
"Token moved",
"Moved to",
"Token Out",
"Min. received",
"Steps",
"Deadline",
]);
expect(detail(result, "Moved from the sender").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
}); });
}); });
@@ -2876,16 +2800,17 @@ describe("the allow-revert flag", () => {
}, },
); );
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => { test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH); const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe( expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH", "1.0000 WETH",
); );
const withFlag = decode(execute(flagged(check))); const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED); expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out")).toBeUndefined(); expect(detail(withFlag, "Min. paid out").value).toBe(
expect(detail(withFlag, "Min. received")).toBeUndefined(); "None (no minimum guaranteed)",
);
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.