1 Commits
Author SHA1 Message Date
sneak 1727a4af07 fix: tracked tokens are kept per network, and bundled picks are offered on mainnet only (closes #523)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch now 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, the way endpoints are kept per network. trackedTokens
stays the active network's list, so its readers are unchanged, and the
background's switch goes through the same function. The two add-token screens
offer their common-token picks, which are mainnet contracts, on Ethereum
Mainnet only. After a switch in Settings, Back finds the screens beneath as a
reopened popup does: it renders the one it lands on for the new network, and
the stack is cut at the first screen that cannot be rendered from saved state.

Model: opus-5-5
2026-10-19 01:48:44 +00:00
30 changed files with 455 additions and 642 deletions
+40 -28
View File
@@ -1494,6 +1494,15 @@ 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.
"Back" then renders the screen it lands on for the new network, 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
@@ -1924,7 +1933,8 @@ 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
@@ -1941,16 +1951,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
@@ -1995,7 +2008,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`)
@@ -2036,9 +2050,7 @@ view would leave a wallet one click from deletion.
- **Elements**: - **Elements**:
- "Back" button, "Delete Wallet" heading - "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any - Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase, the private key or funds are unrecoverable without the recovery phrase
the extended private key, according to the wallet's kind
(`keyMaterialName()` in `src/popup/views/helpers.js`)
- Password input - Password input
- Error line - Error line
- "Confirm Delete" button - "Confirm Delete" button
@@ -2079,10 +2091,9 @@ view would leave a wallet one click from deletion.
cannot be unlocked again, and that no password is needed to delete it cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored - What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again, with a new password, from a kept - The route back — adding the wallet again with the recovery phrase and a
copy of its recovery phrase, private key or extended private key (named as new password — and, in bold, that without that phrase written down the
on DeleteWallet) — and, in bold, that without that copy the deletion loses deletion loses everything the wallet holds, forever
everything the wallet holds, forever
- That the other wallets are not touched - That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. A name - The wallet's name, and a text input asking for it to be typed back. A name
that shows nothing at all (only spaces, or only characters that paint that shows nothing at all (only spaces, or only characters that paint
@@ -2140,9 +2151,8 @@ view would leave a wallet one click from deletion.
the address **only if it has on-chain activity**. An address that was the address **only if it has on-chain activity**. An address that was
never used is not found by that scan. The text is written by never used is not found by that scan. The text is written by
`recoveryPathText()` rather than sitting in `index.html`, so it can name `recoveryPathText()` rather than sitting in `index.html`, so it can name
the wallet's own kind of key material through `keyMaterialName()`, as the wallet's own kind of key material: an xprv wallet has no recovery
DeleteWallet and DeleteWalletLostPassword do: an xprv wallet has no phrase to re-import.
recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `formatAddressTotal()` (see
@@ -2178,7 +2188,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
@@ -2187,8 +2198,12 @@ 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
@@ -2406,8 +2421,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"
@@ -2884,14 +2900,10 @@ Currently supported:
or recipient, and amount. or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
The swap summary is shown only for a transaction sent to one of the Universal Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap
Router addresses Uniswap publishes for the network: on TxApproval the network action is read only in the parameter shape deployed on mainnet and Sepolia,
the transaction is for, on TransactionDetail the active network. The same not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36`
calldata sent to any other address is shown as raw data. Decodes Permit2, field. An action in any other shape is not read, and its step is listed as
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. `V4 Swap (could not be decoded)`.
### Non-Goals Forever ### Non-Goals Forever
+14 -12
View File
@@ -838,7 +838,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
> >
@@ -924,9 +924,9 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Network</h3> <h3 class="font-bold mb-1">Network</h3>
<p class="text-xs text-muted mb-1"> <p class="text-xs text-muted mb-1">
Select the Ethereum network. Switching restores the RPC Select the Ethereum network. Switching networks will
and Blockscout endpoints last used on that network, or update the RPC and Blockscout endpoints to their
that network's defaults if it has none. defaults.
</p> </p>
<div class="text-xs flex items-center gap-1"> <div class="text-xs flex items-center gap-1">
<select <select
@@ -1123,8 +1123,7 @@
<p class="text-xs mb-3"> <p class="text-xs mb-3">
Deleting Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any <strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your funds will be unrecoverable without your recovery phrase.
<span id="delete-wallet-key-material"></span>.
</p> </p>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
@@ -1175,13 +1174,13 @@
money at its addresses is not moved or destroyed. money at its addresses is not moved or destroyed.
</p> </p>
<p class="text-xs mb-2"> <p class="text-xs mb-2">
If you have kept a copy of this wallet's If you have the recovery phrase for this wallet written
<span id="delete-wallet-lost-key-material"></span>, add the down, add the wallet again afterwards with a new password
wallet again afterwards with a new password and you will and you will have it back.
have it back.
<strong <strong
>If you have not, deleting this wallet means losing >If you do not have it written down, deleting this
everything it holds, forever.</strong wallet means losing everything it holds,
forever.</strong
> >
</p> </p>
<p class="text-xs mb-3">Your other wallets are not touched.</p> <p class="text-xs mb-3">Your other wallets are not touched.</p>
@@ -1322,8 +1321,10 @@
<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>
<div id="settings-addtoken-common">
<!-- top 10 quick-pick buttons --> <!-- top 10 quick-pick buttons -->
<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"
@@ -1353,6 +1354,7 @@
Add selected Add selected
</button> </button>
</div> </div>
</div>
<!-- manual contract address --> <!-- manual contract address -->
<div class="mb-3"> <div class="mb-3">
+8 -5
View File
@@ -37,9 +37,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();
} }
@@ -213,9 +215,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.
// //
+6
View File
@@ -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(
+5 -17
View File
@@ -79,10 +79,9 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// Try to decode calldata sent to `toAddress` on the network with chain id // Try to decode calldata using known ABIs.
// `chainId`, using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId) { function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20 // Where a token's scale is looked for, for every decoder below: the ERC-20
@@ -186,12 +185,7 @@ function decodeCalldata(data, toAddress, chainId) {
} }
// Try Uniswap Universal Router // Try Uniswap Universal Router
const routerResult = uniswap.decode( const routerResult = uniswap.decode(data, toAddress, decimalsSources);
data,
toAddress,
chainId,
decimalsSources,
);
if (routerResult) return routerResult; if (routerResult) return routerResult;
return null; return null;
@@ -275,14 +269,8 @@ function showTxApproval(details) {
chainId: approvedTx.chainId, chainId: approvedTx.chainId,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount. // If this is an ERC-20 call, try to extract the real recipient and amount
// Decoded for the network the transaction is for, which the Network line const decoded = decodeCalldata(approvedTx.data, toAddr || "");
// names.
const decoded = decodeCalldata(
approvedTx.data,
toAddr || "",
approvedTx.chainId,
);
if (decoded && decoded.details) { if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token // The asset the status summary is counted in: an ERC-20 call's Token
// contract, or a swap's input token. Its symbol is resolved from the // contract, or a swap's input token. Its symbol is resolved from the
+4 -2
View File
@@ -17,9 +17,9 @@ const {
attachCopyHandlers, attachCopyHandlers,
addressHoldsFunds, addressHoldsFunds,
balanceLinesForAddress, balanceLinesForAddress,
keyMaterialName,
} = require("./helpers"); } = require("./helpers");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
canRemoveAddress, canRemoveAddress,
@@ -54,7 +54,9 @@ function setFlash(msg) {
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and // The noun follows the wallet: an xprv wallet holds no recovery phrase, and
// this screen is offered on xprv wallets too. // this screen is offered on xprv wallets too.
function recoveryPathText(wallet) { function recoveryPathText(wallet) {
const secret = keyMaterialName(wallet); const secret = walletHasRecoveryPhrase(wallet)
? "recovery phrase"
: "extended private key";
return ( return (
"Getting the address back into this list is not easy, so be sure. " + "Getting the address back into this list is not easy, so be sure. " +
"Adding an address derives the next unused one, not this one, and " + "Adding an address derives the next unused one, not this one, and " +
-7
View File
@@ -7,7 +7,6 @@ const {
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
keyMaterialName,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -76,9 +75,6 @@ function show(walletIdx) {
clear(); clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
$("delete-wallet-name").textContent = displayName(walletIdx); $("delete-wallet-name").textContent = displayName(walletIdx);
$("delete-wallet-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
@@ -98,9 +94,6 @@ function showLostPassword() {
clearLostPassword(); clearLostPassword();
$("delete-wallet-lost-name").textContent = name; $("delete-wallet-lost-name").textContent = name;
$("delete-wallet-lost-name-echo").textContent = name; $("delete-wallet-lost-name-echo").textContent = name;
$("delete-wallet-lost-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
// showView() runs the leave hook of delete-wallet-confirm, which nulls // showView() runs the leave hook of delete-wallet-confirm, which nulls
// deleteWalletIndex, so this screen's own selection is recorded after // deleteWalletIndex, so this screen's own selection is recorded after
// it and not before. // it and not before.
-12
View File
@@ -25,7 +25,6 @@ const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
@@ -419,16 +418,6 @@ function addressHoldsFunds(addr) {
return false; return false;
} }
// The name of a wallet's key material, which is also its only backup: the
// recovery phrase of an HD wallet, the private key of a key wallet, the
// extended private key of an xprv wallet. DeleteWallet,
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
function keyMaterialName(wallet) {
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
if (wallet.type === "key") return "private key";
return "extended private key";
}
// The fewest characters of an address any caller may ask to display. The // The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same // 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call // guarantee; this is the half that used to be spelled out at each call
@@ -736,7 +725,6 @@ module.exports = {
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds, addressHoldsFunds,
keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenLabel, tokenLabel,
+15
View File
@@ -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,
@@ -323,8 +325,21 @@ function init(ctx) {
// 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 });
// 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.
// Without a wallet the stack starts at Welcome, which that cut would
// drop, and Welcome shows nothing from a network.
if (state.hasWallet) {
state.viewStack = restorableStack(state.viewStack, "settings");
}
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 + ".");
}); });
+6
View File
@@ -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");
+2 -7
View File
@@ -21,7 +21,7 @@ const {
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -316,12 +316,7 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // transfer that is the transfer's recipient, not the token contract.
// On the active network, the one whose explorer was just asked. const decoded = decodeCalldata(inputData, txData.to?.hash || "");
const decoded = decodeCalldata(
inputData,
txData.to?.hash || "",
currentNetwork().chainId,
);
if (decoded) { if (decoded) {
// Render decoded calldata matching approval view style // Render decoded calldata matching approval view style
actionEl.textContent = decoded.name; actionEl.textContent = decoded.name;
+10
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -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
+4 -45
View File
@@ -8,7 +8,6 @@ const {
resolveTokenSymbol, resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("./approvalAmount"); } = require("./approvalAmount");
const { NETWORKS } = require("./networks");
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
@@ -16,41 +15,6 @@ const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)", "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]); ]);
// The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there.
// The files also list `UnsupportedProtocol`, which is not a router.
const ROUTER_ADDRESSES = new Map([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
"0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2
"0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1
"0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2
"0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1
"0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2
],
],
]);
// Whether `address` is a Universal Router deployment on the network with this
// chain id. Address case is ignored.
function isRouterAddress(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase();
return routers.some((router) => router.toLowerCase() === wanted);
}
// Universal Router command IDs (lower 5 bits of each command byte) // Universal Router command IDs (lower 5 bits of each command byte)
const COMMAND_NAMES = { const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)", 0x00: "V3 Swap (Exact In)",
@@ -156,7 +120,7 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia. // WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// Either one counts on either network. // decode() is not told the network, so it takes either.
const WETH_ADDRESSES = [ const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14", "0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
@@ -595,15 +559,10 @@ function decodeV4Swap(input) {
} }
} }
// Try to decode a Universal Router execute() call sent to `toAddress` on the // Try to decode a Universal Router execute() call.
// network with chain id `chainId`.
// Returns { name, description, details } matching the format used by // Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute() or // the approval UI, or null if the calldata is not a recognised execute().
// `toAddress` is not a Universal Router on that network: the same calldata function decode(data, toAddress, sources) {
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources) {
if (!isRouterAddress(toAddress, chainId)) return null;
try { try {
const parsed = ROUTER_IFACE.parseTransaction({ data }); const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null; if (!parsed) return null;
+1 -5
View File
@@ -68,11 +68,7 @@ function swapData(amountIn, amountOutMin) {
} }
function swapDetail(amountIn, amountOutMin, label) { function swapDetail(amountIn, amountOutMin, label) {
const decoded = uniswap.decode( const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
swapData(amountIn, amountOutMin),
ROUTER,
"0x1",
);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
+11 -4
View File
@@ -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 () => {
-30
View File
@@ -259,36 +259,6 @@ describe("reaching the screen", () => {
}); });
}); });
// Both screens tell the user what they need to get the wallet back, and a
// key or xprv wallet has no recovery phrase: telling its owner to rely on
// one sends them looking for words that do not exist
// (https://git.eeqj.de/sneak/AutistMask/issues/529).
describe("what the screens say the wallet's backup is", () => {
test.each([
["hd", "recovery phrase"],
["key", "private key"],
["xprv", "extended private key"],
])(
"a wallet of type %s is told about its %s",
async (type, keyMaterial) => {
const { deleteWallet, state } = load();
state.wallets[1].type = type;
deleteWallet.show(1);
expect(state.currentView).toBe("delete-wallet-confirm");
expect(node("delete-wallet-key-material").textContent).toBe(
keyMaterial,
);
await click("btn-delete-wallet-lost-password");
expect(state.currentView).toBe(VIEW);
expect(node("delete-wallet-lost-key-material").textContent).toBe(
keyMaterial,
);
},
);
});
describe("the typed confirmation", () => { describe("the typed confirmation", () => {
test("a name that is not the wallet's deletes nothing", async () => { test("a name that is not the wallet's deletes nothing", async () => {
const { deleteWallet, state, storage } = load(); const { deleteWallet, state, storage } = load();
+1 -1
View File
@@ -136,7 +136,7 @@ function erc20AmountLine(data, tokenAddress) {
} }
function swapAmountLine(data) { function swapAmountLine(data) {
return decodeCalldata(data, ROUTER, "0x1").details.find( return decodeCalldata(data, ROUTER).details.find(
(d) => d.label === "Amount", (d) => d.label === "Amount",
).value; ).value;
} }
+4
View File
@@ -61,12 +61,16 @@ async function importOn(page, { tab, field, secret }) {
await page.click("btn-add-wallet-confirm"); await page.click("btn-add-wallet-confirm");
} }
// Fake timers hold the timer of the flash message an import ends with, which
// would otherwise fire after cleanupPopup() has removed the page and throw.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
chainBalance = "0.0"; chainBalance = "0.0";
}); });
afterEach(() => { afterEach(() => {
cleanupPopup(); cleanupPopup();
jest.useRealTimers();
}); });
describe("a popup that opened on Welcome", () => { describe("a popup that opened on Welcome", () => {
+24
View File
@@ -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,
-42
View File
@@ -1,42 +0,0 @@
// A timer the popup started and that fires after cleanupPopup() has removed
// the page throws "document is not defined", and the throw fails whatever test
// the jest worker is running by then, in this file or another
// (https://git.eeqj.de/sneak/AutistMask/issues/578). The flash message's
// timer is the one that did it. A throw from a timer during a test fails that
// test, so each test here passes only if nothing throws.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const FLASH_MS = 10;
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
afterEach(() => {
cleanupPopup();
});
test("a flash message clears while the page is up", async () => {
const page = await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
expect(page.text("flash-msg")).toBe("Copied.");
await wait(FLASH_MS * 5);
expect(page.text("flash-msg")).toBe("");
});
test("a flash message's timer does not fire after cleanupPopup()", async () => {
await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
cleanupPopup();
await wait(FLASH_MS * 5);
});
-28
View File
@@ -43,12 +43,6 @@ jest.doMock("ethereum-blockies-base64", () => makeBlockie);
// Taken before any boot replaces them; see bootPopup(). // Taken before any boot replaces them; see bootPopup().
const realSetInterval = globalThis.setInterval; const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval; const realClearInterval = globalThis.clearInterval;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
// Every timeout set since the last cleanupPopup() that has neither fired nor
// been cleared. cleanupPopup() clears them all.
const pendingTimeouts = new Set();
const POPUP_HTML = fs.readFileSync( const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"), path.join(__dirname, "..", "..", "src", "popup", "index.html"),
@@ -348,24 +342,6 @@ async function bootPopup(stored, options) {
globalThis.clearInterval = (id) => { globalThis.clearInterval = (id) => {
intervals.delete(id); intervals.delete(id);
}; };
// Timeouts, such as the one that clears a flash message, run for real, but
// each is recorded until it fires or is cleared, so cleanupPopup() can
// clear the ones still pending. One that fired after the page was removed
// would throw "document is not defined" into whatever test the worker runs
// by then. jest.useFakeTimers() in a test does not reach them. Put back by
// cleanupPopup().
globalThis.setTimeout = (fn, ms, ...args) => {
const timer = realSetTimeout(() => {
pendingTimeouts.delete(timer);
fn(...args);
}, ms);
pendingTimeouts.add(timer);
return timer;
};
globalThis.clearTimeout = (timer) => {
pendingTimeouts.delete(timer);
realClearTimeout(timer);
};
require("../../src/popup/index"); require("../../src/popup/index");
@@ -431,10 +407,6 @@ function cleanupPopup() {
delete globalThis.window; delete globalThis.window;
globalThis.setInterval = realSetInterval; globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval; globalThis.clearInterval = realClearInterval;
for (const timer of pendingTimeouts) realClearTimeout(timer);
pendingTimeouts.clear();
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
} }
module.exports = { module.exports = {
+189
View File
@@ -0,0 +1,189 @@
// 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. 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,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
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",
};
// Fake timers hold the timer of the flash message a switch ends with, which
// would otherwise fire after cleanupPopup() has removed the page and throw.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
cleanupPopup();
jest.useRealTimers();
});
// 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] },
}),
);
}
// 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");
});
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");
});
});
+44 -47
View File
@@ -1,7 +1,6 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers"); const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1.
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
@@ -316,13 +315,13 @@ function detail(result, label) {
describe("uniswap decoder", () => { describe("uniswap decoder", () => {
test("returns null for non-execute calldata", () => { test("returns null for non-execute calldata", () => {
expect(uniswap.decode("0x", ROUTER_ADDR, "0x1")).toBeNull(); expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR, "0x1")).toBeNull(); expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR)).toBeNull();
expect(uniswap.decode(null, ROUTER_ADDR, "0x1")).toBeNull(); expect(uniswap.decode(null, ROUTER_ADDR)).toBeNull();
}); });
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 ETH"); expect(result.name).toBe("Swap USDT \u2192 ETH");
@@ -362,7 +361,6 @@ describe("uniswap decoder", () => {
const result = uniswap.decode( const result = uniswap.decode(
v4SwapCalldata(id, deployed), v4SwapCalldata(id, deployed),
ROUTER_ADDR, ROUTER_ADDR,
"0x1",
); );
expect(detail(result, "Steps").value).toBe("V4 Swap"); expect(detail(result, "Steps").value).toBe("V4 Swap");
}, },
@@ -378,7 +376,6 @@ describe("uniswap decoder", () => {
const result = uniswap.decode( const result = uniswap.decode(
v4SwapCalldata(id, newer), v4SwapCalldata(id, newer),
ROUTER_ADDR, ROUTER_ADDR,
"0x1",
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined();
@@ -409,7 +406,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -430,7 +427,7 @@ describe("uniswap decoder", () => {
[encodeV4Swap(new Uint8Array([id]), [newer])], [encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -460,7 +457,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Amount").value).toBe("2.0000 USDT"); expect(detail(result, "Amount").value).toBe("2.0000 USDT");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
@@ -488,7 +485,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
@@ -510,7 +507,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
); );
@@ -530,7 +527,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
@@ -557,7 +554,7 @@ describe("uniswap decoder", () => {
1767225600n, 1767225600n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → WETH"); expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Token In").address).toBe(USDC_ADDR); expect(detail(result, "Token In").address).toBe(USDC_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR); expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
@@ -589,7 +586,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
@@ -618,7 +615,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → ETH"); expect(result.name).toBe("Swap USDC → ETH");
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
@@ -661,7 +658,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").address).toBe(tokenOut); expect(detail(result, "Token Out").address).toBe(tokenOut);
expect(detail(result, "Min. received").value).toBe(minReceived); expect(detail(result, "Min. received").value).toBe(minReceived);
}, },
@@ -682,7 +679,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
@@ -715,7 +712,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
}); });
@@ -734,7 +731,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
}); });
@@ -759,7 +756,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
@@ -782,7 +779,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
}); });
@@ -794,7 +791,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const tokenIn = result.details.find((d) => d.label === "Token In"); const tokenIn = result.details.find((d) => d.label === "Token In");
@@ -817,7 +814,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// UNWRAP_WETH means output is native ETH // UNWRAP_WETH means output is native ETH
expect(result.name).toBe("Swap USDT \u2192 ETH"); expect(result.name).toBe("Swap USDT \u2192 ETH");
@@ -833,7 +830,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received"); const minOut = result.details.find((d) => d.label === "Min. received");
@@ -853,7 +850,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to // The V4 swap action's parameters are empty, so that step is said to
@@ -865,7 +862,7 @@ describe("uniswap decoder", () => {
}); });
test("shows the deadline as a UTC date and time", () => { test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
}); });
@@ -883,7 +880,7 @@ describe("uniswap decoder", () => {
2n ** 256n - 1n, 2n ** 256n - 1n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe( expect(detail(result, "Deadline").value).toBe(
@@ -898,7 +895,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const amount = result.details.find((d) => d.label === "Amount"); const amount = result.details.find((d) => d.label === "Amount");
@@ -973,7 +970,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap" // Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions // After fix: correctly identifies both tokens from V4 sub-actions
@@ -1007,7 +1004,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop. // The amount and the token it is counted in come from the same hop.
@@ -1047,7 +1044,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
@@ -1092,7 +1089,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1121,7 +1118,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1158,7 +1155,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH"); expect(detail(result, "Token In").value).toContain("WETH");
@@ -1351,7 +1348,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
@@ -1403,7 +1400,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -1462,7 +1459,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe( expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(), USDT_ADDR.toLowerCase(),
@@ -1518,7 +1515,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
@@ -1541,7 +1538,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
@@ -1575,7 +1572,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
@@ -1599,7 +1596,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT"); expect(detail(result, "Token In").value).toContain("USDT");
@@ -1630,7 +1627,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH"); expect(detail(result, "Token Out").value).toContain("WETH");
@@ -1651,7 +1648,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1670,7 +1667,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT"); expect(detail(result, "Token Out").value).toContain("USDT");
@@ -1690,7 +1687,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
@@ -1710,7 +1707,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
-298
View File
@@ -1,298 +0,0 @@
// Which transactions are shown as a swap through the Uniswap Universal Router
// (https://git.eeqj.de/sneak/AutistMask/issues/520).
//
// The decoder used to recognise the router's execute() call by its selector
// alone, so the same calldata sent to an ordinary address was shown as a swap
// through the router, with that address as its Protocol. It is now decoded
// only when the transaction is sent to a Universal Router deployment on the
// network it is for; anywhere else it is left to the raw data.
//
// The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it.
const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail");
const MAINNET = "0x1";
const SEPOLIA = "0xaa36a7";
// From Uniswap's deployment files: one router on Ethereum Mainnet only, one on
// Sepolia only, and one at the same address on both.
const MAINNET_ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const TOKEN = "0xE2E0000000000000000000000000000000000E2e";
const HUNDREDTH_ETH = 10000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// The calldata from the issue: wrap 0.01 ETH, then a V3 swap of it into a
// token.
const SWAP_DATA = routerIface.encodeFunctionData("execute", [
solidityPacked(["uint8", "uint8"], [0x0b, 0x00]),
[
coder.encode(["address", "uint256"], [RECIPIENT, HUNDREDTH_ETH]),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
RECIPIENT,
HUNDREDTH_ETH,
1n,
solidityPacked(
["address", "uint24", "address"],
[SEPOLIA_WETH, 3000, TOKEN],
),
false,
],
),
],
9999999999n,
]);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("execute() calldata on the approval screen's decoder", () => {
test("sent to a router on the transaction's network, it is a swap", () => {
const onMainnet = decodeCalldata(SWAP_DATA, MAINNET_ROUTER, MAINNET);
expect(onMainnet.description).toBe("Swap via Uniswap Universal Router");
expect(onMainnet.details[0]).toMatchObject({
label: "Protocol",
address: MAINNET_ROUTER,
});
expect(
decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, SEPOLIA).description,
).toBe("Swap via Uniswap Universal Router");
});
test("sent to an ordinary address, it is not decoded", () => {
expect(decodeCalldata(SWAP_DATA, ORDINARY, MAINNET)).toBeNull();
expect(decodeCalldata(SWAP_DATA, ORDINARY, SEPOLIA)).toBeNull();
});
test("sent to a router of the other network only, it is not decoded", () => {
expect(decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, MAINNET)).toBeNull();
expect(decodeCalldata(SWAP_DATA, MAINNET_ROUTER, SEPOLIA)).toBeNull();
});
test("a router deployed at the same address on both networks is one on each", () => {
expect(
decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, MAINNET),
).not.toBeNull();
expect(
decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, SEPOLIA),
).not.toBeNull();
});
test("the router address matches in any letter case", () => {
const upper = "0x" + MAINNET_ROUTER.slice(2).toUpperCase();
for (const address of [getAddress(MAINNET_ROUTER), upper]) {
expect(decodeCalldata(SWAP_DATA, address, MAINNET)).not.toBeNull();
}
});
});
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
to: { hash: mockSentTo },
raw_input: SWAP_DATA,
method: "execute",
};
}
return {};
}
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the transaction detail screen with Sepolia active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo) {
mockSentTo = sentTo;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "sepolia";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
});
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (_msg, reply) =>
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: RECIPIENT,
approvedTx: {
type: 2,
from: RECIPIENT,
chainId: SEPOLIA,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: sentTo,
value: "0x" + HUNDREDTH_ETH.toString(16),
data: SWAP_DATA,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
}
// The router is on Sepolia's list only, so the swap is shown only if the
// screen decodes for the transaction's network: on Ethereum Mainnet, the
// active network, that address is not a router.
describe("execute() calldata on the approval screen, for a Sepolia transaction while Ethereum Mainnet is active", () => {
test("sent to a router on Sepolia, it is a swap", async () => {
await showApprovalSentTo(SEPOLIA_ROUTER);
expect(node("approve-tx-decoded").classList.contains("hidden")).toBe(
false,
);
expect(node("approve-tx-decoded-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
test("sent to an ordinary address, it is shown as raw data", async () => {
await showApprovalSentTo(ORDINARY);
expect(node("approve-tx-decoded").classList.contains("hidden")).toBe(
true,
);
expect(node("approve-tx-decoded-details").innerHTML).toBe("");
expect(node("approve-tx-data").textContent).toBe(SWAP_DATA);
expect(
node("approve-tx-data-section").classList.contains("hidden"),
).toBe(false);
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ function unwrapData() {
} }
function detail(data, label, sources) { function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {}); const decoded = uniswap.decode(data, ROUTER, sources || {});
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
+4 -10
View File
@@ -79,7 +79,7 @@ function balanceCheck(token, minBalance) {
} }
function detail(data, label) { function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {}); const decoded = uniswap.decode(data, ROUTER, {});
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -113,9 +113,7 @@ describe("an execute() whose input currency never decoded", () => {
}); });
test("does not name ETH in the swap title either", () => { test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
"Uniswap Swap",
);
}); });
}); });
@@ -125,9 +123,7 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => { test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL); expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
"Uniswap Swap",
);
}); });
test("still states the output it did establish", () => { test("still states the output it did establish", () => {
@@ -174,9 +170,7 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)"); expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH"); expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe( expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT");
"Swap ETH → USDT",
);
}); });
test("WRAP_ETH is still ETH at 18 decimals", () => { test("WRAP_ETH is still ETH at 18 decimals", () => {
+3 -5
View File
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
} }
function detail(data, label) { function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {}); const decoded = uniswap.decode(data, ROUTER, {});
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -140,7 +140,7 @@ describe("a V4 step that states a Min. received but no output currency", () => {
}); });
test("does not name ETH in the swap title either", () => { test("does not name ETH in the swap title either", () => {
const decoded = uniswap.decode(data(), ROUTER, "0x1", {}); const decoded = uniswap.decode(data(), ROUTER, {});
expect(decoded.name).toBe("Uniswap Swap"); expect(decoded.name).toBe("Uniswap Swap");
}); });
}); });
@@ -206,8 +206,6 @@ describe("native ETH out, which V4 spells as the zero address", () => {
test("is still named ETH and still scaled at 18 decimals", () => { test("is still named ETH and still scaled at 18 decimals", () => {
expect(detail(data(), "Token Out").value).toBe("ETH"); expect(detail(data(), "Token Out").value).toBe("ETH");
expect(detail(data(), "Min. received").value).toBe("0.5000 ETH"); expect(detail(data(), "Min. received").value).toBe("0.5000 ETH");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH");
"Swap USDT → ETH",
);
}); });
}); });
+1 -1
View File
@@ -82,7 +82,7 @@ function walletsHolding(token, decimals) {
// decodeCalldata() rather than uniswap.decode() directly, because the scale // decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test. // sources the screen supplies are part of what is under test.
function swapDetail(data, label) { function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER, "0x1"); const decoded = decodeCalldata(data, ROUTER);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }