Author SHA1 Message Date
sneak 628bc07c27 fix: a V4 SETTLE of ETH paid from the router reads as moved from the sender (closes #682)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
V4Router pays a SETTLE of ETH from the router's own ETH, whichever payer
it names. The router holds the transaction's value. So a SETTLE of ETH
that names the router as payer now counts as paid by the user.

When an earlier step left ETH in the router, such a SETTLE gets lines of
its own. Its line reads "Up to" and its amount, and that amount never
joins the Amount total. When the decoder does not know the SETTLE's
amount, as for CONTRACT_BALANCE, its line reads "Unknown (could not be
decoded)", also on the step that sets the input side.

One existing row in tests/uniswapRecipient.test.js now expects the ETH
lines. Its SETTLE spends all the router's ETH, the transaction's value
included.

Model: opus-5-5
2026-10-27 05:18:43 +00:00
16 changed files with 170 additions and 1397 deletions
+61 -64
View File
@@ -905,8 +905,8 @@ src/
prices.js — ETH/USD and token/USD from Chainlink price feeds prices.js — ETH/USD and token/USD from Chainlink price feeds
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
the active network, read from storage read from storage for the requests those settings gate
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded) price feeds (hardcoded)
@@ -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,8 +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
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -3393,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
@@ -3521,56 +3522,52 @@ Currently supported:
none): one of a token other than `Token In`; one of `Token In` after an amount none): one of a token other than `Token In`; one of `Token In` after an amount
that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their
wording on the `Amount` line, or no stated amount, as of a V4 exact-out step); wording on the `Amount` line, or no stated amount, as of a V4 exact-out step);
an ETH take whose line reads `Up to` (see above); and one whose amount the one of `Token In` that ETH an earlier step left in the router may pay part of;
decoder does not know. When there is no `Amount` line, each take the total and one whose amount the decoder does not know. When there is no `Amount`
counts gets them too, so that every amount the decoder reads is shown: the line, each take the total counts gets them too, so that every amount the
amount the step that sets the input side states, when it is a quantity and the decoder reads is shown: the amount the step that sets the input side states,
user pays that step (a V2 or V3 step whose `payerIsUser` is true, a V4 step when it is a quantity and the user pays that step (a V2 or V3 step whose
the user pays some of its input token, or a `WRAP_ETH`, whose ETH comes from `payerIsUser` is true, a V4 step the user pays some of its input token, or a
the transaction's value), and each later take of `Token In` added to it. A `WRAP_ETH`, whose ETH comes from the transaction's value), and each later take
step that sets the input side gets none when the user pays it none of its of `Token In` added to it. A step that sets the input side gets none when the
input token. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins of user pays it none of its input token. A `PERMIT2_PERMIT` the decoder cannot
600 USDT and 400 DAI paid by the user, give lines for both amounts. When the read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines
step that sets the input side states no amount, the user pays it some of its for both amounts. When the step that sets the input side states no amount, the
input token, and there is no `Amount` line, that step gets them too whenever user pays it some of its input token, and there is no `Amount` line, that step
another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gets them too whenever another take's lines give a figure, or a
gives one, on the lines below or, into the router, on its `Paid to` line, so `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
that figure cannot read as all that leaves. When it states no amount and the the router, on its `Paid to` line, so that figure cannot read as all that
user pays it none of its input token, that step never gets them, with a permit leaves. When it states no amount and the user pays it none of its input token,
or without. They come in step order, except that the step that sets the input that step never gets them, with a permit or without. They come in step order,
side comes first when it has them, followed, when there is no `Amount` line, except that the step that sets the input side comes first when it has them,
by the other takes the total counts: `Moved from the sender`, its amount, followed, when there is no `Amount` line, by the other takes the total counts:
worded as the `Amount` line words one, or `Unknown (could not be decoded)` `Moved from the sender`, its amount, worded as the `Amount` line words one, or
when the decoder does not know it; `Token moved`, giving the token's address `Unknown (could not be decoded)` when the decoder does not know it;
as the `Token In` line does; and `Moved to`, which reads `Token moved`, giving the token's address as the `Token In` line does; and
`The swap step that spends it`. A token the decoder does not know gives the `Moved to`, which reads `The swap step that spends it`. A token the decoder
`Moved from the sender` line alone. A step may also take from the user's does not know gives the `Moved from the sender` line alone. A step may also
wallet what the decoder did not read: a V4 step whose input or one of whose take from the user's wallet what the decoder did not read: a V4 step whose
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the
or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it router still runs; a V2 or V3 exact-in or exact-out step or a
cannot read, such as a V3 step whose path is shorter than one token address or `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every path is shorter than one token address or has bytes after its last hop; every
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such `PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
step gives one `Moved from the sender` line among those, reading and every step it fails on. Each such step gives one `Moved from the sender`
`Unknown (could not be decoded)`. That line, or any line for a token the line among those, reading `Unknown (could not be decoded)`. That line, or any
decoder does not know, leaves no `Amount` line, whose figure might be too low, line for a token the decoder does not know, leaves no `Amount` line, whose
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into
order, gives three lines: `Moved from the sender`, the amount it moves, for the router, in step order, gives three lines: `Moved from the sender`, the
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the
`Token In` line does; and `Moved to`, which reads 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
+14 -47
View File
@@ -22,7 +22,6 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -99,7 +98,6 @@ let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
async function loadTransactions(address) { async function loadTransactions(address) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -116,74 +114,43 @@ async function loadTransactions(address) {
}); });
const txs = result.transactions; const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so // Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just // that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names. // the ones that were originally entered as ENS names.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
names = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
// The rows then show no ENS names. ensNameMap = new Map();
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+14 -49
View File
@@ -29,7 +29,6 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -171,7 +170,6 @@ function show() {
} }
async function loadTransactions(address, tokenId) { async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -188,6 +186,16 @@ async function loadTransactions(address, tokenId) {
}); });
let txs = result.transactions; let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only // Filter to this token only
if (tokenId === "ETH") { if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null); txs = txs.filter((tx) => tx.contractAddress === null);
@@ -199,75 +207,32 @@ async function loadTransactions(address, tokenId) {
); );
} }
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse // Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address. // lookups work for every displayed address.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
names = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
// The rows then show no ENS names. ensNameMap = new Map();
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+6 -34
View File
@@ -44,7 +44,6 @@ const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
function findActiveAddr() { function findActiveAddr() {
@@ -184,7 +183,6 @@ async function loadHomeTxs(ctx) {
} }
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
@@ -204,15 +202,6 @@ async function loadHomeTxs(ctx) {
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25 // Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set(); const seen = new Set();
let merged = []; let merged = [];
@@ -226,24 +215,14 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters); const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts, inside the save and // Persist any newly discovered fraud contracts
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) { if (filtered.newFraudContracts.length > 0) {
let saved = false; for (const addr of filtered.newFraudContracts) {
await saveState((stored) => { if (!state.fraudContracts.includes(addr)) {
if (state.networkId !== networkId) return false; state.fraudContracts.push(addr);
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
saved = true; }
}); await saveState();
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
merged = filtered.transactions; merged = filtered.transactions;
@@ -255,13 +234,6 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message); log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list"); const list = $("home-tx-list");
if (list) { if (list) {
list.innerHTML = list.innerHTML =
+7 -41
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,34 +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`.
async function saveEndpoint(field, url, networkId) {
let refused = false;
await saveState((stored) => {
refused =
state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false;
state[field] = url;
stored[field] = url;
});
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else {
showFlash("Saved.");
}
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
deleteWallet.init(ctx); deleteWallet.init(ctx);
@@ -366,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, {
@@ -386,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;
@@ -401,7 +364,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", url, net.id); state.rpcUrl = url;
await saveState();
showFlash("Saved.");
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -410,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");
@@ -425,7 +389,9 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("blockscoutUrl", 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
+1 -19
View File
@@ -47,30 +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.
//
// A balance 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).
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
let net = null; let net = null;
await saveState((stored) => { await saveState(() => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
clearPopupCaches(); clearPopupCaches();
}); });
return net; return net;
+2 -4
View File
@@ -591,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
+3 -19
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network // Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// from the stored profile. // profile.
// //
// Before each request that sends the user's data to Blockscout or Etherscan, the // Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting // wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,11 +7,6 @@
// made, and an answer that lands after the setting went off is dropped. A page's // made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just // own copy of the setting is not used, because another page may have just
// changed it. // changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi"); const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState"); const { normalizePersisted } = require("./persistedState");
@@ -29,15 +24,4 @@ async function storedSettingIsOn(name) {
} }
} }
// Whether `networkId` is the active network in the stored profile. A failed module.exports = { storedSettingIsOn };
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+50 -41
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,
}; };
} }
@@ -1629,13 +1616,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;
@@ -2342,9 +2329,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: 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 +2340,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 +2375,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
details.push({ // address reads as one of a token nothing knows: its amount in
label: "Permit2 allowance", // base units, and the zero address as its token.
value: if (p.token === "0x0000000000000000000000000000000000000000") {
p.amount >= MAX_UINT160 details.push({
? "Unlimited" label: "Permit2 allowance",
: amountText(p.amount, info).display, value:
}); p.amount >= MAX_UINT160
details.push(tokenMovedLine("Allowance token", info)); ? "Unlimited"
: unknownDecimalsAmount(p.amount),
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
@@ -2456,7 +2462,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 +2470,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 +2489,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);
});
-493
View File
@@ -1,493 +0,0 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
-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.
-49
View File
@@ -1,49 +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.");
},
);
-72
View File
@@ -177,78 +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's save writes only the token lists that this page changed.
// So DAI stays in `trackedTokens`, the active network's list.
test("a token another page tracked before a switch in Settings stays in trackedTokens", 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([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
// Another write holds the lock, so the switch's save waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
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");
+7 -89
View File
@@ -3440,82 +3440,6 @@ describe("uniswap decoder", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
// with an ETH figure.
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
const MOVED_AMOUNT =
"1000000000000000000 base units (decimals unknown)";
test("to another address names the address", () => {
const result = decodeSteps(
[0x00, 0x02],
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
});
test("into the router names the address", () => {
const result = decodeSteps(
[0x02, 0x00],
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
);
expect(detail(result, "Paid to").value).toBe(
"The router, " +
MOVED_AMOUNT +
" from the sender (later steps may not spend all of it)",
);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: ZERO_ADDR,
address: ZERO_ADDR,
isToken: true,
});
});
// The V4 step's SETTLE names the user as payer and states no
// amount. The step's lines say that the ETH amount is not known.
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
const v4EthExactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
590000000n,
ONE_WETH,
),
encodeV4Settle(ZERO_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x02],
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", MOVED_AMOUNT],
["Token moved", ZERO_ADDR],
["Moved to", OTHER_ADDR],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE // 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, // 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 // and the router holds the transaction's value. On next at 36b2002 a
@@ -4380,9 +4304,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 +4320,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toContain("USDT");
"Unknown (not named in the calldata)", expect(detail(result, "Min. paid out").value).toBe(
); "None (no minimum guaranteed)",
expect(detail(result, "Min. paid out")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Paid to").value).toBe(
"The router, where it stays (no later step pays it out)",
);
expect(detail(result, "Token moved into the router").address).toBe(
WETH_ADDR,
); );
}); });
+5 -60
View File
@@ -688,62 +688,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
]); ]);
}); });
// The same route, then a balance check of the sender's USDC with a zero
// minimum. The check guarantees nothing, so Token Out stays ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
balanceCheck(USDC, 0n),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
test.each([
{
kind: "before the UNWRAP_WETH",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{
kind: "after the UNWRAP_WETH",
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
},
])(
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
({ steps }) => {
const result = decode(
execute(
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
...steps,
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// A TAKE the decoder cannot read may pay part of the WETH to the user. // A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => { test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode( const result = decode(
@@ -2876,16 +2820,17 @@ describe("the allow-revert flag", () => {
}, },
); );
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => { test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH); const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe( expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH", "1.0000 WETH",
); );
const withFlag = decode(execute(flagged(check))); const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED); expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out")).toBeUndefined(); expect(detail(withFlag, "Min. paid out").value).toBe(
expect(detail(withFlag, "Min. received")).toBeUndefined(); "None (no minimum guaranteed)",
);
}); });
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.