Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71480822b |
@@ -2233,8 +2233,6 @@ view would leave a wallet one click from deletion.
|
||||
**AddressDetail**
|
||||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||
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
|
||||
answers → nothing is tracked; "The active network changed before the
|
||||
lookup finished, so the token was not added." flash message, no screen
|
||||
|
||||
+10
-27
@@ -65,14 +65,7 @@ function renderWalletList() {
|
||||
const pageClosed = new AbortController();
|
||||
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 refreshAgain = false;
|
||||
|
||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
||||
// the recovery screen: there is no profile left to refresh.
|
||||
@@ -96,10 +89,7 @@ let refreshTimer = null;
|
||||
// user turned "Use Blockscout" off. So the save drops every token the user has
|
||||
// not tracked if the stored setting is off by then.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) {
|
||||
refreshAgain = true;
|
||||
return;
|
||||
}
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
await followStoredSettings();
|
||||
@@ -142,35 +132,28 @@ async function doRefreshAndRender() {
|
||||
});
|
||||
renderWalletList();
|
||||
} catch (e) {
|
||||
// No caller waits for this function, so it must never reject. An
|
||||
// unhandled rejection reports nothing. The save inside it reports its
|
||||
// own failure through onSaveFailure() (see src/shared/state.js); what
|
||||
// is left here is a failed network round trip, which the next tick
|
||||
// retries.
|
||||
// Every call site fires this and walks away — the boot below, the ten
|
||||
// second interval, and eight views through ctx — so it must never
|
||||
// reject: an unhandled rejection is not a report of anything. The save
|
||||
// inside it reports its own failure through onSaveFailure() (see
|
||||
// src/shared/state.js); what is left here is a failed network round
|
||||
// trip, which the next tick retries.
|
||||
log.errorf("popup: background refresh failed:", e);
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
if (refreshAgain) {
|
||||
refreshAgain = false;
|
||||
doRefreshAndRender();
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// adds one, which in a popup that opened on Welcome is the first
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
|
||||
// refresh already started, it starts no second one. On the recovery screen it
|
||||
// does nothing.
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
|
||||
// running it starts no second one, and on the recovery screen it does nothing.
|
||||
function startRefresh() {
|
||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||
doRefreshAndRender();
|
||||
if (refreshTimer === null) {
|
||||
// A tick that finds a refresh running is dropped.
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!refreshInFlight) return doRefreshAndRender();
|
||||
}, 10000);
|
||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,7 @@ const { lookupTokenInfo } = require("../../shared/balances");
|
||||
const { isScamAddress } = require("../../shared/scamlist");
|
||||
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() {
|
||||
addTokenScreenOpenings++;
|
||||
$("add-token-address").value = "";
|
||||
$("add-token-info").textContent = "";
|
||||
$("add-token-info").style.visibility = "hidden";
|
||||
@@ -60,7 +55,6 @@ function init(ctx) {
|
||||
// The network the lookup asks, which Settings may switch away from
|
||||
// before it answers, as for a transaction in confirmTx.js.
|
||||
const chainId = currentNetwork().chainId;
|
||||
const opening = addTokenScreenOpenings;
|
||||
try {
|
||||
const info = await lookupTokenInfo(
|
||||
contractAddr,
|
||||
@@ -86,17 +80,6 @@ function init(ctx) {
|
||||
});
|
||||
await saveState();
|
||||
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
|
||||
// so the newly added token is visible immediately.
|
||||
if (state.viewStack.length > 0) {
|
||||
@@ -117,7 +100,6 @@ function init(ctx) {
|
||||
? detail
|
||||
: "Could not add the token.",
|
||||
);
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -61,31 +61,20 @@ function clearPopupCaches() {
|
||||
// (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.
|
||||
// added on. When the stored record is on another network, a switch saved
|
||||
// elsewhere has not reached this page yet. The stored record's tokens are then
|
||||
// that network's, and none are taken.
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
takeStoredNetwork,
|
||||
takeStoredTrackedTokens,
|
||||
} = require("./state");
|
||||
const { state, saveState, takeStoredTrackedTokens } = require("./state");
|
||||
let net = null;
|
||||
await saveState((stored) => {
|
||||
if (state.networkId === newNetworkId) return false;
|
||||
if (stored.networkId !== state.networkId) takeStoredNetwork(stored);
|
||||
takeStoredTrackedTokens(stored);
|
||||
if (stored.networkId === state.networkId) {
|
||||
takeStoredTrackedTokens(stored);
|
||||
}
|
||||
net = applyChainSwitchFields(state, newNetworkId);
|
||||
stored.lastBalanceRefresh = 0;
|
||||
stored.fraudContracts = [];
|
||||
|
||||
+11
-57
@@ -125,12 +125,11 @@ function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// `baseline` holds the persisted fields as this page held them when its last
|
||||
// loadState() finished, or when its last successful saveState() began.
|
||||
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
|
||||
// this page has taken from the stored record since then. saveState() diffs the
|
||||
// live state against `baseline` to find only the fields THIS page changed
|
||||
// since.
|
||||
// The persisted fields as this page held them when its last loadState()
|
||||
// finished, or when its last successful saveState() began, with what this page
|
||||
// has taken from the stored record since (takeStoredNetwork() and
|
||||
// takeStoredTrackedTokens()). saveState() diffs the live state against this to
|
||||
// find only the fields THIS page changed since.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||
@@ -443,10 +442,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// milder than the other two.
|
||||
//
|
||||
// Every other persisted field stays a whole-field diff:
|
||||
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||
// 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`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||
// 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
|
||||
@@ -455,11 +452,6 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// own UI scratch space, not data another page has any reason to share
|
||||
// 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
|
||||
// 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
|
||||
@@ -532,26 +524,6 @@ async function saveStateOnce(beforeWrite) {
|
||||
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);
|
||||
// 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
|
||||
@@ -632,33 +604,15 @@ function saveState(beforeWrite) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
// Give this page the network switch held by `stored`, in `state` and in
|
||||
// `baseline` alike, as loadState() does with a whole record. `stored` is the
|
||||
// record that a save of this page has just read. followStoredSettings() in
|
||||
// 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
|
||||
// Give this page the network switch held by `stored`, a record a save of this
|
||||
// page has just read (followStoredSettings() in src/popup/index.js calls this
|
||||
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
|
||||
// with a whole record. 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
|
||||
// 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) {
|
||||
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(baseline, structuredClone(stored));
|
||||
rawState.networkTrackedTokens = networkTrackedTokens;
|
||||
}
|
||||
|
||||
// Give this page the tracked tokens of `stored`, a record on this page's
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// 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,157 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -221,187 +221,6 @@ test("a token another page removed before a switch in Settings stays removed", a
|
||||
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.
|
||||
@@ -473,120 +292,6 @@ 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", () => {
|
||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
|
||||
Reference in New Issue
Block a user