Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f46515d87 | ||
|
|
bcdffa56a4 | ||
|
|
c0a3a02418 | ||
|
|
f550d8c8d2 | ||
|
|
fc6cffcaba |
@@ -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" and "Check recipients on Etherscan",
|
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
|
||||||
read from storage for the requests those settings gate
|
the active network, read from storage
|
||||||
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,22 +1277,18 @@ 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 the
|
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
|
||||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
a V2, V3 or V4 swap's minimum of zero. The router passes a
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
|
||||||
read `0.0000` when the token's scale was known. The router passes a balance
|
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
|
||||||
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
lines, as a swap step does. Only a check of the transaction's sender's own
|
||||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
balance sets either line; a check of any other address sets neither (see
|
||||||
output side holds no minimum, not even a zero one, at the point the check is
|
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
|
||||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
without reverting the transaction (see Transaction Decoding) guarantees
|
||||||
`minBalance` sets both lines, as a swap step does. Only a check of the
|
nothing either, whatever its `minBalance`, and is read as one whose
|
||||||
transaction's sender's own balance sets either line; a check of any other
|
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
|
||||||
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
|
is likewise read as zero.
|
||||||
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
|
||||||
@@ -3397,7 +3393,8 @@ 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)`. When a swap step, `TAKE` or
|
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
|
||||||
`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
|
||||||
@@ -3566,10 +3563,14 @@ Currently supported:
|
|||||||
on every router but `UniversalRouterV1`, and otherwise gives the address. A
|
on every router but `UniversalRouterV1`, and otherwise gives the address. A
|
||||||
move into the router needs none of them, because its `Paid to` line and
|
move into the router needs none of them, because its `Paid to` line and
|
||||||
`Token moved into the router` line already give the amount, the token and
|
`Token moved into the router` line already give the amount, the token and
|
||||||
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
|
where it goes, and so its amount is shown once. For a move of the zero
|
||||||
amount does not stand as the `Amount` figure, before the step that sets the
|
address, `Moved from the sender` gives the amount in base units and
|
||||||
input side or after it, gives three lines, in step order, after those for each
|
`Token moved` gives the zero address. For such a move into the router, the
|
||||||
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
|
`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
|
||||||
`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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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,
|
||||||
@@ -98,6 +99,7 @@ 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,
|
||||||
@@ -114,43 +116,74 @@ 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 {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
// The rows then show no ENS names.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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,
|
||||||
@@ -170,6 +171,7 @@ 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,
|
||||||
@@ -186,16 +188,6 @@ 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);
|
||||||
@@ -207,32 +199,75 @@ 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 {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
// The rows then show no ENS names.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>';
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-2
@@ -44,6 +44,7 @@ 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() {
|
||||||
@@ -183,6 +184,7 @@ 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,
|
||||||
@@ -202,6 +204,15 @@ 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 = [];
|
||||||
@@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
const filtered = filterTransactions(merged, filters);
|
const filtered = filterTransactions(merged, filters);
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts
|
// 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 (filtered.newFraudContracts.length > 0) {
|
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) {
|
for (const addr of filtered.newFraudContracts) {
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
state.fraudContracts.push(addr);
|
state.fraudContracts.push(addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await saveState();
|
saved = true;
|
||||||
|
});
|
||||||
|
// If the network changed while the save waited for the lock,
|
||||||
|
// nothing is drawn.
|
||||||
|
if (!saved) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
merged = filtered.transactions;
|
merged = filtered.transactions;
|
||||||
@@ -234,6 +255,13 @@ 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 =
|
||||||
|
|||||||
@@ -334,6 +334,11 @@ function showNewSettings() {
|
|||||||
// src/popup/index.js) between setting the URL and writing it. If the active
|
// 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,
|
// 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.
|
// 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) {
|
async function saveEndpoint(field, url, networkId) {
|
||||||
let refused = false;
|
let refused = false;
|
||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
@@ -341,6 +346,7 @@ async function saveEndpoint(field, url, networkId) {
|
|||||||
state.networkId !== networkId || stored.networkId !== networkId;
|
state.networkId !== networkId || stored.networkId !== networkId;
|
||||||
if (refused) return false;
|
if (refused) return false;
|
||||||
state[field] = url;
|
state[field] = url;
|
||||||
|
stored[field] = url;
|
||||||
});
|
});
|
||||||
if (refused) {
|
if (refused) {
|
||||||
showFlash("The URL was not saved because the network changed.");
|
showFlash("The URL was not saved because the network changed.");
|
||||||
|
|||||||
@@ -48,11 +48,15 @@ function clearPopupCaches() {
|
|||||||
// 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 switch is also applied to the stored record that the save has just read,
|
// The save writes only the fields that differ from what this page last saved.
|
||||||
// as the background applies its own switch. A save writes only the fields that
|
// When this page last saved a balance of "0", clearing that balance is no
|
||||||
// this page changed since its last save. A cleared balance of "0" is no change
|
// change. So the switch also resets these fields in the stored record:
|
||||||
// when this page last saved "0" too. The balance that the background stored
|
//
|
||||||
// since then would otherwise stay under the new network
|
// - 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).
|
// (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");
|
||||||
@@ -60,7 +64,13 @@ async function onChainSwitch(newNetworkId) {
|
|||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
if (state.networkId === newNetworkId) return false;
|
if (state.networkId === newNetworkId) return false;
|
||||||
net = applyChainSwitchFields(state, newNetworkId);
|
net = applyChainSwitchFields(state, newNetworkId);
|
||||||
applyChainSwitchFields(stored, 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;
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
// Split out of chainSwitch.js so the background can perform a chain switch
|
// Split out of chainSwitch.js so the background can perform a chain switch
|
||||||
// without the singleton being reachable from its bundle at all. The popup
|
// without the singleton being reachable from its bundle at all. The popup
|
||||||
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
|
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
|
||||||
// the singleton and to the stored record that its save reads; the background
|
// the singleton and saves; the background applies it to the detached record of
|
||||||
// applies it to the detached record of its own read-modify-write
|
// its own read-modify-write (src/background/state.js).
|
||||||
// (src/background/state.js).
|
|
||||||
//
|
//
|
||||||
// Everything here is synchronous and touches nothing but the objects it is
|
// Everything here is synchronous and touches nothing but the objects it is
|
||||||
// given: no storage, no caches, no imports beyond the network table. That is
|
// given: no storage, no caches, no imports beyond the network table. That is
|
||||||
|
|||||||
+3
-3
@@ -592,9 +592,9 @@ 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. It may also change the stored
|
||||||
// record: a field that this page did not change is written as `beforeWrite`
|
// record. A field that this page did not change is then written as
|
||||||
// left it. When it returns false, nothing is written; any other return lets the
|
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
||||||
// save go ahead.
|
// 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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
||||||
// profile.
|
// from the stored 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,6 +7,11 @@
|
|||||||
// 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");
|
||||||
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { storedSettingIsOn };
|
// 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 };
|
||||||
|
|||||||
+35
-44
@@ -479,6 +479,17 @@ 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`
|
||||||
@@ -497,16 +508,18 @@ 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. ETH has no address.
|
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
|
||||||
function tokenMovedLine(label, token, sources) {
|
// ETH has no address.
|
||||||
const info = tokenInfo(token, sources);
|
function tokenMovedLine(label, info) {
|
||||||
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 ? info.symbol + " (" + token + ")" : token,
|
value: info.symbol
|
||||||
address: token,
|
? info.symbol + " (" + info.address + ")"
|
||||||
|
: info.address,
|
||||||
|
address: info.address,
|
||||||
isToken: true,
|
isToken: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1616,13 +1629,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 and does not replace a minimum an earlier
|
// nothing. A check the router lets fail guarantees
|
||||||
// step stated. A check the router lets fail guarantees
|
// nothing either, so it is read as a zero one. A zero
|
||||||
// nothing either, so it is read as a zero one. Any
|
// check sets neither Token Out nor the minimum. Any
|
||||||
// other minBalance sets the output side as a swap
|
// other minBalance sets the output side as a swap
|
||||||
// does. It pays nobody.
|
// does. The check pays nobody.
|
||||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||||
if (!(minBalance === 0n && present(minOutput))) {
|
if (minBalance !== 0n) {
|
||||||
setOutput(i, b.token, minBalance, []);
|
setOutput(i, b.token, minBalance, []);
|
||||||
}
|
}
|
||||||
keepsRouterWeth = true;
|
keepsRouterWeth = true;
|
||||||
@@ -2329,7 +2342,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
: UNDECODED_CURRENCY,
|
: UNDECODED_CURRENCY,
|
||||||
});
|
});
|
||||||
if (present(s.token)) {
|
if (present(s.token)) {
|
||||||
details.push(tokenMovedLine("Token moved", s.token, sources));
|
details.push(
|
||||||
|
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",
|
||||||
@@ -2340,12 +2355,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, tokenInfo(m.token, sources))
|
value: amountText(m.amount, info).display,
|
||||||
.display,
|
|
||||||
});
|
});
|
||||||
details.push(tokenMovedLine("Token moved", m.token, sources));
|
details.push(tokenMovedLine("Token moved", info));
|
||||||
details.push(
|
details.push(
|
||||||
present(m.to)
|
present(m.to)
|
||||||
? { label: "Moved to", value: m.to }
|
? { label: "Moved to", value: m.to }
|
||||||
@@ -2375,36 +2390,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Permit2 has no allowance of ETH. An allowance of the zero
|
const info = permit2TokenInfo(p.token, sources);
|
||||||
// address reads as one of a token nothing knows: its amount in
|
|
||||||
// base units, and the zero address as its token.
|
|
||||||
if (p.token === "0x0000000000000000000000000000000000000000") {
|
|
||||||
details.push({
|
details.push({
|
||||||
label: "Permit2 allowance",
|
label: "Permit2 allowance",
|
||||||
value:
|
value:
|
||||||
p.amount >= MAX_UINT160
|
p.amount >= MAX_UINT160
|
||||||
? "Unlimited"
|
? "Unlimited"
|
||||||
: unknownDecimalsAmount(p.amount),
|
: amountText(p.amount, info).display,
|
||||||
});
|
});
|
||||||
details.push({
|
details.push(tokenMovedLine("Allowance token", info));
|
||||||
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,
|
||||||
@@ -2462,7 +2456,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 = tokenInfo(m.token, sources);
|
const info = permit2TokenInfo(m.token, sources);
|
||||||
paidTo.push({
|
paidTo.push({
|
||||||
label: "Paid to",
|
label: "Paid to",
|
||||||
value:
|
value:
|
||||||
@@ -2470,9 +2464,7 @@ 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(
|
paidTo.push(tokenMovedLine("Token moved into the router", info));
|
||||||
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 });
|
||||||
@@ -2489,8 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
paidTo.push(
|
paidTo.push(
|
||||||
tokenMovedLine(
|
tokenMovedLine(
|
||||||
"Token moved into the router",
|
"Token moved into the router",
|
||||||
getAddress(token),
|
tokenInfo(getAddress(token), sources),
|
||||||
sources,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
// 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 refresh by the background that is saved just before
|
// The last test covers a balance refresh by the background that is saved while
|
||||||
// the switch in Settings (https://git.eeqj.de/sneak/AutistMask/issues/653).
|
// a switch in Settings waits for the lock
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -165,7 +166,7 @@ test("a refresh that returns after the background switched the network writes no
|
|||||||
|
|
||||||
// The background's refresh saves Ethereum Mainnet's balances while the switch
|
// 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
|
// in Settings waits for the lock. This page last saved a balance of "0" and no
|
||||||
// tokens, the same values the switch sets.
|
// 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 () => {
|
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
|
||||||
const profile = unversionedValidProfile();
|
const profile = unversionedValidProfile();
|
||||||
profile.wallets[0].addresses[0].balance = "0";
|
profile.wallets[0].addresses[0].balance = "0";
|
||||||
@@ -174,8 +175,9 @@ test("a background refresh saved while a switch in Settings waits for the lock l
|
|||||||
const background = require("../src/background/state");
|
const background = require("../src/background/state");
|
||||||
await page.click("btn-settings");
|
await page.click("btn-settings");
|
||||||
|
|
||||||
// The background takes the lock first. Once let go, the background saves
|
// The background takes the lock first and holds it until the test calls
|
||||||
// balances as backgroundRefresh() in src/background/index.js does.
|
// `letGo()`. It then saves balances as backgroundRefresh() in
|
||||||
|
// src/background/index.js does.
|
||||||
let letGo;
|
let letGo;
|
||||||
const refreshSave = background.updateState(async (fresh) => {
|
const refreshSave = background.updateState(async (fresh) => {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 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,6 +177,78 @@ 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");
|
||||||
|
|||||||
+89
-7
@@ -3440,6 +3440,82 @@ 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
|
||||||
@@ -4304,10 +4380,9 @@ describe("uniswap decoder", () => {
|
|||||||
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
// With no minimum stated before it, a zero balance check is what sets the
|
// A zero balance check sets neither Token Out nor the minimum. The route
|
||||||
// output side, and it guarantees nothing. No step pays anyone, so the
|
// pays only the WRAP_ETH's WETH, to the router, where it stays.
|
||||||
// minimum is not said to be received.
|
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
|
||||||
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]),
|
||||||
[
|
[
|
||||||
@@ -4320,9 +4395,16 @@ 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).toContain("USDT");
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
expect(detail(result, "Min. paid out").value).toBe(
|
"Unknown (not named in the calldata)",
|
||||||
"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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -688,6 +688,62 @@ 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(
|
||||||
@@ -2820,17 +2876,16 @@ describe("the allow-revert flag", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
|
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
|
||||||
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").address).toBe(WETH);
|
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
|
||||||
expect(detail(withFlag, "Min. paid out").value).toBe(
|
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
|
||||||
"None (no minimum guaranteed)",
|
expect(detail(withFlag, "Min. received")).toBeUndefined();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
||||||
|
|||||||
Reference in New Issue
Block a user