Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a00bb4f8e | ||
|
|
41842b197e | ||
|
|
8998d6c661 | ||
|
|
08e47c39ad | ||
|
|
7ba0a5ff75 | ||
|
|
cd5951a4f8 |
@@ -905,8 +905,8 @@ src/
|
|||||||
prices.js — ETH/USD and token/USD from Chainlink price feeds
|
prices.js — ETH/USD and token/USD from Chainlink price feeds
|
||||||
scamlist.js — known fraud contract addresses
|
scamlist.js — known fraud contract addresses
|
||||||
state.js — persisted state (extension storage)
|
state.js — persisted state (extension storage)
|
||||||
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
|
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
|
||||||
the active network, read from storage
|
read from storage for the requests those settings gate
|
||||||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
||||||
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
|
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
|
||||||
price feeds (hardcoded)
|
price feeds (hardcoded)
|
||||||
@@ -2233,6 +2233,8 @@ view would leave a wallet one click from deletion.
|
|||||||
**AddressDetail**
|
**AddressDetail**
|
||||||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||||
contract lookup → flash message, no screen change
|
contract lookup → flash message, no screen change
|
||||||
|
- "Add", then the contract lookup answers while another screen shows, or
|
||||||
|
after AddToken was opened again → tracks the token, no screen change
|
||||||
- "Add", then a network switch in Settings before the contract lookup
|
- "Add", then a network switch in Settings before the contract lookup
|
||||||
answers → nothing is tracked; "The active network changed before the
|
answers → nothing is tracked; "The active network changed before the
|
||||||
lookup finished, so the token was not added." flash message, no screen
|
lookup finished, so the token was not added." flash message, no screen
|
||||||
@@ -2279,6 +2281,9 @@ view would leave a wallet one click from deletion.
|
|||||||
no user data and is made whether or not "Use Blockscout" is ticked.
|
no user data and is made whether or not "Use Blockscout" is ticked.
|
||||||
- If the network changes before either "Save" stores its URL, the URL is not
|
- If the network changes before either "Save" stores its URL, the URL is not
|
||||||
saved and a flash message says so
|
saved and a flash message says so
|
||||||
|
- Either "Save" stores its URL only if the input still shows that URL when
|
||||||
|
the endpoint answers. Otherwise a flash message says that the URL was not
|
||||||
|
saved
|
||||||
- Etherscan:
|
- Etherscan:
|
||||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||||
ticks it
|
ticks it
|
||||||
|
|||||||
+27
-10
@@ -65,7 +65,14 @@ function renderWalletList() {
|
|||||||
const pageClosed = new AbortController();
|
const pageClosed = new AbortController();
|
||||||
window.addEventListener("pagehide", () => pageClosed.abort());
|
window.addEventListener("pagehide", () => pageClosed.abort());
|
||||||
|
|
||||||
|
// While a refresh runs, a request through ctx is not dropped. The request sets
|
||||||
|
// refreshAgain, and one more refresh runs once the running one ends. Several
|
||||||
|
// requests in that time lead to one more refresh, not several. An import asks
|
||||||
|
// for a refresh after its address scan. A refresh already running does not
|
||||||
|
// have the addresses the scan found
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
let refreshAgain = false;
|
||||||
|
|
||||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
||||||
// the recovery screen: there is no profile left to refresh.
|
// the recovery screen: there is no profile left to refresh.
|
||||||
@@ -89,7 +96,10 @@ let refreshTimer = null;
|
|||||||
// user turned "Use Blockscout" off. So the save drops every token the user has
|
// user turned "Use Blockscout" off. So the save drops every token the user has
|
||||||
// not tracked if the stored setting is off by then.
|
// not tracked if the stored setting is off by then.
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) {
|
||||||
|
refreshAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
await followStoredSettings();
|
await followStoredSettings();
|
||||||
@@ -132,28 +142,35 @@ async function doRefreshAndRender() {
|
|||||||
});
|
});
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
// No caller waits for this function, so it must never reject. An
|
||||||
// second interval, and eight views through ctx — so it must never
|
// unhandled rejection reports nothing. The save inside it reports its
|
||||||
// reject: an unhandled rejection is not a report of anything. The save
|
// own failure through onSaveFailure() (see src/shared/state.js); what
|
||||||
// inside it reports its own failure through onSaveFailure() (see
|
// is left here is a failed network round trip, which the next tick
|
||||||
// src/shared/state.js); what is left here is a failed network round
|
// retries.
|
||||||
// trip, which the next tick retries.
|
|
||||||
log.errorf("popup: background refresh failed:", e);
|
log.errorf("popup: background refresh failed:", e);
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
|
if (refreshAgain) {
|
||||||
|
refreshAgain = false;
|
||||||
|
doRefreshAndRender();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refreshes now and every ten seconds from then on. Called once a wallet
|
// Refreshes now and every ten seconds from then on. Called once a wallet
|
||||||
// exists: by init() when the popup opens with one, and by AddWallet when it
|
// exists: by init() when the popup opens with one, and by AddWallet when it
|
||||||
// adds one, which in a popup that opened on Welcome is the first
|
// adds one, which in a popup that opened on Welcome is the first
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
|
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
|
||||||
// running it starts no second one, and on the recovery screen it does nothing.
|
// refresh already started, it starts no second one. On the recovery screen it
|
||||||
|
// does nothing.
|
||||||
function startRefresh() {
|
function startRefresh() {
|
||||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||||
doRefreshAndRender();
|
doRefreshAndRender();
|
||||||
if (refreshTimer === null) {
|
if (refreshTimer === null) {
|
||||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
// A tick that finds a refresh running is dropped.
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (!refreshInFlight) return doRefreshAndRender();
|
||||||
|
}, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ const { lookupTokenInfo } = require("../../shared/balances");
|
|||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
|
// Counts the times AddToken has opened. A contract lookup started before
|
||||||
|
// AddToken was last opened changes neither the screen nor the status line.
|
||||||
|
let addTokenScreenOpenings = 0;
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
|
addTokenScreenOpenings++;
|
||||||
$("add-token-address").value = "";
|
$("add-token-address").value = "";
|
||||||
$("add-token-info").textContent = "";
|
$("add-token-info").textContent = "";
|
||||||
$("add-token-info").style.visibility = "hidden";
|
$("add-token-info").style.visibility = "hidden";
|
||||||
@@ -55,6 +60,7 @@ function init(ctx) {
|
|||||||
// The network the lookup asks, which Settings may switch away from
|
// The network the lookup asks, which Settings may switch away from
|
||||||
// before it answers, as for a transaction in confirmTx.js.
|
// before it answers, as for a transaction in confirmTx.js.
|
||||||
const chainId = currentNetwork().chainId;
|
const chainId = currentNetwork().chainId;
|
||||||
|
const opening = addTokenScreenOpenings;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
contractAddr,
|
contractAddr,
|
||||||
@@ -80,6 +86,17 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
await saveState();
|
await saveState();
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
|
// If the lookup answers after AddToken was opened again, the
|
||||||
|
// token is still added, but the screen and the status line stay
|
||||||
|
// as they are.
|
||||||
|
if (addTokenScreenOpenings !== opening) return;
|
||||||
|
// The lookup is over, so a later Back onto AddToken shows no
|
||||||
|
// status.
|
||||||
|
infoEl.textContent = "";
|
||||||
|
infoEl.style.visibility = "hidden";
|
||||||
|
// If the lookup answers while another screen shows, the token is
|
||||||
|
// still added but the screen does not change.
|
||||||
|
if (state.currentView !== "add-token") return;
|
||||||
// Pop the stack (back to address detail) and re-render it
|
// Pop the stack (back to address detail) and re-render it
|
||||||
// so the newly added token is visible immediately.
|
// so the newly added token is visible immediately.
|
||||||
if (state.viewStack.length > 0) {
|
if (state.viewStack.length > 0) {
|
||||||
@@ -100,6 +117,7 @@ function init(ctx) {
|
|||||||
? detail
|
? detail
|
||||||
: "Could not add the token.",
|
: "Could not add the token.",
|
||||||
);
|
);
|
||||||
|
if (addTokenScreenOpenings !== opening) return;
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const {
|
|||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -99,7 +98,6 @@ let loadedTxs = [];
|
|||||||
let ensNameMap = new Map();
|
let ensNameMap = new Map();
|
||||||
|
|
||||||
async function loadTransactions(address) {
|
async function loadTransactions(address) {
|
||||||
const networkId = state.networkId;
|
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -116,74 +114,43 @@ async function loadTransactions(address) {
|
|||||||
});
|
});
|
||||||
const txs = result.transactions;
|
const txs = result.transactions;
|
||||||
|
|
||||||
|
// Persist any newly discovered fraud contracts
|
||||||
|
if (result.newFraudContracts.length > 0) {
|
||||||
|
for (const addr of result.newFraudContracts) {
|
||||||
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
|
state.fraudContracts.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedTxs = txs;
|
||||||
|
|
||||||
// Collect ALL unique addresses (from + to) for ENS resolution so
|
// Collect ALL unique addresses (from + to) for ENS resolution so
|
||||||
// that reverse lookups work for every displayed address, not just
|
// that reverse lookups work for every displayed address, not just
|
||||||
// the ones that were originally entered as ENS names.
|
// the ones that were originally entered as ENS names.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
let names = new Map();
|
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
names = await resolveEnsNames(
|
ensNameMap = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// The rows then show no ENS names.
|
ensNameMap = new Map();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the network changed during the lookups above, or the screen now
|
|
||||||
// shows another address, nothing is saved or drawn. See
|
|
||||||
// src/shared/storedSettings.js.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId)) ||
|
|
||||||
$("address-line").dataset.full !== address
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts, inside the save and
|
|
||||||
// holding the lock. Nothing is added unless this page and the stored
|
|
||||||
// record both still name the request's network.
|
|
||||||
if (result.newFraudContracts.length > 0) {
|
|
||||||
let saved = false;
|
|
||||||
await saveState((stored) => {
|
|
||||||
if (state.networkId !== networkId) return false;
|
|
||||||
if (stored.networkId !== networkId) return false;
|
|
||||||
for (const addr of result.newFraudContracts) {
|
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
|
||||||
state.fraudContracts.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saved = true;
|
|
||||||
});
|
|
||||||
// If the network changed while the save waited for the lock,
|
|
||||||
// nothing is drawn.
|
|
||||||
if (!saved) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedTxs = txs;
|
|
||||||
ensNameMap = names;
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
// If the network changed, or the screen now shows another address,
|
|
||||||
// the failure is not shown.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId)) ||
|
|
||||||
$("address-line").dataset.full !== address
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$("tx-list").innerHTML =
|
$("tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const {
|
|||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -171,7 +170,6 @@ function show() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTransactions(address, tokenId) {
|
async function loadTransactions(address, tokenId) {
|
||||||
const networkId = state.networkId;
|
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -188,6 +186,16 @@ async function loadTransactions(address, tokenId) {
|
|||||||
});
|
});
|
||||||
let txs = result.transactions;
|
let txs = result.transactions;
|
||||||
|
|
||||||
|
// Persist any newly discovered fraud contracts
|
||||||
|
if (result.newFraudContracts.length > 0) {
|
||||||
|
for (const addr of result.newFraudContracts) {
|
||||||
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
|
state.fraudContracts.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveState();
|
||||||
|
}
|
||||||
|
|
||||||
// Filter to this token only
|
// Filter to this token only
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
txs = txs.filter((tx) => tx.contractAddress === null);
|
txs = txs.filter((tx) => tx.contractAddress === null);
|
||||||
@@ -199,75 +207,32 @@ async function loadTransactions(address, tokenId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadedTxs = txs;
|
||||||
|
|
||||||
// Collect ALL unique addresses for ENS resolution so reverse
|
// Collect ALL unique addresses for ENS resolution so reverse
|
||||||
// lookups work for every displayed address.
|
// lookups work for every displayed address.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
let names = new Map();
|
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
names = await resolveEnsNames(
|
ensNameMap = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// The rows then show no ENS names.
|
ensNameMap = new Map();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the network changed during the lookups above, or the screen now
|
|
||||||
// shows another address or token, nothing is saved or drawn. See
|
|
||||||
// src/shared/storedSettings.js.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId)) ||
|
|
||||||
$("address-token-line").dataset.full !== address ||
|
|
||||||
state.selectedToken !== tokenId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts, inside the save and
|
|
||||||
// holding the lock. Nothing is added unless this page and the stored
|
|
||||||
// record both still name the request's network.
|
|
||||||
if (result.newFraudContracts.length > 0) {
|
|
||||||
let saved = false;
|
|
||||||
await saveState((stored) => {
|
|
||||||
if (state.networkId !== networkId) return false;
|
|
||||||
if (stored.networkId !== networkId) return false;
|
|
||||||
for (const addr of result.newFraudContracts) {
|
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
|
||||||
state.fraudContracts.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saved = true;
|
|
||||||
});
|
|
||||||
// If the network changed while the save waited for the lock,
|
|
||||||
// nothing is drawn.
|
|
||||||
if (!saved) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedTxs = txs;
|
|
||||||
ensNameMap = names;
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
// If the network changed, or the screen now shows another address or
|
|
||||||
// token, the failure is not shown.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId)) ||
|
|
||||||
$("address-token-line").dataset.full !== address ||
|
|
||||||
state.selectedToken !== tokenId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$("address-token-tx-list").innerHTML =
|
$("address-token-tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-34
@@ -44,7 +44,6 @@ const {
|
|||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
function findActiveAddr() {
|
function findActiveAddr() {
|
||||||
@@ -184,7 +183,6 @@ async function loadHomeTxs(ctx) {
|
|||||||
}
|
}
|
||||||
if (allAddresses.length === 0) return;
|
if (allAddresses.length === 0) return;
|
||||||
|
|
||||||
const networkId = state.networkId;
|
|
||||||
const filters = {
|
const filters = {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
@@ -204,15 +202,6 @@ async function loadHomeTxs(ctx) {
|
|||||||
);
|
);
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
|
|
||||||
// If the network changed during the lookups, nothing is saved or
|
|
||||||
// drawn. See src/shared/storedSettings.js.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge, deduplicate by hash, filter, sort, take 25
|
// Merge, deduplicate by hash, filter, sort, take 25
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let merged = [];
|
let merged = [];
|
||||||
@@ -226,24 +215,14 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
const filtered = filterTransactions(merged, filters);
|
const filtered = filterTransactions(merged, filters);
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts, inside the save and
|
// Persist any newly discovered fraud contracts
|
||||||
// holding the lock. Nothing is added unless this page and the stored
|
|
||||||
// record both still name the request's network.
|
|
||||||
if (filtered.newFraudContracts.length > 0) {
|
if (filtered.newFraudContracts.length > 0) {
|
||||||
let saved = false;
|
for (const addr of filtered.newFraudContracts) {
|
||||||
await saveState((stored) => {
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
if (state.networkId !== networkId) return false;
|
state.fraudContracts.push(addr);
|
||||||
if (stored.networkId !== networkId) return false;
|
|
||||||
for (const addr of filtered.newFraudContracts) {
|
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
|
||||||
state.fraudContracts.push(addr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
saved = true;
|
}
|
||||||
});
|
await saveState();
|
||||||
// If the network changed while the save waited for the lock,
|
|
||||||
// nothing is drawn.
|
|
||||||
if (!saved) return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
merged = filtered.transactions;
|
merged = filtered.transactions;
|
||||||
@@ -255,13 +234,6 @@ async function loadHomeTxs(ctx) {
|
|||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadHomeTxs failed:", e.message);
|
log.errorf("loadHomeTxs failed:", e.message);
|
||||||
// If the network changed, the failure is not shown.
|
|
||||||
if (
|
|
||||||
state.networkId !== networkId ||
|
|
||||||
!(await storedNetworkIs(networkId))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (list) {
|
if (list) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
|
|||||||
@@ -339,17 +339,31 @@ function showNewSettings() {
|
|||||||
// Another page may have stored a different URL since then. So `url` is set in
|
// 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
|
// the stored record too. "Saved." then means that the stored record holds
|
||||||
// `url`.
|
// `url`.
|
||||||
async function saveEndpoint(field, url, networkId) {
|
//
|
||||||
|
// `input` is the id of the input that the user typed `url` in. When another
|
||||||
|
// page changes the network, "Use Blockscout" or "Check recipients on
|
||||||
|
// Etherscan", this page draws Settings again. A redraw during the endpoint
|
||||||
|
// check puts the URL from `state` back in the input. So if the input no longer
|
||||||
|
// shows `url`, nothing is saved. The flash message then says that the URL was
|
||||||
|
// changed during the check. The save compares the input and sets `url` in
|
||||||
|
// `state` in one step. So a redraw after the comparison shows `url`.
|
||||||
|
async function saveEndpoint(field, input, url, networkId) {
|
||||||
let refused = false;
|
let refused = false;
|
||||||
|
let inputChanged = false;
|
||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
refused =
|
refused =
|
||||||
state.networkId !== networkId || stored.networkId !== networkId;
|
state.networkId !== networkId || stored.networkId !== networkId;
|
||||||
if (refused) return false;
|
inputChanged = $(input).value.trim() !== url;
|
||||||
|
if (refused || inputChanged) return false;
|
||||||
state[field] = url;
|
state[field] = url;
|
||||||
stored[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.");
|
||||||
|
} else if (inputChanged) {
|
||||||
|
showFlash(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showFlash("Saved.");
|
showFlash("Saved.");
|
||||||
}
|
}
|
||||||
@@ -401,7 +415,7 @@ function init(_ctx) {
|
|||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint("rpcUrl", url, net.id);
|
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||||
@@ -425,7 +439,7 @@ function init(_ctx) {
|
|||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint("blockscoutUrl", url, net.id);
|
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock. So
|
// Each change is made inside the save, holding the lock. So
|
||||||
|
|||||||
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether TransactionDetail still holds the transaction with `txHash` on
|
||||||
|
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
||||||
|
// holds. A visit to Settings does not.
|
||||||
|
function holdsTransaction(txHash, chainId) {
|
||||||
|
const tx = state.viewData.tx;
|
||||||
|
return tx?.hash === txHash && tx?.chainId === chainId;
|
||||||
|
}
|
||||||
|
|
||||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
// asked. An answer that lands after the setting went off is dropped
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||||
|
//
|
||||||
|
// Blockscout can answer after the user has opened another transaction. That
|
||||||
|
// answer is dropped.
|
||||||
async function loadFullTxDetails(txHash, chainId) {
|
async function loadFullTxDetails(txHash, chainId) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
|
if (!holdsTransaction(txHash, chainId)) return;
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
|
|||||||
@@ -53,18 +53,42 @@ function clearPopupCaches() {
|
|||||||
// change. So the switch also resets these fields in the stored record:
|
// change. So the switch also resets these fields in the stored record:
|
||||||
//
|
//
|
||||||
// - every address's balance and token balances;
|
// - every address's balance and token balances;
|
||||||
// - the time of the last balance refresh.
|
// - the time of the last balance refresh;
|
||||||
|
// - the fraud contracts.
|
||||||
//
|
//
|
||||||
// A balance that the background or another page stored since this page's last
|
// A balance or a fraud contract that the background or another page stored
|
||||||
// save is then not kept under the new network
|
// 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,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||||
|
//
|
||||||
|
// When the stored record is on another network, a switch saved elsewhere has
|
||||||
|
// not reached this page yet. This page first takes that switch with
|
||||||
|
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
|
||||||
|
// `newNetworkId` is then made from the stored record's network. So every
|
||||||
|
// network's list of tracked tokens in the stored record is kept
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
|
||||||
|
// already on `newNetworkId`, the switch made from there only resets the
|
||||||
|
// balances and caches.
|
||||||
|
//
|
||||||
|
// Before the switch, `state` takes the tracked tokens that another page stored
|
||||||
|
// since this page's last save (takeStoredTrackedTokens() in state.js). The
|
||||||
|
// switch then puts them away under the previous network, the network they were
|
||||||
|
// added on.
|
||||||
async function onChainSwitch(newNetworkId) {
|
async function onChainSwitch(newNetworkId) {
|
||||||
const { state, saveState } = require("./state");
|
const {
|
||||||
|
state,
|
||||||
|
saveState,
|
||||||
|
takeStoredNetwork,
|
||||||
|
takeStoredTrackedTokens,
|
||||||
|
} = require("./state");
|
||||||
let net = null;
|
let net = null;
|
||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
if (state.networkId === newNetworkId) return false;
|
if (state.networkId === newNetworkId) return false;
|
||||||
|
if (stored.networkId !== state.networkId) takeStoredNetwork(stored);
|
||||||
|
takeStoredTrackedTokens(stored);
|
||||||
net = applyChainSwitchFields(state, newNetworkId);
|
net = applyChainSwitchFields(state, newNetworkId);
|
||||||
stored.lastBalanceRefresh = 0;
|
stored.lastBalanceRefresh = 0;
|
||||||
|
stored.fraudContracts = [];
|
||||||
for (const wallet of stored.wallets) {
|
for (const wallet of stored.wallets) {
|
||||||
for (const addr of wallet.addresses) {
|
for (const addr of wallet.addresses) {
|
||||||
addr.balance = "0";
|
addr.balance = "0";
|
||||||
|
|||||||
+80
-11
@@ -125,11 +125,12 @@ function currentNetwork() {
|
|||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as this page held them when its last loadState()
|
// `baseline` holds the persisted fields as this page held them when its last
|
||||||
// finished, or when its last successful saveState() began, with any network
|
// loadState() finished, or when its last successful saveState() began.
|
||||||
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
|
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
|
||||||
// saveState() diffs the live state against this to find only the fields THIS
|
// this page has taken from the stored record since then. saveState() diffs the
|
||||||
// page changed since.
|
// live state against `baseline` to find only the fields THIS page changed
|
||||||
|
// since.
|
||||||
//
|
//
|
||||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||||
@@ -442,8 +443,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// milder than the other two.
|
// milder than the other two.
|
||||||
//
|
//
|
||||||
// Every other persisted field stays a whole-field diff:
|
// Every other persisted field stays a whole-field diff:
|
||||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||||
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in
|
||||||
|
// Settings first merges that list by contract address
|
||||||
|
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
|
||||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||||
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
||||||
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
||||||
@@ -452,6 +455,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// own UI scratch space, not data another page has any reason to share
|
// own UI scratch space, not data another page has any reason to share
|
||||||
// membership of.
|
// membership of.
|
||||||
//
|
//
|
||||||
|
// One exception: when the stored record is on another network than this
|
||||||
|
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
|
||||||
|
// into the list in `networkTrackedTokens` for this page's network, by contract
|
||||||
|
// address.
|
||||||
|
//
|
||||||
// This does not make two pages that both change the SAME leaf concurrently
|
// This does not make two pages that both change the SAME leaf concurrently
|
||||||
// safe: last write wins on that one leaf, same as before. What it removes
|
// safe: last write wins on that one leaf, same as before. What it removes
|
||||||
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
|
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
|
||||||
@@ -524,6 +532,26 @@ async function saveStateOnce(beforeWrite) {
|
|||||||
merged[key] = current[key];
|
merged[key] = current[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When the merged record names a network other than this page's, this page
|
||||||
|
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
|
||||||
|
// is then the list of this page's network. The stored record keeps the
|
||||||
|
// list of this page's network in `networkTrackedTokens`. So a token that
|
||||||
|
// this page added or removed since its last save is added to or removed
|
||||||
|
// from that stored list. The merged record keeps the stored `trackedTokens`
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
|
||||||
|
if (merged.networkId !== current.networkId) {
|
||||||
|
merged.trackedTokens = fresh.trackedTokens;
|
||||||
|
merged.networkTrackedTokens = {
|
||||||
|
...fresh.networkTrackedTokens,
|
||||||
|
[current.networkId]: mergeListByIdentity(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
current.trackedTokens,
|
||||||
|
fresh.networkTrackedTokens[current.networkId],
|
||||||
|
(token) => token.address.toLowerCase(),
|
||||||
|
(base, ours) => ours,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
||||||
// Stamped on every write, never merged or diffed: the record that goes to
|
// Stamped on every write, never merged or diffed: the record that goes to
|
||||||
// storage is in THIS build's shape whatever shape it was read in, which is
|
// storage is in THIS build's shape whatever shape it was read in, which is
|
||||||
@@ -604,15 +632,55 @@ function saveState(beforeWrite) {
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give this page the network switch held by `stored`, a record a save of this
|
// Give this page the network switch held by `stored`, in `state` and in
|
||||||
// page has just read (followStoredSettings() in src/popup/index.js calls this
|
// `baseline` alike, as loadState() does with a whole record. `stored` is the
|
||||||
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
|
// record that a save of this page has just read. followStoredSettings() in
|
||||||
// with a whole record. The switch is then not this page's own change, even when
|
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
|
||||||
|
// from `beforeWrite`. The switch is then not this page's own change, even when
|
||||||
// that save fails to write: a later switch back to the previous network still
|
// that save fails to write: a later switch back to the previous network still
|
||||||
// differs from `baseline`, and is stored.
|
// differs from `baseline`, and is stored.
|
||||||
|
//
|
||||||
|
// A token that this page tracked or removed since its last save was tracked or
|
||||||
|
// removed on this page's network before the switch. `state` keeps that change
|
||||||
|
// in the list in `networkTrackedTokens` for that network, merged with the
|
||||||
|
// stored list by contract address. `baseline` takes the stored list, so the
|
||||||
|
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
|
||||||
function takeStoredNetwork(stored) {
|
function takeStoredNetwork(stored) {
|
||||||
|
const networkTrackedTokens = {
|
||||||
|
...stored.networkTrackedTokens,
|
||||||
|
[rawState.networkId]: mergeListByIdentity(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
rawState.trackedTokens,
|
||||||
|
stored.networkTrackedTokens[rawState.networkId],
|
||||||
|
(token) => token.address.toLowerCase(),
|
||||||
|
(base, ours) => ours,
|
||||||
|
),
|
||||||
|
};
|
||||||
copyChainSwitchFields(rawState, stored);
|
copyChainSwitchFields(rawState, stored);
|
||||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
copyChainSwitchFields(baseline, structuredClone(stored));
|
||||||
|
rawState.networkTrackedTokens = networkTrackedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give this page the tracked tokens of `stored`, a record on this page's
|
||||||
|
// network that a save of this page has just read (onChainSwitch() in
|
||||||
|
// src/shared/chainSwitch.js calls this from `beforeWrite`):
|
||||||
|
//
|
||||||
|
// - A token that another page tracked or removed since this page's last save
|
||||||
|
// is tracked or removed in `state` too.
|
||||||
|
// - A token that this page tracked or removed since then stays as this page
|
||||||
|
// has it.
|
||||||
|
//
|
||||||
|
// `baseline` takes the stored list, so the save stores the list in `state`
|
||||||
|
// whenever the two differ.
|
||||||
|
function takeStoredTrackedTokens(stored) {
|
||||||
|
rawState.trackedTokens = mergeListByIdentity(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
rawState.trackedTokens,
|
||||||
|
stored.trackedTokens,
|
||||||
|
(token) => token.address.toLowerCase(),
|
||||||
|
(base, ours) => ours,
|
||||||
|
);
|
||||||
|
baseline.trackedTokens = structuredClone(stored.trackedTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||||
@@ -661,6 +729,7 @@ module.exports = {
|
|||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
onSaveFailure,
|
||||||
takeStoredNetwork,
|
takeStoredNetwork,
|
||||||
|
takeStoredTrackedTokens,
|
||||||
loadState,
|
loadState,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
currentNetwork,
|
currentNetwork,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
||||||
// from the stored profile.
|
// profile.
|
||||||
//
|
//
|
||||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
||||||
// wallet reads that service's setting from storage. The wallet reads the setting
|
// wallet reads that service's setting from storage. The wallet reads the setting
|
||||||
@@ -7,11 +7,6 @@
|
|||||||
// made, and an answer that lands after the setting went off is dropped. A page's
|
// made, and an answer that lands after the setting went off is dropped. A page's
|
||||||
// own copy of the setting is not used, because another page may have just
|
// own copy of the setting is not used, because another page may have just
|
||||||
// changed it.
|
// changed it.
|
||||||
//
|
|
||||||
// Before asking Blockscout for the transaction history, a screen remembers
|
|
||||||
// which network is active. If another network is active on the page or in
|
|
||||||
// storage when the answer lands, the screen drops the answer. A switch saved
|
|
||||||
// elsewhere reaches storage first and the page later.
|
|
||||||
|
|
||||||
const { storageGet } = require("./browserApi");
|
const { storageGet } = require("./browserApi");
|
||||||
const { normalizePersisted } = require("./persistedState");
|
const { normalizePersisted } = require("./persistedState");
|
||||||
@@ -29,15 +24,4 @@ async function storedSettingIsOn(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether `networkId` is the active network in the stored profile. A failed
|
module.exports = { storedSettingIsOn };
|
||||||
// read counts as no.
|
|
||||||
async function storedNetworkIs(networkId) {
|
|
||||||
try {
|
|
||||||
const { autistmask } = await storageGet("autistmask");
|
|
||||||
return normalizePersisted(autistmask).networkId === networkId;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { storedSettingIsOn, storedNetworkIs };
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// A contract lookup on AddToken can answer after the user has left AddToken.
|
||||||
|
// The token is still added, and the screen the user is on does not change
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
|
||||||
|
// still shows goes to AddressDetail. Driven through the real popup over a
|
||||||
|
// stored profile.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A second contract address, typed after AddToken was opened again.
|
||||||
|
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopened onto AddressDetail, then AddToken opened from it with
|
||||||
|
// TOKEN's contract address typed in.
|
||||||
|
async function openAddToken() {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
boot.node("add-token-address").value = TOKEN.address;
|
||||||
|
return boot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presses Add with a contract lookup that answers only when the function
|
||||||
|
// returned here is called. That function waits for Add to finish.
|
||||||
|
function addWithPendingLookup(boot) {
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const adding = boot.click("btn-add-token-confirm");
|
||||||
|
return async () => {
|
||||||
|
answer({
|
||||||
|
symbol: TOKEN.symbol,
|
||||||
|
decimals: TOKEN.decimals,
|
||||||
|
name: TOKEN.name,
|
||||||
|
});
|
||||||
|
await adding;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
|
||||||
|
await answerLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await answerLookup();
|
||||||
|
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.text("add-token-info")).toBe("");
|
||||||
|
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerFirstLookup = addWithPendingLookup(boot);
|
||||||
|
await boot.click("btn-add-token-back");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||||
|
// The second lookup is never answered.
|
||||||
|
addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
await answerFirstLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let failFirstLookup;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
failFirstLookup = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const firstAdd = boot.click("btn-add-token-confirm");
|
||||||
|
await boot.click("btn-add-token-back");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||||
|
// The second lookup is never answered.
|
||||||
|
addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
failFirstLookup(new Error("lookup failed"));
|
||||||
|
await firstAdd;
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
await answerLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
expect(boot.node("address-balances").innerHTML).toContain("TST");
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// While a refresh runs, a request through ctx is not dropped. One more refresh
|
||||||
|
// runs once the running one ends, however many were asked for in that time
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
|
||||||
|
// timer that finds a refresh running is dropped. Each test boots the real popup
|
||||||
|
// over a stored profile and holds the ten-second refresh open. `readChain()`
|
||||||
|
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
|
||||||
|
// sets it from the node.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const {
|
||||||
|
hdWalletFromMnemonic,
|
||||||
|
deriveAddressFromXpub,
|
||||||
|
} = require("../src/shared/wallet");
|
||||||
|
|
||||||
|
const PHRASE =
|
||||||
|
"abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about";
|
||||||
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
|
||||||
|
// The first two addresses of PHRASE, both used on the chain.
|
||||||
|
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
|
||||||
|
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
|
||||||
|
|
||||||
|
// The ETH balance the chain holds for each address, in ether.
|
||||||
|
const CHAIN = {
|
||||||
|
[ADDRESS]: "1.5",
|
||||||
|
[firstAddress]: "2.0",
|
||||||
|
[SECOND_ADDRESS]: "3.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The balance stand-in for a refresh that returns.
|
||||||
|
async function readChain(wallets) {
|
||||||
|
for (const wallet of wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
addr.balance = CHAIN[addr.address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boots the popup over a profile with one wallet. From then on,
|
||||||
|
// `refreshBalances()` never returns unless the test answers it. So an unwanted
|
||||||
|
// refresh is counted once and then waits, instead of starting more.
|
||||||
|
async function boot() {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
refreshBalances.mockImplementation(() => new Promise(() => {}));
|
||||||
|
return { page, refreshBalances };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the ten-second refresh and holds it open until finish() is called.
|
||||||
|
// The held refresh then writes the chain's balances. finish() resolves once
|
||||||
|
// the held refresh has saved them and drawn Home. The one refresh after the
|
||||||
|
// held one also returns.
|
||||||
|
async function holdRefresh(page) {
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
refreshBalances
|
||||||
|
.mockImplementationOnce(async (wallets) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
await readChain(wallets);
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(readChain);
|
||||||
|
const ticking = page.tick();
|
||||||
|
await page.settle();
|
||||||
|
return {
|
||||||
|
finish: async () => {
|
||||||
|
answer();
|
||||||
|
await ticking;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
|
||||||
|
// addresses.
|
||||||
|
async function importPhrase(page) {
|
||||||
|
const { scanForAddresses } = require("../src/shared/balances");
|
||||||
|
scanForAddresses.mockImplementationOnce(async () => ({
|
||||||
|
addresses: [
|
||||||
|
{ address: firstAddress, index: 0 },
|
||||||
|
{ address: SECOND_ADDRESS, index: 1 },
|
||||||
|
],
|
||||||
|
nextIndex: 2,
|
||||||
|
}));
|
||||||
|
await page.click("btn-add-wallet-bottom");
|
||||||
|
await page.click("tab-mnemonic");
|
||||||
|
page.node("wallet-mnemonic").value = PHRASE;
|
||||||
|
page.node("add-wallet-password").value = PASSWORD;
|
||||||
|
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports the private key `key` on AddWallet.
|
||||||
|
async function importKey(page, key) {
|
||||||
|
await page.click("btn-add-wallet-bottom");
|
||||||
|
await page.click("tab-privkey");
|
||||||
|
page.node("import-private-key").value = key;
|
||||||
|
page.node("add-wallet-password").value = PASSWORD;
|
||||||
|
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
|
||||||
|
const { page } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
|
||||||
|
await importPhrase(page);
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
const list = page.node("wallet-list").innerHTML;
|
||||||
|
expect(list).toContain("<span>2.0000</span>");
|
||||||
|
expect(list).toContain("<span>3.0000</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("several requests during a refresh lead to exactly one more refresh", async () => {
|
||||||
|
const { page, refreshBalances } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
const calls = refreshBalances.mock.calls.length;
|
||||||
|
|
||||||
|
await importKey(page, "0x" + "11".repeat(32));
|
||||||
|
await importKey(page, "0x" + "22".repeat(32));
|
||||||
|
await importPhrase(page);
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
|
||||||
|
const { page, refreshBalances } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
const calls = refreshBalances.mock.calls.length;
|
||||||
|
|
||||||
|
await page.tick();
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||||
|
});
|
||||||
@@ -47,3 +47,123 @@ test.each([
|
|||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
expect(page.text("flash-msg")).toBe("Saved.");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The user types a URL in Settings and taps "Save". While the check waits for
|
||||||
|
// the endpoint's answer, another page turns "Use Blockscout" on. This page
|
||||||
|
// follows that change and draws Settings again, with the stored URL in the
|
||||||
|
// input. The check then passes. The typed URL is not stored. The flash message
|
||||||
|
// says that the URL was changed during the check
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
input: "settings-blockscout",
|
||||||
|
field: "blockscoutUrl",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button stores nothing when Settings is drawn again during the check",
|
||||||
|
async ({ button, input, field }) => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
await page.click("btn-settings");
|
||||||
|
const storedUrl = page.value(input);
|
||||||
|
let answer;
|
||||||
|
globalThis.fetch.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
page.node(input).value = "https://typed.example.invalid";
|
||||||
|
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
// The browser announces this write to this page.
|
||||||
|
await page.storage.local.set({
|
||||||
|
autistmask: {
|
||||||
|
...page.storage.read("autistmask"),
|
||||||
|
useBlockscout: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
answer({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||||
|
expect(page.value(input)).toBe(storedUrl);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// As above, but another write holds the lock. So this page's follow of the
|
||||||
|
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
|
||||||
|
// input still shows the typed URL. The save then asks for the lock, after the
|
||||||
|
// follow did. So the follow draws Settings again before the save compares the
|
||||||
|
// input.
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
input: "settings-blockscout",
|
||||||
|
field: "blockscoutUrl",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button stores nothing when Settings is drawn again while the save waits for the lock",
|
||||||
|
async ({ button, input, field }) => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
await page.click("btn-settings");
|
||||||
|
const storedUrl = page.value(input);
|
||||||
|
let answer;
|
||||||
|
globalThis.fetch.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const typedUrl = "https://typed.example.invalid";
|
||||||
|
|
||||||
|
page.node(input).value = typedUrl;
|
||||||
|
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
let releaseLock;
|
||||||
|
const lockHeld = navigator.locks.request(
|
||||||
|
"autistmask",
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseLock = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The browser announces this write to this page.
|
||||||
|
await page.storage.local.set({
|
||||||
|
autistmask: {
|
||||||
|
...page.storage.read("autistmask"),
|
||||||
|
useBlockscout: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
answer({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
expect(page.value(input)).toBe(typedUrl);
|
||||||
|
releaseLock();
|
||||||
|
await lockHeld;
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||||
|
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||||
|
expect(page.value(input)).toBe(storedUrl);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
// a contract lookup that answers after one. Driven through the real popup over
|
// a contract lookup that answers after one. Driven through the real popup over
|
||||||
// a stored profile; the same switch made by the background is in
|
// a stored profile; the same switch made by the background is in
|
||||||
// tests/coldWorkerChainSwitch.test.js.
|
// tests/coldWorkerChainSwitch.test.js.
|
||||||
|
//
|
||||||
|
// The tests of what another page stored before a switch in Settings cover a
|
||||||
|
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -178,9 +181,8 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
|
// 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.
|
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
|
||||||
// So DAI stays in `trackedTokens`, the active network's list.
|
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
|
||||||
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
|
|
||||||
const boot = await bootPopup(unversionedValidProfile());
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
expect(boot.pageErrors).toEqual([]);
|
expect(boot.pageErrors).toEqual([]);
|
||||||
boot.storage.write("autistmask", {
|
boot.storage.write("autistmask", {
|
||||||
@@ -193,10 +195,232 @@ test("a token another page tracked before a switch in Settings stays in trackedT
|
|||||||
|
|
||||||
const stored = boot.storage.read("autistmask");
|
const stored = boot.storage.read("autistmask");
|
||||||
expect(stored.networkId).toBe("sepolia");
|
expect(stored.networkId).toBe("sepolia");
|
||||||
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
|
||||||
|
// last save. This page still holds DAI. The switch to Sepolia does not store
|
||||||
|
// DAI under Ethereum Mainnet again.
|
||||||
|
test("a token another page removed before a switch in Settings stays removed", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Another popup page removes DAI after this page's last save. That page then
|
||||||
|
// adds DAI again, typing the contract address in lower case. The two pages'
|
||||||
|
// entries for DAI match, because contract addresses are compared ignoring
|
||||||
|
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
|
||||||
|
// Mainnet.
|
||||||
|
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [
|
||||||
|
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page saves a switch to Sepolia after this page's last save.
|
||||||
|
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
|
||||||
|
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
|
||||||
|
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [SEPOLIA_TOKEN],
|
||||||
|
networkTrackedTokens: { mainnet: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
|
||||||
|
// taking that switch, this page adds a token on Ethereum Mainnet. The add's
|
||||||
|
// save adds the token to Ethereum Mainnet's list and leaves Sepolia's list as
|
||||||
|
// it was.
|
||||||
|
test("a token added before this page takes another page's switch is tracked on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet", {
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
});
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [SEPOLIA_TOKEN],
|
||||||
|
networkTrackedTokens: {
|
||||||
|
mainnet: [MAINNET_TOKEN],
|
||||||
|
sepolia: [SEPOLIA_TOKEN],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce({
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
});
|
||||||
|
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
await boot.click("btn-settings-addtoken-manual");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
{
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
|
||||||
|
// Sepolia. This page does not track DAI. Before taking that switch, this page
|
||||||
|
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
|
||||||
|
// token on Ethereum Mainnet's list.
|
||||||
|
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [],
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce({
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
});
|
||||||
|
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
await boot.click("btn-settings-addtoken-manual");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
{
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
|
||||||
|
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
|
||||||
|
// on a tracked token in Settings does. The removal's save drops DAI from
|
||||||
|
// Ethereum Mainnet's list and leaves Sepolia's list as it was.
|
||||||
|
test("a token removed before this page takes another page's switch is gone from Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [SEPOLIA_TOKEN],
|
||||||
|
networkTrackedTokens: {
|
||||||
|
mainnet: [MAINNET_TOKEN],
|
||||||
|
sepolia: [SEPOLIA_TOKEN],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { state, saveState } = require("../src/shared/state");
|
||||||
|
state.trackedTokens.splice(0, 1);
|
||||||
|
await saveState();
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
|
||||||
|
// Sepolia. That switch puts DAI away under Ethereum Mainnet. This page has not
|
||||||
|
// taken the switch yet. A switch to Sepolia in this page's Settings keeps DAI
|
||||||
|
// on Ethereum Mainnet's list.
|
||||||
|
test("a token another page tracked before its own switch stays tracked on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [],
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page records a fraud contract on Ethereum Mainnet after this
|
||||||
|
// page's last save. The switch to Sepolia drops that fraud contract, as the
|
||||||
|
// switch drops this page's own.
|
||||||
|
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
|
||||||
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
fraudContracts: [NEW_TOKEN_ADDRESS],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.fraudContracts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
|
// 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
|
// 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.
|
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
|
||||||
@@ -249,6 +473,120 @@ test("a token added while a switch in Settings waits for the lock stays tracked
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
|
||||||
|
// this page's switch to Sepolia waits for the lock. Another popup page holds
|
||||||
|
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
|
||||||
|
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
|
||||||
|
// list.
|
||||||
|
test("a token added while a switch in Settings waits for another page's switch 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");
|
||||||
|
|
||||||
|
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();
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [],
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
{
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
|
||||||
|
// tracked on Ethereum Mainnet. This page waits for the lock to take that
|
||||||
|
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
|
||||||
|
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
|
||||||
|
// stay on Ethereum Mainnet's list.
|
||||||
|
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
let releaseLock;
|
||||||
|
const lockHeld = navigator.locks.request(
|
||||||
|
"autistmask",
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseLock = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await boot.storage.local.set({
|
||||||
|
autistmask: {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkId: "sepolia",
|
||||||
|
trackedTokens: [],
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await boot.settle();
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce({
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
});
|
||||||
|
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
const adding = boot.click("btn-settings-addtoken-manual");
|
||||||
|
await boot.settle();
|
||||||
|
releaseLock();
|
||||||
|
await lockHeld;
|
||||||
|
await adding;
|
||||||
|
await boot.settle();
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// TransactionDetail shows only details that Blockscout gave for the
|
||||||
|
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
|
||||||
|
//
|
||||||
|
// The screen asks Blockscout for its transaction and does not wait for the
|
||||||
|
// answer. So the user can press Back and open another transaction before
|
||||||
|
// Blockscout answers for the first. The first answer must then fill nothing on
|
||||||
|
// the second transaction's screen. An answer that lands while Settings is up
|
||||||
|
// must still fill TransactionDetail.
|
||||||
|
//
|
||||||
|
// Driven through the real popup over a stored profile
|
||||||
|
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
|
||||||
|
// opens it: the current screen is pushed, then transactionDetail.show() draws
|
||||||
|
// the transaction.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||||
|
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
|
||||||
|
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
||||||
|
|
||||||
|
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
|
||||||
|
// network with `chainId`.
|
||||||
|
function historyEntry(hash, chainId, symbol) {
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
from: ADDRESS,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
symbol,
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
contractAddress: null,
|
||||||
|
chainId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
|
||||||
|
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
|
||||||
|
// A transaction on Sepolia with the same hash as FIRST.
|
||||||
|
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
|
||||||
|
|
||||||
|
// The URL that asks Blockscout for `entry`, on the network it was sent on.
|
||||||
|
function requestUrl(entry) {
|
||||||
|
const blockscout =
|
||||||
|
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
|
||||||
|
return blockscout + "/transactions/" + entry.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Blockscout reports for each transaction, by the URL that asks for it.
|
||||||
|
const ANSWERS = {
|
||||||
|
[requestUrl(FIRST)]: {
|
||||||
|
block_number: 21000001,
|
||||||
|
nonce: 4441,
|
||||||
|
fee: { value: "21000000000000" },
|
||||||
|
raw_input: "0x11111111",
|
||||||
|
method: "firstMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(SECOND)]: {
|
||||||
|
block_number: 21000002,
|
||||||
|
nonce: 4442,
|
||||||
|
fee: { value: "42000000000000" },
|
||||||
|
raw_input: "0x22222222",
|
||||||
|
method: "secondMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(FIRST_ON_SEPOLIA)]: {
|
||||||
|
block_number: 21000003,
|
||||||
|
nonce: 4443,
|
||||||
|
fee: { value: "63000000000000" },
|
||||||
|
raw_input: "0x33333333",
|
||||||
|
method: "sepoliaMethod",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The text each answer puts on screen: its block, nonce, fee, method name and
|
||||||
|
// raw data. No text from one answer appears in another.
|
||||||
|
const FIRST_SHOWN = [
|
||||||
|
"21000001",
|
||||||
|
"4441",
|
||||||
|
"0.000021 ETH",
|
||||||
|
"firstMethod",
|
||||||
|
"0x11111111",
|
||||||
|
];
|
||||||
|
const SECOND_SHOWN = [
|
||||||
|
"21000002",
|
||||||
|
"4442",
|
||||||
|
"0.000042 ETH",
|
||||||
|
"secondMethod",
|
||||||
|
"0x22222222",
|
||||||
|
];
|
||||||
|
const FIRST_ON_SEPOLIA_SHOWN = [
|
||||||
|
"21000003",
|
||||||
|
"4443",
|
||||||
|
"0.000063 SepoliaETH",
|
||||||
|
"sepoliaMethod",
|
||||||
|
"0x33333333",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Everything drawn in the fields that Blockscout's answer fills.
|
||||||
|
function drawnDetails(page) {
|
||||||
|
return [
|
||||||
|
"tx-detail-block",
|
||||||
|
"tx-detail-nonce",
|
||||||
|
"tx-detail-fee",
|
||||||
|
"tx-detail-calldata-action",
|
||||||
|
"tx-detail-rawdata",
|
||||||
|
]
|
||||||
|
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens `entry` on TransactionDetail, as a history screen does.
|
||||||
|
function open(entry) {
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/transactionDetail").show(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The URLs of every request to Blockscout so far, in order.
|
||||||
|
function requestedUrls() {
|
||||||
|
return globalThis.fetch.mock.calls.map(([url]) => url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
|
||||||
|
// request at once from ANSWERS, except the request for `held`. Returns the
|
||||||
|
// page and answer(), which lets the answer for `held` land.
|
||||||
|
async function bootWithHeldRequest(held) {
|
||||||
|
const page = await bootPopup(
|
||||||
|
unversionedValidProfile({ useBlockscout: true }),
|
||||||
|
);
|
||||||
|
let answer;
|
||||||
|
const heldAnswer = new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
globalThis.fetch = jest.fn(async (url) => {
|
||||||
|
if (url === requestUrl(held)) await heldAnswer;
|
||||||
|
return { ok: true, status: 200, json: async () => ANSWERS[url] };
|
||||||
|
});
|
||||||
|
return { page, answer: () => answer() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user opens `first`, whose request to Blockscout is held. Then the user
|
||||||
|
// presses Back and opens `second`. Then the answer for `first` lands. Returns
|
||||||
|
// the page.
|
||||||
|
async function firstAnswerLandsOnSecond(first, second) {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(first);
|
||||||
|
open(first);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-tx-back");
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
open(second);
|
||||||
|
await page.settle();
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(FIRST);
|
||||||
|
open(FIRST);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
expect(page.visibleViews()).toEqual(["settings"]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user