fix: tracked tokens are kept per network, and bundled picks are offered on mainnet only (closes #523)
A network switch puts the outgoing network's tracked tokens away in a new persisted field, networkTrackedTokens, and brings back the incoming network's list, or an empty one, as endpoints are kept; the background's switch uses the same function. The two add-token screens offer their common-token picks on Ethereum Mainnet only. After a switch in Settings, Back renders the screen it lands on again, and the stack is cut at the first screen that cannot be rendered from saved state, or at AddressToken for any token but the native one; the cut stack is saved with the switch. ConfirmTx drawn again keeps the network it was reviewed on. A contract lookup that answers after a switch adds nothing. Model: opus-5-5
This commit was merged in pull request #572.
This commit is contained in:
@@ -1498,6 +1498,19 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
|
|||||||
"Back" always re-renders, so the wallet list reflects anything that changed
|
"Back" always re-renders, so the wallet list reflects anything that changed
|
||||||
while the user was away from it.
|
while the user was away from it.
|
||||||
|
|
||||||
|
A network switch in Settings leaves every screen beneath Settings showing the
|
||||||
|
previous network, so after it "Back" behaves as in a reopened popup: every
|
||||||
|
screen on the stack counts as not yet rendered, and the stack is cut at the
|
||||||
|
first screen that cannot be rendered from saved state, such as Send or AddToken,
|
||||||
|
or at AddressToken for any token but the native one, because that token was
|
||||||
|
picked on the previous network. The cut stack is saved with the switch, so a
|
||||||
|
popup closed straight after it and reopened onto Settings lands in the same
|
||||||
|
place. "Back" then renders the screen it lands on for the new network, except
|
||||||
|
ConfirmTx, which keeps the network its transaction was reviewed on, and anything
|
||||||
|
typed there and not saved, which belonged to the previous network, is dropped.
|
||||||
|
Without a wallet the stack is not cut, because it starts at Welcome, which shows
|
||||||
|
nothing from a network.
|
||||||
|
|
||||||
Every screen that holds secret material in the page registers a cleanup with
|
Every screen that holds secret material in the page registers a cleanup with
|
||||||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||||
exit from that screen rather than only on its "Back" button, so nothing secret
|
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||||
@@ -1795,19 +1808,22 @@ view would leave a wallet one click from deletion.
|
|||||||
it, and a disagreement is a refusal rather than a preference for either
|
it, and a disagreement is a refusal rather than a preference for either
|
||||||
value (`src/shared/transferAmount.js`)
|
value (`src/shared/transferAmount.js`)
|
||||||
- "Sign & Send" after the active network was switched in Settings since this
|
- "Sign & Send" after the active network was switched in Settings since this
|
||||||
transaction was reviewed, with this screen open or before the popup
|
transaction was reviewed → nothing is signed; "The active network changed
|
||||||
reopened onto it → nothing is signed; "The active network changed after
|
after this transaction was reviewed, so it was not sent. Please go back
|
||||||
this transaction was reviewed, so it was not sent. Please go back and
|
and review it again." in the errors box, no screen change. After a switch,
|
||||||
review it again." in the errors box, no screen change. The transaction
|
"Back" from Settings draws this screen again from the saved transaction,
|
||||||
carries the chain id of the network active when Send showed this screen,
|
as a popup reopened onto it does; when Send is beneath it on the stack, as
|
||||||
and keeps it when the screen is drawn again from the saved transaction, so
|
when Send showed it in the same popup, "Back" lands below Send instead
|
||||||
it cannot be signed for another. The screen describes it on that network,
|
(see the top of Screen Map). The transaction carries the chain id of the
|
||||||
whichever network is active: its amounts are in that network's native
|
network active when Send showed this screen, and keeps it when the screen
|
||||||
currency, its USD values are that network's, so a testnet transaction
|
is drawn again from the saved transaction, so it cannot be signed for
|
||||||
shows none, its From, To and token contract links open that network's
|
another. The screen describes it on that network, whichever network is
|
||||||
explorer, the balance read, the fee estimate and the contract and
|
active: its amounts are in that network's native currency, its USD values
|
||||||
never-sent recipient checks ask that network's RPC endpoint, and the
|
are that network's, so a testnet transaction shows none, its From, To and
|
||||||
Etherscan label check asks that network's explorer
|
token contract links open that network's explorer, the balance read, the
|
||||||
|
fee estimate and the contract and never-sent recipient checks ask that
|
||||||
|
network's RPC endpoint, and the Etherscan label check asks that network's
|
||||||
|
explorer
|
||||||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||||
line, no screen change
|
line, no screen change
|
||||||
- "Sign & Send" signs as the From address above, not as whichever address is
|
- "Sign & Send" signs as the From address above, not as whichever address is
|
||||||
@@ -1945,13 +1961,18 @@ view would leave a wallet one click from deletion.
|
|||||||
- Contract address input
|
- Contract address input
|
||||||
- Status line ("Looking up token...", cleared or replaced on failure)
|
- Status line ("Looking up token...", cleared or replaced on failure)
|
||||||
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
||||||
contract address input
|
contract address input. Shown on Ethereum Mainnet only, because the
|
||||||
|
bundled token list holds mainnet contracts
|
||||||
- "Add" button
|
- "Add" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
||||||
**AddressDetail**
|
**AddressDetail**
|
||||||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||||
contract lookup → flash message, no screen change
|
contract lookup → flash message, no screen change
|
||||||
|
- "Add", then 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
|
||||||
|
change
|
||||||
- "Back" → previous screen (AddressDetail)
|
- "Back" → previous screen (AddressDetail)
|
||||||
|
|
||||||
#### Settings (`settings`)
|
#### Settings (`settings`)
|
||||||
@@ -1962,16 +1983,19 @@ view would leave a wallet one click from deletion.
|
|||||||
- Wallets: one row per wallet with its name (tap to rename inline), a
|
- Wallets: one row per wallet with its name (tap to rename inline), a
|
||||||
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
|
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
|
||||||
plus a "+ Add wallet" button
|
plus a "+ Add wallet" button
|
||||||
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
- Tracked Tokens: one row per token tracked on the active network with an
|
||||||
plus a "+ Add token" button
|
`[x]` remove button, plus a "+ Add token" button
|
||||||
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
||||||
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
||||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
|
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
|
||||||
network changes only here, or when the user approves a site's request on
|
network changes only here, or when the user approves a site's request on
|
||||||
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
||||||
endpoints last used on that network, or that network's defaults if it has
|
endpoints last used on that network, or that network's defaults if it has
|
||||||
none, and sends `chainChanged` with the new chain id to every open tab.
|
none, and the tokens tracked on that network, and sends `chainChanged`
|
||||||
Choosing the network already active changes nothing and sends nothing
|
with the new chain id to every open tab. A token is tracked only on the
|
||||||
|
network it was added on, because a contract address names a token on one
|
||||||
|
network only. Choosing the network already active changes nothing and
|
||||||
|
sends nothing
|
||||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||||
`eth_chainId` before being saved)
|
`eth_chainId` before being saved)
|
||||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
@@ -2016,7 +2040,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
||||||
nothing and tells the background nothing
|
nothing and tells the background nothing
|
||||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
- "Back" (or Settings gear again) → previous screen (Home)
|
- "Back" (or Settings gear again) → previous screen (Home); after a network
|
||||||
|
switch, the stack is cut first (see the top of Screen Map)
|
||||||
|
|
||||||
#### ShowRecoveryPhrase (`show-phrase`)
|
#### ShowRecoveryPhrase (`show-phrase`)
|
||||||
|
|
||||||
@@ -2199,7 +2224,8 @@ view would leave a wallet one click from deletion.
|
|||||||
#### SettingsAddToken (`settings-addtoken`)
|
#### SettingsAddToken (`settings-addtoken`)
|
||||||
|
|
||||||
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
||||||
across every address, unlike AddToken which is reached from one address.
|
across every address on the active network, unlike AddToken which is reached
|
||||||
|
from one address.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Back" button, "Add Token" heading
|
- "Back" button, "Add Token" heading
|
||||||
- Instruction text
|
- Instruction text
|
||||||
@@ -2208,11 +2234,19 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
||||||
"Add selected" button
|
"Add selected" button
|
||||||
- "Or enter contract address:" input, a status line, and an "Add" button
|
- "Or enter contract address:" input, a status line, and an "Add" button
|
||||||
|
- The two lists from the bundled token list, "Top tokens:" and "Or pick from
|
||||||
|
top 100:", are shown on Ethereum Mainnet only, because that list holds
|
||||||
|
mainnet contracts. On any other network the contract address is the only
|
||||||
|
add path
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Any of the three add paths, on success → adds the token and shows an
|
- Any of the add paths shown, on success → adds the token and shows an
|
||||||
"Added SYMBOL" flash message (no screen change)
|
"Added SYMBOL" flash message (no screen change)
|
||||||
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
||||||
message, no screen change
|
message, no screen change
|
||||||
|
- "Add" by contract address, then a network switch in Settings before the
|
||||||
|
lookup answers → nothing is tracked; "The active network changed before
|
||||||
|
the lookup finished, so the token was not added." flash message, no screen
|
||||||
|
change
|
||||||
- "Back" → previous screen (Settings)
|
- "Back" → previous screen (Settings)
|
||||||
|
|
||||||
#### SiteApproval (`approve-site`)
|
#### SiteApproval (`approve-site`)
|
||||||
@@ -2429,8 +2463,9 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Switch" → closes popup; the background switches the network as
|
- "Switch" → closes popup; the background switches the network as
|
||||||
**Settings** does, restoring the RPC and Blockscout endpoints last used on
|
**Settings** does, restoring the RPC and Blockscout endpoints last used on
|
||||||
that network (or that network's defaults if it has none), sends
|
that network (or that network's defaults if it has none) and the tokens
|
||||||
`chainChanged` to every open tab, and answers the site with success
|
tracked on that network, sends `chainChanged` to every open tab, and
|
||||||
|
answers the site with success
|
||||||
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
|
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
|
||||||
nothing changes
|
nothing changes
|
||||||
- Popup window closed without answering → the same as "Reject"
|
- Popup window closed without answering → the same as "Reject"
|
||||||
|
|||||||
+31
-28
@@ -852,7 +852,7 @@
|
|||||||
id="add-token-info"
|
id="add-token-info"
|
||||||
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div id="add-token-common" class="mb-2">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>Or pick a common token:</label
|
>Or pick a common token:</label
|
||||||
>
|
>
|
||||||
@@ -1336,36 +1336,39 @@
|
|||||||
<h2 class="font-bold mb-2">Add Token</h2>
|
<h2 class="font-bold mb-2">Add Token</h2>
|
||||||
<p class="text-xs text-muted mb-3">
|
<p class="text-xs text-muted mb-3">
|
||||||
Pick a common token or enter a contract address manually.
|
Pick a common token or enter a contract address manually.
|
||||||
|
Common tokens are offered on Ethereum Mainnet only.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- top 10 quick-pick buttons -->
|
<div id="settings-addtoken-common">
|
||||||
<div class="mb-3">
|
<!-- top 10 quick-pick buttons -->
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<div class="mb-3">
|
||||||
>Top tokens:</label
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>
|
>Top tokens:</label
|
||||||
<div
|
>
|
||||||
id="settings-addtoken-top10"
|
<div
|
||||||
class="flex flex-wrap gap-1"
|
id="settings-addtoken-top10"
|
||||||
></div>
|
class="flex flex-wrap gap-1"
|
||||||
</div>
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- top 100 dropdown -->
|
<!-- top 100 dropdown -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>Or pick from top 100:</label
|
>Or pick from top 100:</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="settings-addtoken-select"
|
id="settings-addtoken-select"
|
||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
>
|
>
|
||||||
<option value="">-- select --</option>
|
<option value="">-- select --</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
id="btn-settings-addtoken-select"
|
id="btn-settings-addtoken-select"
|
||||||
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
>
|
>
|
||||||
Add selected
|
Add selected
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- manual contract address -->
|
<!-- manual contract address -->
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ function markViewRendered(view) {
|
|||||||
if (view) renderedViews.add(view);
|
if (view) renderedViews.add(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin a fresh page-load scope. The popup gets one by being loaded; the
|
// Begin a fresh page-load scope. The popup gets one by being loaded, and
|
||||||
|
// asks for one when the user switches the network, because every screen
|
||||||
|
// then on the page shows the previous network (views/settings.js); the
|
||||||
// unit tests, which simulate several page loads against one module
|
// unit tests, which simulate several page loads against one module
|
||||||
// instance, ask for one.
|
// instance, ask for one too.
|
||||||
function resetRenderedViews() {
|
function resetRenderedViews() {
|
||||||
renderedViews.clear();
|
renderedViews.clear();
|
||||||
}
|
}
|
||||||
@@ -217,9 +219,10 @@ function renderView(view, state, views) {
|
|||||||
// in the two cases where the view is known to be on the page already:
|
// in the two cases where the view is known to be on the page already:
|
||||||
//
|
//
|
||||||
// - It is not one the popup renders from persisted state. The restored
|
// - It is not one the popup renders from persisted state. The restored
|
||||||
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
|
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
|
||||||
// be on the stack from this page load, where forward navigation
|
// it the same way (views/settings.js), so such a view can only be on the
|
||||||
// rendered it on the way in.
|
// stack from this page load, where forward navigation rendered it on the
|
||||||
|
// way in.
|
||||||
// - This page load has rendered it. Re-rendering would re-fetch and
|
// - This page load has rendered it. Re-rendering would re-fetch and
|
||||||
// clobber what it holds; Home is rendered anyway, see above.
|
// clobber what it holds; Home is rendered anyway, see above.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -9,6 +9,12 @@ function show() {
|
|||||||
$("add-token-address").value = "";
|
$("add-token-address").value = "";
|
||||||
$("add-token-info").textContent = "";
|
$("add-token-info").textContent = "";
|
||||||
$("add-token-info").style.visibility = "hidden";
|
$("add-token-info").style.visibility = "hidden";
|
||||||
|
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||||
|
// on any other network.
|
||||||
|
$("add-token-common").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
state.networkId !== "mainnet",
|
||||||
|
);
|
||||||
const list = $("common-token-list");
|
const list = $("common-token-list");
|
||||||
list.innerHTML = getTopTokens(25)
|
list.innerHTML = getTopTokens(25)
|
||||||
.map(
|
.map(
|
||||||
@@ -46,6 +52,9 @@ function init(ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", contractAddr);
|
log.debugf("Looking up token contract", contractAddr);
|
||||||
|
// 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;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
contractAddr,
|
contractAddr,
|
||||||
@@ -53,6 +62,15 @@ function init(ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
|
// A contract address names a token on one network only, so an
|
||||||
|
// answer after a switch is not added to the new network's list.
|
||||||
|
if (currentNetwork().chainId !== chainId) {
|
||||||
|
showFlash(
|
||||||
|
"The active network changed before the lookup finished," +
|
||||||
|
" so the token was not added.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.infof("Adding token", info.symbol, contractAddr);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
address: contractAddr,
|
address: contractAddr,
|
||||||
|
|||||||
@@ -647,9 +647,9 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The screen describes the network the transaction was reviewed for,
|
// The screen describes the network the transaction was reviewed for,
|
||||||
// which Settings may have switched away from since: with this screen
|
// which Settings may have switched away from since: "Back" from
|
||||||
// open, where Back only unhides it, or before the popup reopened onto
|
// Settings after a switch draws this screen again from the saved
|
||||||
// it.
|
// transaction, as a popup reopened onto it does.
|
||||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||||||
showSendError(
|
showSendError(
|
||||||
"The active network changed after this transaction was" +
|
"The active network changed after this transaction was" +
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
|
const { restorableStack } = require("../../shared/persistedState");
|
||||||
|
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
|
||||||
const {
|
const {
|
||||||
log,
|
log,
|
||||||
debugFetch,
|
debugFetch,
|
||||||
@@ -319,12 +321,33 @@ function init(ctx) {
|
|||||||
networkSelect.addEventListener("change", async () => {
|
networkSelect.addEventListener("change", async () => {
|
||||||
const newId = networkSelect.value;
|
const newId = networkSelect.value;
|
||||||
if (newId === state.networkId) return;
|
if (newId === state.networkId) return;
|
||||||
|
// The screens beneath Settings show the previous network, so Back
|
||||||
|
// finds them as it does in a reopened popup: each is rendered again
|
||||||
|
// when Back lands on it, and the stack is cut at the first one that
|
||||||
|
// cannot be rendered from saved state, such as Send or AddToken. It
|
||||||
|
// is cut at AddressToken too, unless that shows the native token: any
|
||||||
|
// other token there was picked on the previous network. Without a
|
||||||
|
// wallet the stack starts at Welcome, which that cut would drop, and
|
||||||
|
// Welcome shows nothing from a network. The cut comes before the
|
||||||
|
// switch so that the switch's save keeps it for a popup closed
|
||||||
|
// straight after.
|
||||||
|
if (state.hasWallet) {
|
||||||
|
const tokenView = state.viewStack.indexOf("address-token");
|
||||||
|
if (tokenView !== -1 && state.selectedToken !== "ETH") {
|
||||||
|
state.viewStack = state.viewStack.slice(0, tokenView);
|
||||||
|
}
|
||||||
|
state.viewStack = restorableStack(state.viewStack, "settings");
|
||||||
|
}
|
||||||
const net = await onChainSwitch(newId);
|
const net = await onChainSwitch(newId);
|
||||||
// Open pages are told by the background, as after a site's approved
|
// Open pages are told by the background, as after a site's approved
|
||||||
// switch request.
|
// switch request.
|
||||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||||
|
resetRenderedViews();
|
||||||
|
// Settings itself is brought up to date for the new network here.
|
||||||
|
markViewRendered("settings");
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
|
renderTrackedTokens();
|
||||||
showFlash("Switched to " + net.name + ".");
|
showFlash("Switched to " + net.name + ".");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -75,6 +75,12 @@ function show() {
|
|||||||
$("settings-addtoken-address").value = "";
|
$("settings-addtoken-address").value = "";
|
||||||
$("settings-addtoken-info").textContent = "";
|
$("settings-addtoken-info").textContent = "";
|
||||||
$("settings-addtoken-info").style.visibility = "hidden";
|
$("settings-addtoken-info").style.visibility = "hidden";
|
||||||
|
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||||
|
// on any other network.
|
||||||
|
$("settings-addtoken-common").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
state.networkId !== "mainnet",
|
||||||
|
);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
renderDropdown();
|
renderDropdown();
|
||||||
showView("settings-addtoken");
|
showView("settings-addtoken");
|
||||||
@@ -130,6 +136,9 @@ function init(_ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", addr);
|
log.debugf("Looking up token contract", addr);
|
||||||
|
// 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;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
addr,
|
addr,
|
||||||
@@ -137,6 +146,15 @@ function init(_ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
|
// A contract address names a token on one network only, so an
|
||||||
|
// answer after a switch is not added to the new network's list.
|
||||||
|
if (currentNetwork().chainId !== chainId) {
|
||||||
|
showFlash(
|
||||||
|
"The active network changed before the lookup finished," +
|
||||||
|
" so the token was not added.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.infof("Adding token", info.symbol, addr);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
address: addr,
|
address: addr,
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
blockscoutUrl: s.blockscoutUrl,
|
blockscoutUrl: s.blockscoutUrl,
|
||||||
};
|
};
|
||||||
const remembered = s.networkEndpoints[net.id] || {};
|
const remembered = s.networkEndpoints[net.id] || {};
|
||||||
|
|
||||||
|
// Tracked tokens are kept per network the same way, because a contract
|
||||||
|
// address names a token on one network only. s.trackedTokens stays the
|
||||||
|
// active network's list, so nothing that reads it changes: the outgoing
|
||||||
|
// list is put away under its network, and the incoming network's list, or
|
||||||
|
// an empty one, takes its place.
|
||||||
|
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
||||||
|
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
||||||
|
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
|
||||||
|
|
||||||
s.networkId = net.id;
|
s.networkId = net.id;
|
||||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||||
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
|
|||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
wallets: [],
|
wallets: [],
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
|
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
|
||||||
|
// trackedTokens above is the active network's list; this is what the
|
||||||
|
// others are restored from when the active network changes. See
|
||||||
|
// applyChainSwitchFields().
|
||||||
|
networkTrackedTokens: {},
|
||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: DEFAULT_RPC_URL,
|
rpcUrl: DEFAULT_RPC_URL,
|
||||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||||
@@ -289,6 +294,20 @@ function normalizePersisted(saved) {
|
|||||||
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
||||||
// value and survives.
|
// value and survives.
|
||||||
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
||||||
|
// Each remembered list is floored like trackedTokens, because
|
||||||
|
// applyChainSwitchFields() puts it straight back onto trackedTokens when
|
||||||
|
// its network becomes the active one. A key that is not a network in
|
||||||
|
// networks.js is dropped, which also keeps an own "__proto__" key from
|
||||||
|
// reaching the plain assignment below.
|
||||||
|
out.networkTrackedTokens = {};
|
||||||
|
if (isRecord(saved.networkTrackedTokens)) {
|
||||||
|
for (const netId of Object.keys(saved.networkTrackedTokens)) {
|
||||||
|
if (!isKnownNetworkId(netId)) continue;
|
||||||
|
out.networkTrackedTokens[netId] = structuredClone(
|
||||||
|
tokenRefs(saved.networkTrackedTokens[netId]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||||
// so a value that is not a network in networks.js must never get that far
|
// so a value that is not a network in networks.js must never get that far
|
||||||
|
|||||||
+12
-8
@@ -42,9 +42,11 @@ const { log } = require("./log");
|
|||||||
// and writes THIS object, never the proxy: the guard is for callers.
|
// and writes THIS object, never the proxy: the guard is for callers.
|
||||||
const rawState = {
|
const rawState = {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
// Their own objects, not the ones DEFAULT_STATE holds:
|
||||||
// mutates this map in place, and a spread copies the reference.
|
// applyChainSwitchFields() mutates these maps in place, and a spread copies
|
||||||
|
// the reference.
|
||||||
networkEndpoints: {},
|
networkEndpoints: {},
|
||||||
|
networkTrackedTokens: {},
|
||||||
currentView: null,
|
currentView: null,
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
selectedAddress: null,
|
selectedAddress: null,
|
||||||
@@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
//
|
//
|
||||||
// Every other persisted field stays a whole-field diff:
|
// Every other persisted field stays a whole-field diff:
|
||||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||||
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
||||||
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||||
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
||||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
||||||
// this page's own UI scratch space, not data another page has any reason to
|
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
|
||||||
// share membership of.
|
// mutation for a whole-field diff to collide with; `viewData` is this page's
|
||||||
|
// own UI scratch space, not data another page has any reason to share
|
||||||
|
// membership of.
|
||||||
//
|
//
|
||||||
// This does not make two pages that both change the SAME leaf concurrently
|
// This does not make two pages that both change the SAME leaf concurrently
|
||||||
// safe: last write wins on that one leaf, same as before. What it removes
|
// safe: last write wins on that one leaf, same as before. What it removes
|
||||||
|
|||||||
@@ -204,17 +204,21 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||||
|
|
||||||
// And it took nothing else with it. Without the load these come back
|
// And it took nothing else with it. Without the load these come back
|
||||||
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
|
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
|
||||||
// extension gone, encrypted secrets included.
|
// extension gone, encrypted secrets included.
|
||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
||||||
expect(after.trackedTokens).toEqual([
|
expect(after.networkTrackedTokens).toEqual({
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||||
]);
|
});
|
||||||
expect(after.theme).toBe("dark");
|
expect(after.theme).toBe("dark");
|
||||||
|
|
||||||
|
// The mainnet token is tracked on mainnet only: Sepolia starts with
|
||||||
|
// none of its own.
|
||||||
|
expect(after.trackedTokens).toEqual([]);
|
||||||
|
|
||||||
// The user's mainnet endpoint is remembered rather than replaced by
|
// The user's mainnet endpoint is remembered rather than replaced by
|
||||||
// the public default, so switching back returns it.
|
// the public default, so switching back returns it.
|
||||||
expect(after.networkEndpoints.mainnet).toEqual({
|
expect(after.networkEndpoints.mainnet).toEqual({
|
||||||
@@ -226,6 +230,9 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
||||||
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
||||||
expect(bg.persisted().wallets).toEqual(walletFixture());
|
expect(bg.persisted().wallets).toEqual(walletFixture());
|
||||||
|
expect(bg.persisted().trackedTokens).toEqual([
|
||||||
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compares the requested chain against the stored one, not the default", async () => {
|
test("compares the requested chain against the stored one, not the default", async () => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe.each([
|
|||||||
jest.doMock("../src/shared/state", () => ({
|
jest.doMock("../src/shared/state", () => ({
|
||||||
state: { trackedTokens: [] },
|
state: { trackedTokens: [] },
|
||||||
saveState,
|
saveState,
|
||||||
|
currentNetwork: () => ({ chainId: 1 }),
|
||||||
}));
|
}));
|
||||||
jest.doMock("../src/popup/views/helpers", () => ({
|
jest.doMock("../src/popup/views/helpers", () => ({
|
||||||
$: element,
|
$: element,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const {
|
|||||||
normalizePersisted,
|
normalizePersisted,
|
||||||
} = require("../src/shared/persistedState");
|
} = require("../src/shared/persistedState");
|
||||||
const { stateProblem } = require("../src/shared/stateSchema");
|
const { stateProblem } = require("../src/shared/stateSchema");
|
||||||
|
const { isKnownNetworkId } = require("../src/shared/networks");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -193,6 +194,29 @@ const CONTRACT = [
|
|||||||
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
||||||
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: "networkTrackedTokens",
|
||||||
|
kind: KIND.ENTRIES,
|
||||||
|
hostile: [42, "notarecord", { sepolia: "notalist" }],
|
||||||
|
floorOnly: [
|
||||||
|
[1, 2],
|
||||||
|
{ sepolia: [1, 2] },
|
||||||
|
{ mainnet: [null, {}, [TOKEN_ADDRESS]] },
|
||||||
|
{ notanetwork: [{ address: TOKEN_ADDRESS }] },
|
||||||
|
JSON.parse('{"__proto__":[{"address":"0xabc"}]}'),
|
||||||
|
],
|
||||||
|
// Each list is put straight back onto trackedTokens by
|
||||||
|
// applyChainSwitchFields() when its network becomes the active one, so
|
||||||
|
// it is floored the same way; a key that names no network is dropped.
|
||||||
|
holds: (v) =>
|
||||||
|
isRecord(v) &&
|
||||||
|
Object.getPrototypeOf(v) === Object.prototype &&
|
||||||
|
Object.keys(v).every(
|
||||||
|
(id) =>
|
||||||
|
isKnownNetworkId(id) &&
|
||||||
|
everyEntry(v[id], (t) => isRecord(t) && isText(t.address)),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "allowedSites",
|
field: "allowedSites",
|
||||||
kind: KIND.ENTRIES,
|
kind: KIND.ENTRIES,
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Tracked tokens are kept per network, and the two add-token screens offer the
|
||||||
|
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
|
||||||
|
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on
|
||||||
|
// the screen "Back" lands on after a switch made from the Settings gear and for
|
||||||
|
// a contract lookup that answers after one. Driven through the real popup over
|
||||||
|
// a stored profile; the same switch made by the background is in
|
||||||
|
// tests/coldWorkerChainSwitch.test.js.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
|
|
||||||
|
const SEPOLIA_TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
};
|
||||||
|
const MAINNET_TOKEN = {
|
||||||
|
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
||||||
|
symbol: "DAI",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Dai Stablecoin",
|
||||||
|
};
|
||||||
|
// Tracked on neither network.
|
||||||
|
const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||||
|
const NETWORK_CHANGED_FLASH =
|
||||||
|
"The active network changed before the lookup finished, so the token was" +
|
||||||
|
" not added.";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopened onto SettingsAddToken, on `networkId`.
|
||||||
|
function bootOnAddToken(networkId, extra) {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
networkId,
|
||||||
|
currentView: "settings-addtoken",
|
||||||
|
...extra,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
|
||||||
|
// there and TST tracked on Sepolia.
|
||||||
|
function bootOnAddress() {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
|
||||||
|
// it, over the same tracked tokens as bootOnAddress().
|
||||||
|
function bootOnToken(token) {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address-token",
|
||||||
|
viewStack: ["main", "address"],
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
selectedToken: token,
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressToken for `token` opened from AddressDetail, as tapping the token's
|
||||||
|
// row there does.
|
||||||
|
async function openToken(boot, token) {
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
state.selectedToken = token;
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/addressToken").show();
|
||||||
|
await boot.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presses `addButton` with a contract lookup that answers only after the
|
||||||
|
// Settings gear and a switch to `networkId`.
|
||||||
|
async function addAcrossSwitch(boot, addButton, networkId) {
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const adding = boot.click(addButton);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, networkId);
|
||||||
|
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
||||||
|
await adding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks `networkId` in the Settings network selector, as the user does.
|
||||||
|
async function chooseNetwork(boot, networkId) {
|
||||||
|
const select = boot.node("settings-network");
|
||||||
|
select.value = networkId;
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await boot.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Settings gear, a switch to Sepolia, and Back.
|
||||||
|
async function gearSwitchBack(boot) {
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["mainnet", false],
|
||||||
|
["sepolia", true],
|
||||||
|
])("on %s", (networkId, hidden) => {
|
||||||
|
test(`the common tokens are hidden: ${hidden}`, async () => {
|
||||||
|
const boot = await bootOnAddToken(networkId);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
|
||||||
|
|
||||||
|
require("../src/popup/views/addToken").show();
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.hidden("add-token-common")).toBe(hidden);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
|
||||||
|
const boot = await bootOnAddToken("sepolia", {
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce({
|
||||||
|
symbol: SEPOLIA_TOKEN.symbol,
|
||||||
|
decimals: SEPOLIA_TOKEN.decimals,
|
||||||
|
name: SEPOLIA_TOKEN.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
|
||||||
|
await boot.click("btn-settings-addtoken-manual");
|
||||||
|
|
||||||
|
expect(lookupTokenInfo).toHaveBeenCalledWith(
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
expect.any(String),
|
||||||
|
"sepolia",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "mainnet");
|
||||||
|
expect(stored().networkId).toBe("mainnet");
|
||||||
|
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||||
|
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
|
expect(onMainnet).toContain("DAI");
|
||||||
|
expect(onMainnet).not.toContain("TST");
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
expect(stored().networkId).toBe("sepolia");
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
|
expect(onSepolia).toContain("TST");
|
||||||
|
expect(onSepolia).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Back after a switch from the Settings gear", () => {
|
||||||
|
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(false);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AddressDetail lists the new network's tracked tokens", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
expect(boot.node("address-balances").innerHTML).toContain("DAI");
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = boot.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Neither can be rendered from saved state, so neither is landed on.
|
||||||
|
test.each([
|
||||||
|
["btn-add-token", "add-token"],
|
||||||
|
["btn-send", "send"],
|
||||||
|
])("from over %s, it lands on AddressDetail", async (button, view) => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await boot.click(button);
|
||||||
|
expect(boot.visibleViews()).toEqual([view]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
// DAI was picked on Ethereum Mainnet, where alone it is tracked.
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"opened from AddressDetail",
|
||||||
|
async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await openToken(boot, MAINNET_TOKEN.address);
|
||||||
|
return boot;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
|
||||||
|
])(
|
||||||
|
"from AddressToken for DAI %s, it lands on AddressDetail",
|
||||||
|
async (_how, open) => {
|
||||||
|
const boot = await open();
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
expect(boot.text("address-token-title")).toContain("DAI");
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = boot.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The stack is saved cut with the switch, so a popup closed straight after
|
||||||
|
// it and reopened onto Settings lands where this one would. Loading a
|
||||||
|
// saved stack cuts it at AddToken and Send as well, but not at
|
||||||
|
// AddressToken.
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"AddressToken for DAI",
|
||||||
|
(boot) => openToken(boot, MAINNET_TOKEN.address),
|
||||||
|
],
|
||||||
|
["AddToken", (boot) => boot.click("btn-add-token")],
|
||||||
|
["Send", (boot) => boot.click("btn-send")],
|
||||||
|
])(
|
||||||
|
"from %s, a popup reopened after the switch lands on AddressDetail",
|
||||||
|
async (_view, open) => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await open(boot);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const reopened = await bootPopup(boot.storage.read("autistmask"));
|
||||||
|
expect(reopened.visibleViews()).toEqual(["settings"]);
|
||||||
|
await reopened.click("btn-settings-back");
|
||||||
|
expect(reopened.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = reopened.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("AddressToken for the native token is drawn again for the new network", async () => {
|
||||||
|
const boot = await bootOnToken("ETH");
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
expect(boot.text("address-token-title")).toContain("SepoliaETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
|
||||||
|
// ConfirmTx again from the saved transaction. It is drawn for the network
|
||||||
|
// the transaction was reviewed on, and Sign & Send refuses it on the new
|
||||||
|
// one.
|
||||||
|
test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
networkId: "sepolia",
|
||||||
|
currentView: "confirm-tx",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
viewData: {
|
||||||
|
pendingTx: {
|
||||||
|
token: "ETH",
|
||||||
|
from: ADDRESS,
|
||||||
|
to: ADDRESS,
|
||||||
|
amount: "1",
|
||||||
|
chainId: NETWORKS.sepolia.chainId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "mainnet");
|
||||||
|
globalThis.fetch.mockClear();
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
// Drawn again: its Etherscan label check asks Sepolia's explorer.
|
||||||
|
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
|
||||||
|
]);
|
||||||
|
expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
|
||||||
|
|
||||||
|
boot.node("confirm-tx-password").value = "any password";
|
||||||
|
await boot.click("btn-confirm-send");
|
||||||
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
expect(boot.node("confirm-errors").innerHTML).toContain(
|
||||||
|
"The active network changed after this transaction was reviewed",
|
||||||
|
);
|
||||||
|
// Refused before the password is even tried.
|
||||||
|
expect(boot.text("confirm-tx-password-error")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a wallet, it lands on Welcome", async () => {
|
||||||
|
const boot = await bootPopup(undefined);
|
||||||
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later Back onto Settings keeps what was typed there", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
boot.node("settings-rpc").value = "https://typed.example";
|
||||||
|
|
||||||
|
await boot.click("btn-settings-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
await boot.click("btn-settings-addtoken-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.value("settings-rpc")).toBe("https://typed.example");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a contract lookup that answers after a switch adds nothing", () => {
|
||||||
|
test("from SettingsAddToken", async () => {
|
||||||
|
const boot = await bootOnAddToken("sepolia", {
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
|
||||||
|
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
|
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||||
|
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("from AddToken", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
|
||||||
|
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user