Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
628bc07c27 |
@@ -905,8 +905,8 @@ src/
|
||||
prices.js — ETH/USD and token/USD from Chainlink price feeds
|
||||
scamlist.js — known fraud contract addresses
|
||||
state.js — persisted state (extension storage)
|
||||
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
|
||||
the active network, read from storage
|
||||
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
|
||||
read from storage for the requests those settings gate
|
||||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
||||
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
|
||||
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,
|
||||
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
||||
such meaning and is shown as a zero.
|
||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
|
||||
a V2, V3 or V4 swap's minimum of zero. The router passes a
|
||||
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
|
||||
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
|
||||
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
|
||||
lines, as a swap step does. Only a check of the transaction's sender's own
|
||||
balance sets either line; a check of any other 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.
|
||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||
read `0.0000` when the token's scale was known. The router passes a balance
|
||||
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||
output side holds no minimum, not even a zero one, at the point the check is
|
||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||
`minBalance` sets both lines, as a swap step does. Only a check of the
|
||||
transaction's sender's own balance sets either line; a check of any other
|
||||
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
|
||||
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`
|
||||
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.
|
||||
- If the network changes before either "Save" stores its URL, the URL is not
|
||||
saved and a flash message says so
|
||||
- Etherscan:
|
||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||
ticks it
|
||||
@@ -3393,8 +3395,7 @@ Currently supported:
|
||||
`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
|
||||
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`
|
||||
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
|
||||
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
|
||||
`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
|
||||
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
|
||||
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);
|
||||
an ETH take whose line reads `Up to` (see above); and one whose amount the
|
||||
decoder does not know. When there is no `Amount` line, each take the total
|
||||
counts gets them too, so that every amount the decoder reads is shown: the
|
||||
amount the step that sets the input side states, when it is a quantity and the
|
||||
user pays that step (a V2 or V3 step whose `payerIsUser` is true, a V4 step
|
||||
the user pays some of its input token, or a `WRAP_ETH`, whose ETH comes from
|
||||
the transaction's value), and each later take of `Token In` added to it. A
|
||||
step that sets the input side gets none when the user pays it none of its
|
||||
input token. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins of
|
||||
600 USDT and 400 DAI paid by the user, give lines for both amounts. When the
|
||||
step that sets the input side states no amount, the user pays it some of its
|
||||
input token, and there is no `Amount` line, that step gets them too whenever
|
||||
another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token
|
||||
gives one, on the lines below or, into the router, on its `Paid to` line, so
|
||||
that figure cannot read as all that leaves. When it states no amount and the
|
||||
user pays it none of its input token, that step never gets them, with a permit
|
||||
or without. They come in step order, except that the step that sets the input
|
||||
side comes first when it has them, followed, when there is no `Amount` line,
|
||||
by the other takes the total counts: `Moved from the sender`, its amount,
|
||||
worded as the `Amount` line words one, or `Unknown (could not be decoded)`
|
||||
when the decoder does not know it; `Token moved`, giving the token's address
|
||||
as the `Token In` line does; and `Moved to`, which reads
|
||||
`The swap step that spends it`. A token the decoder does not know gives the
|
||||
`Moved from the sender` line alone. A step may also take from the user's
|
||||
wallet what the decoder did not read: a V4 step whose input or one of whose
|
||||
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2
|
||||
or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
|
||||
cannot read, such as a V3 step whose path is shorter than one token address or
|
||||
has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
|
||||
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
|
||||
step gives one `Moved from the sender` line among those, reading
|
||||
`Unknown (could not be decoded)`. That line, or any line for a token the
|
||||
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
|
||||
one of `Token In` that ETH an earlier step left in the router may pay part of;
|
||||
and one whose amount the decoder does not know. When there is no `Amount`
|
||||
line, each take the total counts gets them too, so that every amount the
|
||||
decoder reads is shown: the amount the step that sets the input side states,
|
||||
when it is a quantity and the user pays that step (a V2 or V3 step whose
|
||||
`payerIsUser` is true, a V4 step the user pays some of its input token, or a
|
||||
`WRAP_ETH`, whose ETH comes from the transaction's value), and each later take
|
||||
of `Token In` added to it. A step that sets the input side gets none when the
|
||||
user pays it none of its input token. A `PERMIT2_PERMIT` the decoder cannot
|
||||
read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines
|
||||
for both amounts. When the step that sets the input side states no amount, the
|
||||
user pays it some of its input token, and there is no `Amount` line, that step
|
||||
gets them too whenever another take's lines give a figure, or a
|
||||
`PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
|
||||
the router, on its `Paid to` line, so that figure cannot read as all that
|
||||
leaves. When it states no amount and the user pays it none of its input token,
|
||||
that step never gets them, with a permit or without. They come in step order,
|
||||
except that the step that sets the input side comes first when it has them,
|
||||
followed, when there is no `Amount` line, by the other takes the total counts:
|
||||
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
|
||||
`Unknown (could not be decoded)` when the decoder does not know it;
|
||||
`Token moved`, giving the token's address as the `Token In` line does; and
|
||||
`Moved to`, which reads `The swap step that spends it`. A token the decoder
|
||||
does not know gives the `Moved from the sender` line alone. A step may also
|
||||
take from the user's wallet what the decoder did not read: a V4 step whose
|
||||
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the
|
||||
router still runs; a V2 or V3 exact-in or exact-out step or a
|
||||
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
|
||||
path is shorter than one token address or has bytes after its last hop; every
|
||||
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
|
||||
and every step it fails on. Each such step gives one `Moved from the sender`
|
||||
line among those, reading `Unknown (could not be decoded)`. That line, or any
|
||||
line for a token the 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)`
|
||||
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
|
||||
`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
|
||||
address, `Moved from the sender` gives the amount in base units and
|
||||
`Token moved` gives the zero address. For such a move into the router, the
|
||||
`Paid to` line gives the amount in base units and
|
||||
`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
|
||||
where it goes, and so its amount is shown once. 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`,
|
||||
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
|
||||
|
||||
@@ -22,7 +22,6 @@ const {
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
@@ -99,7 +98,6 @@ let loadedTxs = [];
|
||||
let ensNameMap = new Map();
|
||||
|
||||
async function loadTransactions(address) {
|
||||
const networkId = state.networkId;
|
||||
try {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
@@ -116,74 +114,43 @@ async function loadTransactions(address) {
|
||||
});
|
||||
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
|
||||
// that reverse lookups work for every displayed address, not just
|
||||
// the ones that were originally entered as ENS names.
|
||||
const counterparties = [
|
||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||
];
|
||||
let names = new Map();
|
||||
if (counterparties.length > 0) {
|
||||
try {
|
||||
names = await resolveEnsNames(
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
} 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);
|
||||
} catch (e) {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
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 =
|
||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ const {
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
@@ -171,7 +170,6 @@ function show() {
|
||||
}
|
||||
|
||||
async function loadTransactions(address, tokenId) {
|
||||
const networkId = state.networkId;
|
||||
try {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
@@ -188,6 +186,16 @@ async function loadTransactions(address, tokenId) {
|
||||
});
|
||||
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
|
||||
if (tokenId === "ETH") {
|
||||
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
|
||||
// lookups work for every displayed address.
|
||||
const counterparties = [
|
||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||
];
|
||||
let names = new Map();
|
||||
if (counterparties.length > 0) {
|
||||
try {
|
||||
names = await resolveEnsNames(
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
} 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);
|
||||
} catch (e) {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
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 =
|
||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||
}
|
||||
|
||||
+6
-34
@@ -44,7 +44,6 @@ const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
function findActiveAddr() {
|
||||
@@ -184,7 +183,6 @@ async function loadHomeTxs(ctx) {
|
||||
}
|
||||
if (allAddresses.length === 0) return;
|
||||
|
||||
const networkId = state.networkId;
|
||||
const filters = {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
@@ -204,15 +202,6 @@ async function loadHomeTxs(ctx) {
|
||||
);
|
||||
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
|
||||
const seen = new Set();
|
||||
let merged = [];
|
||||
@@ -226,24 +215,14 @@ async function loadHomeTxs(ctx) {
|
||||
|
||||
const filtered = filterTransactions(merged, filters);
|
||||
|
||||
// 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.
|
||||
// Persist any newly discovered fraud contracts
|
||||
if (filtered.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 filtered.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
for (const addr of filtered.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;
|
||||
}
|
||||
await saveState();
|
||||
}
|
||||
|
||||
merged = filtered.transactions;
|
||||
@@ -255,13 +234,6 @@ async function loadHomeTxs(ctx) {
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
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");
|
||||
if (list) {
|
||||
list.innerHTML =
|
||||
|
||||
@@ -44,12 +44,6 @@ let ctx;
|
||||
let versionClickCount = 0;
|
||||
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
|
||||
// an [x] that hands it to onRemove.
|
||||
function renderSiteList(containerId, origins, onRemove) {
|
||||
@@ -205,7 +199,6 @@ function renderWalletListSettings() {
|
||||
}
|
||||
|
||||
function show() {
|
||||
shownNetwork = currentNetwork();
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
$("settings-network").value = state.networkId;
|
||||
@@ -313,7 +306,6 @@ function showNewSettings() {
|
||||
state.checkRecipientsOnEtherscan;
|
||||
if (state.currentView === "settings") {
|
||||
markViewRendered("settings");
|
||||
shownNetwork = currentNetwork();
|
||||
$("settings-network").value = state.networkId;
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
@@ -327,34 +319,6 @@ function showNewSettings() {
|
||||
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) {
|
||||
ctx = _ctx;
|
||||
deleteWallet.init(ctx);
|
||||
@@ -366,8 +330,6 @@ function init(_ctx) {
|
||||
showFlash("Please enter an RPC URL.");
|
||||
return;
|
||||
}
|
||||
// The URL is for the network that Settings shows when "Save" is tapped.
|
||||
const net = shownNetwork;
|
||||
showFlash("Testing endpoint...");
|
||||
try {
|
||||
const resp = await debugFetch(url, {
|
||||
@@ -386,6 +348,7 @@ function init(_ctx) {
|
||||
showFlash("Endpoint returned an error.");
|
||||
return;
|
||||
}
|
||||
const net = currentNetwork();
|
||||
if (json.result !== net.chainId) {
|
||||
showFlash("Wrong network: expected " + net.name + ".");
|
||||
return;
|
||||
@@ -401,7 +364,9 @@ function init(_ctx) {
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
await saveEndpoint("rpcUrl", url, net.id);
|
||||
state.rpcUrl = url;
|
||||
await saveState();
|
||||
showFlash("Saved.");
|
||||
});
|
||||
|
||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||
@@ -410,7 +375,6 @@ function init(_ctx) {
|
||||
showFlash("Please enter a Blockscout API URL.");
|
||||
return;
|
||||
}
|
||||
const net = shownNetwork;
|
||||
showFlash("Testing endpoint...");
|
||||
try {
|
||||
const resp = await debugFetch(url + "/stats");
|
||||
@@ -425,7 +389,9 @@ function init(_ctx) {
|
||||
showFlash("Could not reach endpoint.");
|
||||
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
|
||||
|
||||
@@ -47,30 +47,12 @@ function clearPopupCaches() {
|
||||
// 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
|
||||
// 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) {
|
||||
const { state, saveState } = require("./state");
|
||||
let net = null;
|
||||
await saveState((stored) => {
|
||||
await saveState(() => {
|
||||
if (state.networkId === newNetworkId) return false;
|
||||
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();
|
||||
});
|
||||
return net;
|
||||
|
||||
+2
-4
@@ -591,10 +591,8 @@ function reportSaveFailure(err) {
|
||||
//
|
||||
// `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
|
||||
// `state`, and this save writes that change. It may also change the stored
|
||||
// record. A field that this page did not change is then written as
|
||||
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
||||
// return lets the save go ahead.
|
||||
// `state`, and this save writes that change. When it returns false, nothing is
|
||||
// written; any other return lets the save go ahead.
|
||||
function saveState(beforeWrite) {
|
||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||
// Every failed save is reported, whether or not the caller awaited this
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
||||
// from the stored profile.
|
||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
||||
// profile.
|
||||
//
|
||||
// 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
|
||||
@@ -7,11 +7,6 @@
|
||||
// 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
|
||||
// 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 { normalizePersisted } = require("./persistedState");
|
||||
@@ -29,15 +24,4 @@ async function storedSettingIsOn(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether `networkId` is the active network in the stored profile. A failed
|
||||
// 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 };
|
||||
module.exports = { storedSettingIsOn };
|
||||
|
||||
+50
-41
@@ -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
|
||||
// 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`
|
||||
@@ -508,18 +497,16 @@ function amountText(raw, info) {
|
||||
|
||||
// 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
|
||||
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
|
||||
// ETH has no address.
|
||||
function tokenMovedLine(label, info) {
|
||||
// one. ETH has no address.
|
||||
function tokenMovedLine(label, token, sources) {
|
||||
const info = tokenInfo(token, sources);
|
||||
if (!present(info.address)) {
|
||||
return { label, value: info.symbol };
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: info.symbol
|
||||
? info.symbol + " (" + info.address + ")"
|
||||
: info.address,
|
||||
address: info.address,
|
||||
value: info.symbol ? info.symbol + " (" + token + ")" : token,
|
||||
address: token,
|
||||
isToken: true,
|
||||
};
|
||||
}
|
||||
@@ -1629,13 +1616,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
// The router passes this check whenever the owner
|
||||
// holds at least minBalance, so a zero one guarantees
|
||||
// nothing. A check the router lets fail guarantees
|
||||
// nothing either, so it is read as a zero one. A zero
|
||||
// check sets neither Token Out nor the minimum. Any
|
||||
// nothing and does not replace a minimum an earlier
|
||||
// step stated. A check the router lets fail guarantees
|
||||
// nothing either, so it is read as a zero one. Any
|
||||
// other minBalance sets the output side as a swap
|
||||
// does. The check pays nobody.
|
||||
// does. It pays nobody.
|
||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||
if (minBalance !== 0n) {
|
||||
if (!(minBalance === 0n && present(minOutput))) {
|
||||
setOutput(i, b.token, minBalance, []);
|
||||
}
|
||||
keepsRouterWeth = true;
|
||||
@@ -2342,9 +2329,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
: UNDECODED_CURRENCY,
|
||||
});
|
||||
if (present(s.token)) {
|
||||
details.push(
|
||||
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
|
||||
);
|
||||
details.push(tokenMovedLine("Token moved", s.token, sources));
|
||||
details.push({
|
||||
label: "Moved to",
|
||||
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
|
||||
// where it goes, an address on a line of its own.
|
||||
for (const m of movedFromSender) {
|
||||
const info = permit2TokenInfo(m.token, sources);
|
||||
details.push({
|
||||
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(
|
||||
present(m.to)
|
||||
? { label: "Moved to", value: m.to }
|
||||
@@ -2390,15 +2375,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const info = permit2TokenInfo(p.token, sources);
|
||||
details.push({
|
||||
label: "Permit2 allowance",
|
||||
value:
|
||||
p.amount >= MAX_UINT160
|
||||
? "Unlimited"
|
||||
: amountText(p.amount, info).display,
|
||||
});
|
||||
details.push(tokenMovedLine("Allowance token", info));
|
||||
// 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({
|
||||
label: "Permit2 allowance",
|
||||
value:
|
||||
p.amount >= MAX_UINT160
|
||||
? "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({
|
||||
label: "Allowance spender",
|
||||
value: p.spender,
|
||||
@@ -2456,7 +2462,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
||||
}
|
||||
for (const m of movedIntoRouter) {
|
||||
const info = permit2TokenInfo(m.token, sources);
|
||||
const info = tokenInfo(m.token, sources);
|
||||
paidTo.push({
|
||||
label: "Paid to",
|
||||
value:
|
||||
@@ -2464,7 +2470,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
amountText(m.amount, info).display +
|
||||
" 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) {
|
||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||
@@ -2481,7 +2489,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
paidTo.push(
|
||||
tokenMovedLine(
|
||||
"Token moved into the router",
|
||||
tokenInfo(getAddress(token), sources),
|
||||
getAddress(token),
|
||||
sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
// approved a site's request, which changes only the stored record. Driven
|
||||
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
||||
//
|
||||
// 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 {
|
||||
bootPopup,
|
||||
@@ -163,50 +159,3 @@ test("a refresh that returns after the background switched the network writes no
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// or writes the stored record for it: the popup's next save finds it, as
|
||||
// before.
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
);
|
||||
@@ -177,78 +177,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
|
||||
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", () => {
|
||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
|
||||
+7
-89
@@ -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
|
||||
// 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
|
||||
@@ -4380,9 +4304,10 @@ describe("uniswap decoder", () => {
|
||||
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
||||
});
|
||||
|
||||
// A zero balance check sets neither Token Out nor the minimum. The route
|
||||
// pays only the WRAP_ETH's WETH, to the router, where it stays.
|
||||
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
|
||||
// With no minimum stated before it, a zero balance check is what sets the
|
||||
// output side, and it guarantees nothing. No step pays anyone, so the
|
||||
// minimum is not said to be received.
|
||||
test("a zero balance check with no earlier minimum states no minimum", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
||||
[
|
||||
@@ -4395,16 +4320,9 @@ describe("uniswap decoder", () => {
|
||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Token Out").value).toBe(
|
||||
"Unknown (not named in the calldata)",
|
||||
);
|
||||
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,
|
||||
expect(detail(result, "Token Out").value).toContain("USDT");
|
||||
expect(detail(result, "Min. paid out").value).toBe(
|
||||
"None (no minimum guaranteed)",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
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(
|
||||
@@ -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);
|
||||
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
|
||||
"1.0000 WETH",
|
||||
);
|
||||
|
||||
const withFlag = decode(execute(flagged(check)));
|
||||
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
|
||||
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
|
||||
expect(detail(withFlag, "Min. received")).toBeUndefined();
|
||||
expect(detail(withFlag, "Token Out").address).toBe(WETH);
|
||||
expect(detail(withFlag, "Min. paid out").value).toBe(
|
||||
"None (no minimum guaranteed)",
|
||||
);
|
||||
});
|
||||
|
||||
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
||||
|
||||
Reference in New Issue
Block a user