Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f46515d87 |
@@ -905,8 +905,8 @@ src/
|
|||||||
prices.js — ETH/USD and token/USD from Chainlink price feeds
|
prices.js — ETH/USD and token/USD from Chainlink price feeds
|
||||||
scamlist.js — known fraud contract addresses
|
scamlist.js — known fraud contract addresses
|
||||||
state.js — persisted state (extension storage)
|
state.js — persisted state (extension storage)
|
||||||
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
|
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
|
||||||
read from storage for the requests those settings gate
|
the active network, read from storage
|
||||||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
||||||
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
|
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
|
||||||
price feeds (hardcoded)
|
price feeds (hardcoded)
|
||||||
@@ -2279,11 +2279,6 @@ 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
|
|
||||||
- If the user taps the same "Save" again before the endpoint answers, the
|
|
||||||
earlier tap stores nothing and shows no flash message
|
|
||||||
- Etherscan:
|
- Etherscan:
|
||||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||||
ticks it
|
ticks it
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -98,6 +99,7 @@ let loadedTxs = [];
|
|||||||
let ensNameMap = new Map();
|
let ensNameMap = new Map();
|
||||||
|
|
||||||
async function loadTransactions(address) {
|
async function loadTransactions(address) {
|
||||||
|
const networkId = state.networkId;
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -114,43 +116,74 @@ async function loadTransactions(address) {
|
|||||||
});
|
});
|
||||||
const txs = result.transactions;
|
const txs = result.transactions;
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts
|
|
||||||
if (result.newFraudContracts.length > 0) {
|
|
||||||
for (const addr of result.newFraudContracts) {
|
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
|
||||||
state.fraudContracts.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await saveState();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedTxs = txs;
|
|
||||||
|
|
||||||
// Collect ALL unique addresses (from + to) for ENS resolution so
|
// Collect ALL unique addresses (from + to) for ENS resolution so
|
||||||
// that reverse lookups work for every displayed address, not just
|
// that reverse lookups work for every displayed address, not just
|
||||||
// the ones that were originally entered as ENS names.
|
// the ones that were originally entered as ENS names.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
|
let names = new Map();
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
// The rows then show no ENS names.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the network changed during the lookups above, or the screen now
|
||||||
|
// shows another address, nothing is saved or drawn. See
|
||||||
|
// src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
$("address-line").dataset.full !== address
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist any newly discovered fraud contracts, inside the save and
|
||||||
|
// holding the lock. Nothing is added unless this page and the stored
|
||||||
|
// record both still name the request's network.
|
||||||
|
if (result.newFraudContracts.length > 0) {
|
||||||
|
let saved = false;
|
||||||
|
await saveState((stored) => {
|
||||||
|
if (state.networkId !== networkId) return false;
|
||||||
|
if (stored.networkId !== networkId) return false;
|
||||||
|
for (const addr of result.newFraudContracts) {
|
||||||
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
|
state.fraudContracts.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved = true;
|
||||||
|
});
|
||||||
|
// If the network changed while the save waited for the lock,
|
||||||
|
// nothing is drawn.
|
||||||
|
if (!saved) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedTxs = txs;
|
||||||
|
ensNameMap = names;
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
|
// If the network changed, or the screen now shows another address,
|
||||||
|
// the failure is not shown.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
$("address-line").dataset.full !== address
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("tx-list").innerHTML =
|
$("tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -170,6 +171,7 @@ function show() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTransactions(address, tokenId) {
|
async function loadTransactions(address, tokenId) {
|
||||||
|
const networkId = state.networkId;
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -186,16 +188,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
});
|
});
|
||||||
let txs = result.transactions;
|
let txs = result.transactions;
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts
|
|
||||||
if (result.newFraudContracts.length > 0) {
|
|
||||||
for (const addr of result.newFraudContracts) {
|
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
|
||||||
state.fraudContracts.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await saveState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to this token only
|
// Filter to this token only
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
txs = txs.filter((tx) => tx.contractAddress === null);
|
txs = txs.filter((tx) => tx.contractAddress === null);
|
||||||
@@ -207,32 +199,75 @@ async function loadTransactions(address, tokenId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedTxs = txs;
|
|
||||||
|
|
||||||
// Collect ALL unique addresses for ENS resolution so reverse
|
// Collect ALL unique addresses for ENS resolution so reverse
|
||||||
// lookups work for every displayed address.
|
// lookups work for every displayed address.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
|
let names = new Map();
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
// The rows then show no ENS names.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the network changed during the lookups above, or the screen now
|
||||||
|
// shows another address or token, nothing is saved or drawn. See
|
||||||
|
// src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
$("address-token-line").dataset.full !== address ||
|
||||||
|
state.selectedToken !== tokenId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist any newly discovered fraud contracts, inside the save and
|
||||||
|
// holding the lock. Nothing is added unless this page and the stored
|
||||||
|
// record both still name the request's network.
|
||||||
|
if (result.newFraudContracts.length > 0) {
|
||||||
|
let saved = false;
|
||||||
|
await saveState((stored) => {
|
||||||
|
if (state.networkId !== networkId) return false;
|
||||||
|
if (stored.networkId !== networkId) return false;
|
||||||
|
for (const addr of result.newFraudContracts) {
|
||||||
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
|
state.fraudContracts.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved = true;
|
||||||
|
});
|
||||||
|
// If the network changed while the save waited for the lock,
|
||||||
|
// nothing is drawn.
|
||||||
|
if (!saved) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedTxs = txs;
|
||||||
|
ensNameMap = names;
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
|
// If the network changed, or the screen now shows another address or
|
||||||
|
// token, the failure is not shown.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
$("address-token-line").dataset.full !== address ||
|
||||||
|
state.selectedToken !== tokenId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("address-token-tx-list").innerHTML =
|
$("address-token-tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-6
@@ -44,6 +44,7 @@ const {
|
|||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
function findActiveAddr() {
|
function findActiveAddr() {
|
||||||
@@ -183,6 +184,7 @@ async function loadHomeTxs(ctx) {
|
|||||||
}
|
}
|
||||||
if (allAddresses.length === 0) return;
|
if (allAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const networkId = state.networkId;
|
||||||
const filters = {
|
const filters = {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
@@ -202,6 +204,15 @@ async function loadHomeTxs(ctx) {
|
|||||||
);
|
);
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
|
|
||||||
|
// If the network changed during the lookups, nothing is saved or
|
||||||
|
// drawn. See src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge, deduplicate by hash, filter, sort, take 25
|
// Merge, deduplicate by hash, filter, sort, take 25
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let merged = [];
|
let merged = [];
|
||||||
@@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
const filtered = filterTransactions(merged, filters);
|
const filtered = filterTransactions(merged, filters);
|
||||||
|
|
||||||
// Persist any newly discovered fraud contracts
|
// Persist any newly discovered fraud contracts, inside the save and
|
||||||
|
// holding the lock. Nothing is added unless this page and the stored
|
||||||
|
// record both still name the request's network.
|
||||||
if (filtered.newFraudContracts.length > 0) {
|
if (filtered.newFraudContracts.length > 0) {
|
||||||
for (const addr of filtered.newFraudContracts) {
|
let saved = false;
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
await saveState((stored) => {
|
||||||
state.fraudContracts.push(addr);
|
if (state.networkId !== networkId) return false;
|
||||||
|
if (stored.networkId !== networkId) return false;
|
||||||
|
for (const addr of filtered.newFraudContracts) {
|
||||||
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
|
state.fraudContracts.push(addr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
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;
|
||||||
@@ -234,6 +255,13 @@ async function loadHomeTxs(ctx) {
|
|||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadHomeTxs failed:", e.message);
|
log.errorf("loadHomeTxs failed:", e.message);
|
||||||
|
// If the network changed, the failure is not shown.
|
||||||
|
if (
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
!(await storedNetworkIs(networkId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (list) {
|
if (list) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ let versionClickTimer = null;
|
|||||||
// not the one in `state`.
|
// not the one in `state`.
|
||||||
let shownNetwork = null;
|
let shownNetwork = null;
|
||||||
|
|
||||||
// Each endpoint "Save" counts its taps here, under the field that it stores. A
|
|
||||||
// tap stores its URL and shows a flash message only while it is the latest tap
|
|
||||||
// of its "Save".
|
|
||||||
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
|
|
||||||
|
|
||||||
// One row per site origin, however many addresses it appears under, each with
|
// One row per site origin, however many addresses it appears under, each with
|
||||||
// an [x] that hands it to onRemove.
|
// an [x] that hands it to onRemove.
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
function renderSiteList(containerId, origins, onRemove) {
|
||||||
@@ -344,38 +339,17 @@ 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`.
|
|
||||||
//
|
|
||||||
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
|
|
||||||
// the same "Save" again before this save holds the lock, nothing is stored. If
|
|
||||||
// the user taps the same "Save" again before the write ends, no flash message
|
|
||||||
// is shown.
|
|
||||||
async function saveEndpoint(field, input, url, networkId, tap) {
|
|
||||||
let refused = false;
|
let refused = false;
|
||||||
let inputChanged = false;
|
|
||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
if (saveTaps[field] !== tap) return false;
|
|
||||||
refused =
|
refused =
|
||||||
state.networkId !== networkId || stored.networkId !== networkId;
|
state.networkId !== networkId || stored.networkId !== networkId;
|
||||||
inputChanged = $(input).value.trim() !== url;
|
if (refused) return false;
|
||||||
if (refused || inputChanged) return false;
|
|
||||||
state[field] = url;
|
state[field] = url;
|
||||||
stored[field] = url;
|
stored[field] = url;
|
||||||
});
|
});
|
||||||
if (saveTaps[field] !== tap) return;
|
|
||||||
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.");
|
||||||
}
|
}
|
||||||
@@ -387,8 +361,6 @@ function init(_ctx) {
|
|||||||
showPhrase.init();
|
showPhrase.init();
|
||||||
|
|
||||||
$("btn-save-rpc").addEventListener("click", async () => {
|
$("btn-save-rpc").addEventListener("click", async () => {
|
||||||
saveTaps.rpcUrl++;
|
|
||||||
const tap = saveTaps.rpcUrl;
|
|
||||||
const url = $("settings-rpc").value.trim();
|
const url = $("settings-rpc").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter an RPC URL.");
|
showFlash("Please enter an RPC URL.");
|
||||||
@@ -409,8 +381,6 @@ function init(_ctx) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
// The user has tapped this "Save" again since the check began.
|
|
||||||
if (tap !== saveTaps.rpcUrl) return;
|
|
||||||
if (json.error) {
|
if (json.error) {
|
||||||
log.errorf("RPC validation error:", json.error);
|
log.errorf("RPC validation error:", json.error);
|
||||||
showFlash("Endpoint returned an error.");
|
showFlash("Endpoint returned an error.");
|
||||||
@@ -424,7 +394,6 @@ function init(_ctx) {
|
|||||||
// 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;
|
||||||
if (tap !== saveTaps.rpcUrl) return;
|
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
// Not the error's message: fetch puts the whole URL, password and
|
||||||
// key included, in the message of the error it throws for a URL
|
// key included, in the message of the error it throws for a URL
|
||||||
// with a user name and password or one it cannot parse.
|
// with a user name and password or one it cannot parse.
|
||||||
@@ -432,12 +401,10 @@ function init(_ctx) {
|
|||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
|
await saveEndpoint("rpcUrl", url, net.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||||
saveTaps.blockscoutUrl++;
|
|
||||||
const tap = saveTaps.blockscoutUrl;
|
|
||||||
const url = $("settings-blockscout").value.trim();
|
const url = $("settings-blockscout").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter a Blockscout API URL.");
|
showFlash("Please enter a Blockscout API URL.");
|
||||||
@@ -447,26 +414,18 @@ function init(_ctx) {
|
|||||||
showFlash("Testing endpoint...");
|
showFlash("Testing endpoint...");
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(url + "/stats");
|
const resp = await debugFetch(url + "/stats");
|
||||||
if (tap !== saveTaps.blockscoutUrl) return;
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
if (tap !== saveTaps.blockscoutUrl) return;
|
|
||||||
// Not the error's message, as for the RPC check above.
|
// Not the error's message, as for the RPC check above.
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint(
|
await saveEndpoint("blockscoutUrl", url, net.id);
|
||||||
"blockscoutUrl",
|
|
||||||
"settings-blockscout",
|
|
||||||
url,
|
|
||||||
net.id,
|
|
||||||
tap,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock. So
|
// Each change is made inside the save, holding the lock. So
|
||||||
|
|||||||
@@ -311,21 +311,10 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether TransactionDetail still holds the transaction with `txHash` on
|
|
||||||
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
|
||||||
// holds. A visit to Settings does not.
|
|
||||||
function holdsTransaction(txHash, chainId) {
|
|
||||||
const tx = state.viewData.tx;
|
|
||||||
return tx?.hash === txHash && tx?.chainId === chainId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
// asked. An answer that lands after the setting went off is dropped
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||||
//
|
|
||||||
// Blockscout can answer after the user has opened another transaction. That
|
|
||||||
// answer is dropped.
|
|
||||||
async function loadFullTxDetails(txHash, chainId) {
|
async function loadFullTxDetails(txHash, chainId) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
@@ -344,7 +333,6 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
if (!holdsTransaction(txHash, chainId)) return;
|
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
||||||
// profile.
|
// from the stored profile.
|
||||||
//
|
//
|
||||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
||||||
// wallet reads that service's setting from storage. The wallet reads the setting
|
// wallet reads that service's setting from storage. The wallet reads the setting
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
// made, and an answer that lands after the setting went off is dropped. A page's
|
// made, and an answer that lands after the setting went off is dropped. A page's
|
||||||
// own copy of the setting is not used, because another page may have just
|
// own copy of the setting is not used, because another page may have just
|
||||||
// changed it.
|
// changed it.
|
||||||
|
//
|
||||||
|
// Before asking Blockscout for the transaction history, a screen remembers
|
||||||
|
// which network is active. If another network is active on the page or in
|
||||||
|
// storage when the answer lands, the screen drops the answer. A switch saved
|
||||||
|
// elsewhere reaches storage first and the page later.
|
||||||
|
|
||||||
const { storageGet } = require("./browserApi");
|
const { storageGet } = require("./browserApi");
|
||||||
const { normalizePersisted } = require("./persistedState");
|
const { normalizePersisted } = require("./persistedState");
|
||||||
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { storedSettingIsOn };
|
// Whether `networkId` is the active network in the stored profile. A failed
|
||||||
|
// read counts as no.
|
||||||
|
async function storedNetworkIs(networkId) {
|
||||||
|
try {
|
||||||
|
const { autistmask } = await storageGet("autistmask");
|
||||||
|
return normalizePersisted(autistmask).networkId === networkId;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { storedSettingIsOn, storedNetworkIs };
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// Home, AddressDetail and AddressToken drop a transaction history answer that
|
||||||
|
// lands after a network switch. A dropped answer draws no rows and saves no
|
||||||
|
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
|
||||||
|
//
|
||||||
|
// Most tests hold the history request open, switch the network or what the
|
||||||
|
// screen shows, and then let the answer land. The views run against a minimal
|
||||||
|
// DOM stub, as in tests/transactionListCancelled.test.js, and over
|
||||||
|
// tests/support/storageStub.js.
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
|
||||||
|
|
||||||
|
jest.mock("../src/shared/transactions", () => ({
|
||||||
|
...jest.requireActual("../src/shared/transactions"),
|
||||||
|
fetchRecentTransactions: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
jest.mock("../src/shared/ens", () => ({
|
||||||
|
resolveEnsNames: jest.fn(async () => new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||||
|
const { resolveEnsNames } = require("../src/shared/ens");
|
||||||
|
const { state, loadState } = require("../src/shared/state");
|
||||||
|
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
||||||
|
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
||||||
|
const { storedNetworkIs } = require("../src/shared/storedSettings");
|
||||||
|
const home = require("../src/popup/views/home");
|
||||||
|
const addressDetail = require("../src/popup/views/addressDetail");
|
||||||
|
const addressToken = require("../src/popup/views/addressToken");
|
||||||
|
|
||||||
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
|
||||||
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
|
||||||
|
// adds this contract to the fraud contracts.
|
||||||
|
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
|
||||||
|
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
||||||
|
|
||||||
|
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
|
||||||
|
// fraud contracts.
|
||||||
|
function profile() {
|
||||||
|
return {
|
||||||
|
schemaVersion: STATE_SCHEMA_VERSION,
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Main",
|
||||||
|
xpub: "xpub-main",
|
||||||
|
encryptedSecret: "encrypted-secret",
|
||||||
|
nextIndex: 2,
|
||||||
|
addresses: [
|
||||||
|
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
|
||||||
|
{
|
||||||
|
address: OTHER_ADDRESS,
|
||||||
|
balance: "0.0",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
networkId: "mainnet",
|
||||||
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||||
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||||
|
useBlockscout: true,
|
||||||
|
fraudContracts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
|
||||||
|
//
|
||||||
|
// - a transfer of ETH from ADDRESS;
|
||||||
|
// - a transfer of the fake USDT from SPOOF_CONTRACT.
|
||||||
|
function history() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
|
||||||
|
blockNumber: 2,
|
||||||
|
from: ADDRESS,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
rawAmount: "1000000000000000000",
|
||||||
|
rawUnit: "wei",
|
||||||
|
symbol: "ETH",
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
directionLabel: "Sent",
|
||||||
|
direction: "sent",
|
||||||
|
contractAddress: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
|
||||||
|
blockNumber: 1,
|
||||||
|
from: SPOOF_CONTRACT,
|
||||||
|
to: ADDRESS,
|
||||||
|
value: "100.0000",
|
||||||
|
exactValue: "100.0",
|
||||||
|
rawAmount: "100000000",
|
||||||
|
rawUnit: "base units",
|
||||||
|
symbol: "USDT",
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
directionLabel: "Received",
|
||||||
|
direction: "received",
|
||||||
|
contractAddress: SPOOF_CONTRACT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next history request waits until the returned function is called, and
|
||||||
|
// then answers with `answer`.
|
||||||
|
function holdHistory(answer = history()) {
|
||||||
|
let land;
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
land = () => resolve(answer);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => land();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next history request waits until the returned function is called, and
|
||||||
|
// then fails.
|
||||||
|
function holdFailure() {
|
||||||
|
let fail;
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
fail = () => reject(new Error("Blockscout is down"));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another write holds the lock until the returned function is called. Every
|
||||||
|
// save waits until then.
|
||||||
|
function holdLock() {
|
||||||
|
let release;
|
||||||
|
const held = navigator.locks.request(
|
||||||
|
"autistmask",
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return async () => {
|
||||||
|
release();
|
||||||
|
await held;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeElement(id) {
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
contains: () => false,
|
||||||
|
toggle: () => false,
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
appendChild: () => {},
|
||||||
|
};
|
||||||
|
// Views reach for .parentElement to hide whole sections.
|
||||||
|
Object.defineProperty(el, "parentElement", {
|
||||||
|
get: () => node(id + "-parent"),
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
// The debug banner is created on demand by helpers.js; absent
|
||||||
|
// is the state a non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement: () => makeElement("created"),
|
||||||
|
addEventListener: () => {},
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id) {
|
||||||
|
return globalThis.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets every promise that is not held open settle.
|
||||||
|
function settle() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = makeStorageStub({ autistmask: profile() });
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: storage.local, onChanged: storage.onChanged },
|
||||||
|
};
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
globalThis.window = { location: { search: "" } };
|
||||||
|
await loadState();
|
||||||
|
state.viewStack = [];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.selectedToken = "ETH";
|
||||||
|
const ctx = { pageClosed: new AbortController().signal };
|
||||||
|
addressDetail.init(ctx);
|
||||||
|
addressToken.init(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.chrome;
|
||||||
|
delete globalThis.document;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
const HOME = {
|
||||||
|
name: "Home",
|
||||||
|
listId: "home-tx-list",
|
||||||
|
open: () => home.render({ pageClosed: new AbortController().signal }),
|
||||||
|
};
|
||||||
|
const ADDRESS_DETAIL = {
|
||||||
|
name: "AddressDetail",
|
||||||
|
listId: "tx-list",
|
||||||
|
open: () => addressDetail.show(),
|
||||||
|
};
|
||||||
|
const ADDRESS_TOKEN = {
|
||||||
|
name: "AddressToken",
|
||||||
|
listId: "address-token-tx-list",
|
||||||
|
open: () => addressToken.show(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Three ways a test switches the network:
|
||||||
|
//
|
||||||
|
// - made in Settings: the page and storage have both switched;
|
||||||
|
// - saved elsewhere: storage has switched and the page has not;
|
||||||
|
// - not yet stored: the page has switched and storage has not.
|
||||||
|
const SWITCHES = [
|
||||||
|
{
|
||||||
|
how: "made in Settings",
|
||||||
|
to: (networkId) => onChainSwitch(networkId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Another page or the background stored the switch, and this page has
|
||||||
|
// not followed it yet.
|
||||||
|
how: "saved elsewhere",
|
||||||
|
to: async (networkId) => {
|
||||||
|
const record = storage.read("autistmask");
|
||||||
|
applyChainSwitchFields(record, networkId);
|
||||||
|
storage.write("autistmask", record);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The switch's own save has changed the page and has not yet written
|
||||||
|
// storage.
|
||||||
|
how: "not yet stored",
|
||||||
|
to: async (networkId) => {
|
||||||
|
applyChainSwitchFields(state, networkId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// The list still shows `before`, and neither the page nor storage holds a
|
||||||
|
// fraud contract.
|
||||||
|
function expectNothingAdded(screen, before) {
|
||||||
|
expect(node(screen.listId).innerHTML).toBe(before);
|
||||||
|
expect(state.fraudContracts).toEqual([]);
|
||||||
|
expect(storage.read("autistmask").fraudContracts).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
|
||||||
|
test("an answer that lands on the same network is drawn and saved", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(node(screen.listId).innerHTML).toContain("tx-row");
|
||||||
|
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
|
||||||
|
expect(storage.read("autistmask").fraudContracts).toEqual([
|
||||||
|
SPOOF_CONTRACT,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(SWITCHES)(
|
||||||
|
"an answer that lands after a switch $how adds nothing",
|
||||||
|
async ({ to }) => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
await to("sepolia");
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The answer is the transfer of ETH alone, which adds no fraud contract.
|
||||||
|
// Nothing is saved, so only the check before the save can drop the answer.
|
||||||
|
test.each(SWITCHES)(
|
||||||
|
"an answer without a fraud contract that lands after a switch $how adds nothing",
|
||||||
|
async ({ to }) => {
|
||||||
|
const land = holdHistory(history().slice(0, 1));
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
await to("sepolia");
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(SWITCHES)(
|
||||||
|
"a request that fails after a switch $how shows no message",
|
||||||
|
async ({ to }) => {
|
||||||
|
const fail = holdFailure();
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
await to("sepolia");
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(node(screen.listId).innerHTML).toBe(before);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user picks Sepolia in Settings while another write holds the lock.
|
||||||
|
// The answer lands, and its save waits behind the switch.
|
||||||
|
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
const releaseLock = holdLock();
|
||||||
|
const switching = onChainSwitch("sepolia");
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
await releaseLock();
|
||||||
|
await switching;
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The answer lands and its save waits for the lock. Another page or the
|
||||||
|
// background then stores a switch.
|
||||||
|
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
const releaseLock = holdLock();
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
const record = storage.read("autistmask");
|
||||||
|
applyChainSwitchFields(record, "sepolia");
|
||||||
|
storage.write("autistmask", record);
|
||||||
|
await releaseLock();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The answer lands and its save waits for the lock. The page then switches,
|
||||||
|
// and storage has not.
|
||||||
|
test("an answer whose save waits while the page switches adds nothing", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
const releaseLock = holdLock();
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
applyChainSwitchFields(state, "sepolia");
|
||||||
|
await releaseLock();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a request that fails after a switch leaves the new network's rows", async () => {
|
||||||
|
const fail = holdFailure();
|
||||||
|
screen.open();
|
||||||
|
await onChainSwitch("sepolia");
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(async () => history());
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const drawn = node(screen.listId).innerHTML;
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(drawn).toContain("tx-row");
|
||||||
|
expect(node(screen.listId).innerHTML).toBe(drawn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
|
||||||
|
test("an answer that lands once the screen shows another address adds nothing", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
screen.open();
|
||||||
|
state.selectedAddress = 1;
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a request that fails once the screen shows another address shows no message", async () => {
|
||||||
|
const fail = holdFailure();
|
||||||
|
screen.open();
|
||||||
|
state.selectedAddress = 1;
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(node(screen.listId).innerHTML).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer whose name lookup lands after a switch adds nothing", async () => {
|
||||||
|
let landNames;
|
||||||
|
resolveEnsNames.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
landNames = () => resolve(new Map());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(async () => history());
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
await onChainSwitch("sepolia");
|
||||||
|
landNames();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
|
||||||
|
const land = holdHistory();
|
||||||
|
addressToken.show();
|
||||||
|
state.selectedToken = TOKEN;
|
||||||
|
addressToken.show();
|
||||||
|
await settle();
|
||||||
|
const before = node(ADDRESS_TOKEN.listId).innerHTML;
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(ADDRESS_TOKEN, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
|
||||||
|
const fail = holdFailure();
|
||||||
|
addressToken.show();
|
||||||
|
state.selectedToken = TOKEN;
|
||||||
|
addressToken.show();
|
||||||
|
await settle();
|
||||||
|
const before = node(ADDRESS_TOKEN.listId).innerHTML;
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storedNetworkIs(): a failed read counts as no", async () => {
|
||||||
|
globalThis.chrome.storage.local = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("storage is down");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
|
||||||
|
});
|
||||||
@@ -47,347 +47,3 @@ 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.",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
|
||||||
const PASSING_ANSWER = {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Both endpoint checks read this answer as an error. The RPC check reads the
|
|
||||||
// answer's `error`. The Blockscout check reads the answer's `ok`.
|
|
||||||
const ERROR_ANSWER = {
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
|
|
||||||
// the user types a second URL and taps "Save" again. The second check passes
|
|
||||||
// first. The second URL is stored, and the flash message reads "Saved.". The
|
|
||||||
// first check then ends in one of three ways:
|
|
||||||
//
|
|
||||||
// - It passes.
|
|
||||||
// - It fails, because fetch cannot reach the endpoint.
|
|
||||||
// - It gets an answer that holds an error.
|
|
||||||
//
|
|
||||||
// None of the three changes the stored URL or the flash message
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "passes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "fails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "answers with an error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "passes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "fails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "answers with an error",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button keeps the second tap's result when the first check $first last",
|
|
||||||
async ({ button, input, field, first }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
checks.push({ resolve, reject });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const secondUrl = "https://second.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = "https://first.example.invalid";
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
page.node(input).value = secondUrl;
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
checks[1].resolve(PASSING_ANSWER);
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
if (first === "passes") {
|
|
||||||
checks[0].resolve(PASSING_ANSWER);
|
|
||||||
} else if (first === "answers with an error") {
|
|
||||||
checks[0].resolve(ERROR_ANSWER);
|
|
||||||
} else {
|
|
||||||
// fetch rejects when it cannot reach the endpoint.
|
|
||||||
checks[0].reject(new TypeError("Failed to fetch"));
|
|
||||||
}
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user types a URL in Settings and taps "Save". Another write holds the
|
|
||||||
// lock. So the save waits for the lock once the check passes. The user then
|
|
||||||
// taps "Save" again on the same URL. When the lock is free, the first save
|
|
||||||
// stores nothing and shows no flash message. The second check then passes, and
|
|
||||||
// the second save stores the URL.
|
|
||||||
test.each([
|
|
||||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button stores nothing for an earlier tap still waiting for the lock",
|
|
||||||
async ({ button, input, field }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const storedUrl = page.value(input);
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
checks.push(resolve);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const typedUrl = "https://typed.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = typedUrl;
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
let releaseLock;
|
|
||||||
const lockHeld = navigator.locks.request(
|
|
||||||
"autistmask",
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
releaseLock = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
checks[0](PASSING_ANSWER);
|
|
||||||
await page.settle();
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
releaseLock();
|
|
||||||
await lockHeld;
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Testing endpoint...");
|
|
||||||
|
|
||||||
checks[1](PASSING_ANSWER);
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user taps "Save", and the check passes. The save then begins its storage
|
|
||||||
// write, and this test keeps the write waiting. The user taps "Save" again, and
|
|
||||||
// the second check cannot reach the endpoint. When the write ends, the flash
|
|
||||||
// message still reads "Could not reach endpoint.". The write stores the URL
|
|
||||||
// anyway, because the write began before the second tap.
|
|
||||||
test.each([
|
|
||||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button keeps the second tap's message when tapped again during the write",
|
|
||||||
async ({ button, input, field }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
checks.push({ resolve, reject });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const typedUrl = "https://typed.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = typedUrl;
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
const { set } = page.storage.local;
|
|
||||||
const write = set.getMockImplementation();
|
|
||||||
let writeBegan = false;
|
|
||||||
let releaseWrite;
|
|
||||||
const writeHeld = new Promise((resolve) => {
|
|
||||||
releaseWrite = resolve;
|
|
||||||
});
|
|
||||||
set.mockImplementationOnce(async (items) => {
|
|
||||||
writeBegan = true;
|
|
||||||
await writeHeld;
|
|
||||||
await write(items);
|
|
||||||
});
|
|
||||||
checks[0].resolve(PASSING_ANSWER);
|
|
||||||
await page.settle();
|
|
||||||
expect(writeBegan).toBe(true);
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
// fetch rejects when it cannot reach the endpoint.
|
|
||||||
checks[1].reject(new TypeError("Failed to fetch"));
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
|
||||||
releaseWrite();
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
// TransactionDetail shows only details that Blockscout gave for the
|
|
||||||
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
|
|
||||||
//
|
|
||||||
// The screen asks Blockscout for its transaction and does not wait for the
|
|
||||||
// answer. So the user can press Back and open another transaction before
|
|
||||||
// Blockscout answers for the first. The first answer must then fill nothing on
|
|
||||||
// the second transaction's screen. An answer that lands while Settings is up
|
|
||||||
// must still fill TransactionDetail.
|
|
||||||
//
|
|
||||||
// Driven through the real popup over a stored profile
|
|
||||||
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
|
|
||||||
// opens it: the current screen is pushed, then transactionDetail.show() draws
|
|
||||||
// the transaction.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
|
||||||
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
|
|
||||||
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
|
||||||
|
|
||||||
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
|
|
||||||
// network with `chainId`.
|
|
||||||
function historyEntry(hash, chainId, symbol) {
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
from: ADDRESS,
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "1.0000",
|
|
||||||
exactValue: "1.0",
|
|
||||||
symbol,
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
direction: "sent",
|
|
||||||
directionLabel: "Sent",
|
|
||||||
contractAddress: null,
|
|
||||||
chainId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
|
|
||||||
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
|
|
||||||
// A transaction on Sepolia with the same hash as FIRST.
|
|
||||||
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
|
|
||||||
|
|
||||||
// The URL that asks Blockscout for `entry`, on the network it was sent on.
|
|
||||||
function requestUrl(entry) {
|
|
||||||
const blockscout =
|
|
||||||
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
|
|
||||||
return blockscout + "/transactions/" + entry.hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
// What Blockscout reports for each transaction, by the URL that asks for it.
|
|
||||||
const ANSWERS = {
|
|
||||||
[requestUrl(FIRST)]: {
|
|
||||||
block_number: 21000001,
|
|
||||||
nonce: 4441,
|
|
||||||
fee: { value: "21000000000000" },
|
|
||||||
raw_input: "0x11111111",
|
|
||||||
method: "firstMethod",
|
|
||||||
},
|
|
||||||
[requestUrl(SECOND)]: {
|
|
||||||
block_number: 21000002,
|
|
||||||
nonce: 4442,
|
|
||||||
fee: { value: "42000000000000" },
|
|
||||||
raw_input: "0x22222222",
|
|
||||||
method: "secondMethod",
|
|
||||||
},
|
|
||||||
[requestUrl(FIRST_ON_SEPOLIA)]: {
|
|
||||||
block_number: 21000003,
|
|
||||||
nonce: 4443,
|
|
||||||
fee: { value: "63000000000000" },
|
|
||||||
raw_input: "0x33333333",
|
|
||||||
method: "sepoliaMethod",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The text each answer puts on screen: its block, nonce, fee, method name and
|
|
||||||
// raw data. No text from one answer appears in another.
|
|
||||||
const FIRST_SHOWN = [
|
|
||||||
"21000001",
|
|
||||||
"4441",
|
|
||||||
"0.000021 ETH",
|
|
||||||
"firstMethod",
|
|
||||||
"0x11111111",
|
|
||||||
];
|
|
||||||
const SECOND_SHOWN = [
|
|
||||||
"21000002",
|
|
||||||
"4442",
|
|
||||||
"0.000042 ETH",
|
|
||||||
"secondMethod",
|
|
||||||
"0x22222222",
|
|
||||||
];
|
|
||||||
const FIRST_ON_SEPOLIA_SHOWN = [
|
|
||||||
"21000003",
|
|
||||||
"4443",
|
|
||||||
"0.000063 SepoliaETH",
|
|
||||||
"sepoliaMethod",
|
|
||||||
"0x33333333",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Everything drawn in the fields that Blockscout's answer fills.
|
|
||||||
function drawnDetails(page) {
|
|
||||||
return [
|
|
||||||
"tx-detail-block",
|
|
||||||
"tx-detail-nonce",
|
|
||||||
"tx-detail-fee",
|
|
||||||
"tx-detail-calldata-action",
|
|
||||||
"tx-detail-rawdata",
|
|
||||||
]
|
|
||||||
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opens `entry` on TransactionDetail, as a history screen does.
|
|
||||||
function open(entry) {
|
|
||||||
require("../src/popup/views/helpers").pushCurrentView();
|
|
||||||
require("../src/popup/views/transactionDetail").show(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The URLs of every request to Blockscout so far, in order.
|
|
||||||
function requestedUrls() {
|
|
||||||
return globalThis.fetch.mock.calls.map(([url]) => url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
|
|
||||||
// request at once from ANSWERS, except the request for `held`. Returns the
|
|
||||||
// page and answer(), which lets the answer for `held` land.
|
|
||||||
async function bootWithHeldRequest(held) {
|
|
||||||
const page = await bootPopup(
|
|
||||||
unversionedValidProfile({ useBlockscout: true }),
|
|
||||||
);
|
|
||||||
let answer;
|
|
||||||
const heldAnswer = new Promise((resolve) => {
|
|
||||||
answer = resolve;
|
|
||||||
});
|
|
||||||
globalThis.fetch = jest.fn(async (url) => {
|
|
||||||
if (url === requestUrl(held)) await heldAnswer;
|
|
||||||
return { ok: true, status: 200, json: async () => ANSWERS[url] };
|
|
||||||
});
|
|
||||||
return { page, answer: () => answer() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user opens `first`, whose request to Blockscout is held. Then the user
|
|
||||||
// presses Back and opens `second`. Then the answer for `first` lands. Returns
|
|
||||||
// the page.
|
|
||||||
async function firstAnswerLandsOnSecond(first, second) {
|
|
||||||
const { page, answer } = await bootWithHeldRequest(first);
|
|
||||||
open(first);
|
|
||||||
await page.settle();
|
|
||||||
await page.click("btn-tx-back");
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
|
||||||
open(second);
|
|
||||||
await page.settle();
|
|
||||||
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
|
|
||||||
answer();
|
|
||||||
await page.settle();
|
|
||||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
|
|
||||||
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
|
|
||||||
|
|
||||||
const drawn = drawnDetails(page);
|
|
||||||
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
|
|
||||||
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
|
|
||||||
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
|
|
||||||
|
|
||||||
const drawn = drawnDetails(page);
|
|
||||||
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
|
|
||||||
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
|
|
||||||
const { page, answer } = await bootWithHeldRequest(FIRST);
|
|
||||||
open(FIRST);
|
|
||||||
await page.settle();
|
|
||||||
await page.click("btn-settings");
|
|
||||||
expect(page.visibleViews()).toEqual(["settings"]);
|
|
||||||
answer();
|
|
||||||
await page.settle();
|
|
||||||
await page.click("btn-settings");
|
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
|
||||||
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
|
|
||||||
const drawn = drawnDetails(page);
|
|
||||||
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user