1 Commits
Author SHA1 Message Date
sneak fbef39d739 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 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
2026-10-19 09:22:39 +00:00
24 changed files with 722 additions and 1526 deletions
+62 -62
View File
@@ -1144,9 +1144,8 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless decoded swap (see TxApproval below), do not always read as a number. They can
only the user is paid, see Transaction Decoding), do not always read as a read:
number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above. truncated to four decimals, with the floor and the zero cases described above.
@@ -1498,6 +1497,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 +1807,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 +1960,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 +1982,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 +2039,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 +2223,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 +2233,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`)
@@ -2289,9 +2322,7 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a amounts, steps, deadline (see Transaction Decoding)
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -2429,8 +2460,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"
@@ -2926,39 +2958,7 @@ Currently supported:
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other
steps state, the summary names no swap direction, `Token In` and `Token Out` steps state, the summary names no swap direction, `Token In` and `Token Out`
read `Unknown (could not be decoded)`, and there is no `Amount` or read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and `Min. received` line.
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's
sender. The router reads `address(1)` as the transaction's sender, and
`address(2)` as the router itself, which holds what it is paid until a later
step pays it out; a payment to the router, as `address(2)` or by its own
address, is not counted as paid out. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step or `TAKE` paying a token to the V2 pair that the very next step
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
pay the next V2 pair directly, and the router enforces that swap's own
minimum. The decoder checks that the address paid is that pair, deriving it
from the V2 factory and pair init code hash the network's routers were
deployed with and the first two tokens of that step's path; a payment to any
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on
that router.) Every other payment counts, in whichever step and of whatever
token. The minimum reads `Min. received` only when some step pays the
transaction's sender (the From address on TxApproval, the sender the explorer
reports on TransactionDetail), no step pays any other address, no step that
may pay an address went unread, and the output does not stay in the router.
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names
each other address paid, one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router, and one more reads `Unknown (could not be decoded)` when such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command or V4 action the decoder does not read: every command
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
### Non-Goals Forever ### Non-Goals Forever
+4 -1
View File
@@ -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,8 +1336,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"
@@ -1367,6 +1369,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
@@ -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.
// //
+19 -1
View File
@@ -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,
+4 -6
View File
@@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network // Try to decode calldata sent to `toAddress` on the network with chain id
// with chain id `chainId`, using known ABIs. // `chainId`, using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId, fromAddress) { function decodeCalldata(data, toAddress, chainId) {
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
@@ -191,7 +191,6 @@ function decodeCalldata(data, toAddress, chainId, fromAddress) {
toAddress, toAddress,
chainId, chainId,
decimalsSources, decimalsSources,
fromAddress,
); );
if (routerResult) return routerResult; if (routerResult) return routerResult;
@@ -278,12 +277,11 @@ function showTxApproval(details) {
// 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 // Decoded for the network the transaction is for, which the Network line
// names, and the address the From line names. // names.
const decoded = decodeCalldata( const decoded = decodeCalldata(
approvedTx.data, approvedTx.data,
toAddr || "", toAddr || "",
approvedTx.chainId, approvedTx.chainId,
details.approvedFrom,
); );
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
+3 -3
View File
@@ -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" +
+23
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,
@@ -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 + ".");
}); });
+19 -1
View File
@@ -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,
+1 -3
View File
@@ -316,13 +316,11 @@ 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, and // On the active network, the one whose explorer was just asked.
// from the sender it reports.
const decoded = decodeCalldata( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
currentNetwork().chainId, currentNetwork().chainId,
txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
// Render decoded calldata matching approval view style // Render decoded calldata matching approval view style
+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
+32 -290
View File
@@ -1,15 +1,7 @@
// Decode Uniswap Universal Router execute() calldata into human-readable // Decode Uniswap Universal Router execute() calldata into human-readable
// swap details. Designed to be extended with other DEX decoders later. // swap details. Designed to be extended with other DEX decoders later.
const { const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
Interface,
AbiCoder,
getBytes,
formatUnits,
getCreate2Address,
keccak256,
solidityPacked,
} = require("ethers");
const { truncateAmountNeverZero } = require("./amountDisplay"); const { truncateAmountNeverZero } = require("./amountDisplay");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
@@ -73,55 +65,6 @@ function routerCommandTypeMask(address, chainId) {
return router ? router[1] : null; return router ? router[1] : null;
} }
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
// CREATE2 address from the factory, with the hash of the path's first two
// tokens, sorted, as salt.
// - pairFor(): https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/uniswap/v2/UniswapV2Library.sol
// - mainnet: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeployMainnet.s.sol
// - Sepolia: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeploySepolia.s.sol
// Every router in ROUTER_ADDRESSES was deployed with these (their constructor
// arguments on the explorer), except Sepolia's UniversalRouterV1_2_V2Support,
// which was deployed with two placeholders: the V2 factory
// 0x5302086A3a25d473aAbBd0356eFf8Dd811a4d89B (the `UnsupportedProtocol` its
// deployment file lists) and a zero init code hash. On Sepolia no contract
// sits at a pair any router there finds (the hash below is not that of the
// factory's own pairs), so every V2 swap step they run reverts.
const V2_FACTORIES = new Map([
[
NETWORKS.mainnet.chainId,
{
v2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
[
NETWORKS.sepolia.chainId,
{
v2Factory: "0xB7f907f7A9eBC822a80BD25E224be42Ce0A698A0",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
]);
// The address of the V2 pair of `tokenA` and `tokenB` on the network with this
// chain id, found as the router finds it (see V2_FACTORIES).
function v2PairFor(chainId, tokenA, tokenB) {
const { v2Factory, pairInitCodeHash } = V2_FACTORIES.get(chainId);
const [token0, token1] =
tokenA.toLowerCase() < tokenB.toLowerCase()
? [tokenA, tokenB]
: [tokenB, tokenA];
return getCreate2Address(
v2Factory,
keccak256(solidityPacked(["address", "address"], [token0, token1])),
pairInitCodeHash,
);
}
// Universal Router command types, each read from its command byte with the // Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES) // router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = { const COMMAND_NAMES = {
@@ -158,23 +101,16 @@ function formatAmount(raw, decimals) {
// One wording for either side of the screen: a currency the calldata never // One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes // named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity. The Paid to line // on a scale — not as a token name, and not as a quantity.
// uses it too, when no step names anyone paid.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see // The same refusal when a V4 swap action could not be read (see
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// commands this decoder does not read. That action names both of its // commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not // currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an // name it would be false.
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line when the swap's output is paid to the router and no later
// step pays it out of the router.
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
// Explicit presence, never truthiness. Every gate in this file that guards a // Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an // decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent // amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -224,11 +160,6 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped. // that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid"); const ALREADY_PAID = Symbol("v2-already-paid");
// The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token.
const CONTRACT_BALANCE = 2n ** 255n;
// The amount lines that state a fact instead of a quantity. Same register as // The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a // UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say // number — and deliberately not another phrasing of "not named": these say
@@ -248,21 +179,6 @@ const WETH_ADDRESSES = [
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14", "0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
]; ];
// Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
// and ADDRESS_THIS pays the router itself, which holds what it is paid until a
// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH
// map their recipient through the dispatcher's map(), and a V4 TAKE through
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller.
// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol
// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol
// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol
// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol
// - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not // `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the // defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -349,10 +265,8 @@ function decodeV2SwapExactIn(input) {
input, input,
); );
return { return {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2], amountOutMin: d[2],
path: d[3],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
}; };
@@ -371,7 +285,6 @@ function decodeV2SwapExactOut(input) {
input, input,
); );
return { return {
recipient: d[0],
amountOut: d[1], amountOut: d[1],
amountInMax: d[2], amountInMax: d[2],
tokenIn: d[3][0], tokenIn: d[3][0],
@@ -403,7 +316,6 @@ function decodeV3SwapExactIn(input) {
const path = decodeV3Path(d[3]); const path = decodeV3Path(d[3]);
if (!path) return null; if (!path) return null;
return { return {
recipient: d[0],
amountIn: d[1], amountIn: d[1],
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: path.tokenIn, tokenIn: path.tokenIn,
@@ -414,37 +326,12 @@ function decodeV3SwapExactIn(input) {
} }
} }
// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance // Decode WRAP_ETH (command 0x0b) input bytes.
// of `token` to the recipient.
// ABI: (address token, address recipient, uint256 amountMin)
function decodeSweep(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { token: d[0], recipient: d[1] };
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
// recipient.
// ABI: (address recipient, uint256 amount) // ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) { function decodeWrapEth(input) {
try { try {
const d = coder.decode(["address", "uint256"], input); const d = coder.decode(["address", "uint256"], input);
return { recipient: d[0], amount: d[1] }; return { amount: d[1] };
} catch {
return null;
}
}
// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole
// WETH balance into ETH and pays it to the recipient.
// ABI: (address recipient, uint256 amountMin)
function decodeUnwrapWeth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { recipient: d[0] };
} catch { } catch {
return null; return null;
} }
@@ -459,7 +346,6 @@ const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c; const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e; const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f; const V4_TAKE_ALL = 0x0f;
const V4_TAKE_PORTION = 0x10;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -614,10 +500,6 @@ function totalAmount(amounts) {
// these rules leave unknown is set unknown, with no figure. A step with no // these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name. // name.
//
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set
// when the step has a payout this decoder did not read.
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -626,8 +508,6 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
const takes = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction() // The step's swap actions that could be read, as decodeV4SwapAction()
// reads them. // reads them.
const swaps = []; const swaps = [];
@@ -657,12 +537,10 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: t[1] });
} else if (actionId === V4_TAKE_ALL) { } else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount) // TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]); const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
@@ -674,19 +552,9 @@ function decodeV4Swap(input) {
} catch { } catch {
swapUndecoded = true; swapUndecoded = true;
} }
} else if (actionId === V4_TAKE_PORTION) {
// It pays a share of a currency to an address this decoder
// does not read
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
// V4Router reverts on any action not named here.
recipientUnread = true;
} }
} catch { } catch {
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among // Skip sub-actions we can't decode
// them is a payout this decoder did not read.
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) {
recipientUnread = true;
}
} }
} }
@@ -697,8 +565,6 @@ function decodeV4Swap(input) {
tokenOut: takeToken, tokenOut: takeToken,
amountIn: null, amountIn: null,
amountOutMin: null, amountOutMin: null,
takes,
recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: true, hasSwapAction: true,
}; };
@@ -738,8 +604,6 @@ function decodeV4Swap(input) {
tokenOut, tokenOut,
amountIn, amountIn,
amountOutMin, amountOutMin,
takes,
recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: swaps.length > 0, hasSwapAction: swaps.length > 0,
}; };
@@ -748,14 +612,14 @@ function decodeV4Swap(input) {
} }
} }
// Try to decode a Universal Router execute() call sent from `fromAddress`, the // Try to decode a Universal Router execute() call sent to `toAddress` on the
// user, to `toAddress` on the network with chain id `chainId`. // 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() or
// `toAddress` is not a Universal Router on that network: the same calldata // `toAddress` is not a Universal Router on that network: the same calldata
// sent to any other contract is not a swap through the router, whatever that // sent to any other contract is not a swap through the router, whatever that
// contract does with it. // contract does with it.
function decode(data, toAddress, chainId, sources, fromAddress) { function decode(data, toAddress, chainId, sources) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId); const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null; if (commandTypeMask === null) return null;
try { try {
@@ -814,60 +678,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
}; };
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
// its own address, holds what it is paid until a later step pays it
// out.
const sender = (fromAddress || "").toLowerCase();
let paidToSender = false;
// Each other address paid, once.
const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read.
let payeeUnread = false;
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH,
// paid out.
const heldByRouter = new Set();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
}
};
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop,
// not a payout, when the very next step is a V2 exact-in whose input
// is already paid, whose path starts at `token`, and whose pair (see
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2
// pair directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds.
const isHop = (i, token, recipient) => {
if (i + 1 >= commandsBytes.length) return false;
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
const next = decodeV2SwapExactIn(inputs[i + 1]);
return (
next !== null &&
(next.amountIn === ALREADY_PAID ||
next.amountIn === CONTRACT_BALANCE) &&
present(token) &&
next.path.length >= 2 &&
next.tokenIn.toLowerCase() === token.toLowerCase() &&
recipient.toLowerCase() ===
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
);
};
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask; const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push( commandNames.push(
@@ -880,27 +690,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// EXECUTE_SUB_PLAN runs a further list of commands held in // EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step // its input, which this decoder does not read. The step
// says so, and after this loop neither side is named. // says so, and after this loop neither side is named.
// Those commands may pay any address.
hasSubPlan = true; hasSubPlan = true;
commandNames[i] += " (its steps are not shown)"; commandNames[i] += " (its steps are not shown)";
payeeUnread = true; }
} else if (cmdId === 0x0a) {
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an // A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking // input side an earlier hop established without breaking
// the invariant. // the invariant.
if (p) setInput(p.token, p.amount); if (p) setInput(p.token, p.amount);
} else if (cmdId === 0x0e) { }
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at // The router passes this check whenever the owner holds at
// least minBalance, so a zero one guarantees nothing and // least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any // does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does. // other minBalance sets the output side as a swap does.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) { if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance); setOutput(b.token, b.minBalance);
} }
} else if (cmdId === 0x00) { }
if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
@@ -908,69 +721,39 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x08) {
if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x09) {
if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax. // Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true; hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]); const s = decodeV2SwapExactOut(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountInMax); setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut); setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]); if (cmdId === 0x0b) {
if (s) {
addPayee(s.recipient);
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
setInputOnce( setInputOnce(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
w.amount, w.amount,
); );
addPayee(w.recipient);
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x10) { }
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) {
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
}
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
}
if (v4 && v4.swapUndecoded) { if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step // A swap action of this step went unread, so the step
// is marked. // is marked.
@@ -1001,29 +784,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInputOnce(v4.tokenIn, v4.amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin); setOutput(v4.tokenOut, v4.amountOutMin);
} }
} else if (cmdId === 0x0c) { }
if (cmdId === 0x0c) {
hasUnwrapWeth = true; hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
if (!isRouter(u.recipient)) {
WETH_ADDRESSES.forEach((w) =>
heldByRouter.delete(w),
);
}
} else {
payeeUnread = true;
}
} else {
// Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER,
// PAY_PORTION and the others
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
payeeUnread = true;
} }
} catch { } catch {
// Skip commands we can't decode. One may pay any address. // Skip commands we can't decode
payeeUnread = true;
} }
} }
@@ -1169,31 +936,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
} }
// The output is received by the sender only when some step pays the
// sender, none pays another address, none may pay an address this
// decoder did not read, and the output token is not left in the
// router. Otherwise each other address paid is named, and further
// lines say when the output stays in the router, a payout could not
// be read, or no step names anyone paid.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received =
paidToSender &&
paidElsewhere.length === 0 &&
!payeeUnread &&
!outputHeld;
for (const address of paidElsewhere) {
details.push({ label: "Paid to", value: address, address });
}
if (outputHeld) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
if (present(minOutput)) { if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the // A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same // swap guarantees nothing back. It is said in words, in the same
@@ -1202,7 +944,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// return nothing" — and because it is true at every scale, so it // return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown. // holds even when the output token's decimals are unknown.
details.push({ details.push({
label: received ? "Min. received" : "Min. paid out", label: "Min. received",
value: value:
minOutput === 0n minOutput === 0n
? NO_MINIMUM ? NO_MINIMUM
-3
View File
@@ -68,13 +68,10 @@ function swapData(amountIn, amountOutMin) {
} }
function swapDetail(amountIn, amountOutMin, label) { function swapDetail(amountIn, amountOutMin, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode( const decoded = uniswap.decode(
swapData(amountIn, amountOutMin), swapData(amountIn, amountOutMin),
ROUTER, ROUTER,
"0x1", "0x1",
{},
RECIPIENT,
); );
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 () => {
+1
View File
@@ -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,
+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,
+372
View File
@@ -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]);
});
});
+62 -139
View File
@@ -14,25 +14,27 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR. // AutistMask's first-ever swap, 2026-02-27.
// Swapped USDT for ETH via Uniswap V4 Universal Router. // Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77 // https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
// This is that transaction's input, as a public Ethereum node returns it.
const FIRST_SWAP_CALLDATA = const FIRST_SWAP_CALLDATA =
"0x3593564c" + "0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" + "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
"0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" + "0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" + "0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" + "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
"0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" + "00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" + "070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" + "00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" + "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" + "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" + "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
"000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012"; "dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([ const routerIface = new Interface([
@@ -330,13 +332,7 @@ describe("uniswap decoder", () => {
}); });
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
const result = uniswap.decode( const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_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");
@@ -360,11 +356,9 @@ describe("uniswap decoder", () => {
expect(result.details.find((d) => d.label === "Amount").value).toBe( expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited", "Unlimited",
); );
// Its TAKE pays the From address, so the minimum is received.
expect( expect(
result.details.find((d) => d.label === "Min. received").value, result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH"); ).toBe("0.0002 ETH");
expect(labels).not.toContain("Paid to");
// Real mainnet calldata is in the deployed shape, so its V4 step is // Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded. // not said to be undecoded.
@@ -379,8 +373,6 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, deployed), v4SwapCalldata(id, deployed),
ROUTER_ADDR, ROUTER_ADDR,
"0x1", "0x1",
{},
USER_ADDR,
); );
expect(detail(result, "Steps").value).toBe("V4 Swap"); expect(detail(result, "Steps").value).toBe("V4 Swap");
}, },
@@ -397,8 +389,6 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, newer), v4SwapCalldata(id, newer),
ROUTER_ADDR, ROUTER_ADDR,
"0x1", "0x1",
{},
USER_ADDR,
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined();
@@ -429,13 +419,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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)",
); );
@@ -456,13 +440,7 @@ describe("uniswap decoder", () => {
[encodeV4Swap(new Uint8Array([id]), [newer])], [encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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)",
); );
@@ -492,13 +470,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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(
@@ -526,13 +498,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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)",
@@ -554,7 +520,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
); );
@@ -574,7 +540,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
@@ -601,7 +567,7 @@ describe("uniswap decoder", () => {
1767225600n, 1767225600n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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);
@@ -633,7 +599,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
@@ -662,7 +628,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
@@ -705,13 +671,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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);
}, },
@@ -732,7 +692,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
@@ -765,7 +725,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
}); });
@@ -784,7 +744,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
}); });
@@ -809,7 +769,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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)",
); );
@@ -832,7 +792,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
}); });
@@ -844,7 +804,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
@@ -867,13 +827,12 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
}); });
// No step pays anyone, so the minimum is not said to be received.
test("decodes BALANCE_CHECK_ERC20 for min output", () => { test("decodes BALANCE_CHECK_ERC20 for min output", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -884,10 +843,10 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. paid out"); const minOut = result.details.find((d) => d.label === "Min. received");
expect(minOut).toBeDefined(); expect(minOut).toBeDefined();
expect(minOut.value).toContain("2.0000"); expect(minOut.value).toContain("2.0000");
expect(minOut.value).toContain("USDT"); expect(minOut.value).toContain("USDT");
@@ -904,7 +863,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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
@@ -984,13 +943,7 @@ describe("uniswap decoder", () => {
[subPlan], [subPlan],
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)", "Execute Sub-Plan (its steps are not shown)",
@@ -1078,13 +1031,7 @@ describe("uniswap decoder", () => {
"$beside an EXECUTE_SUB_PLAN step does not name either side", "$beside an EXECUTE_SUB_PLAN step does not name either side",
({ commands, inputs, steps }) => { ({ commands, inputs, steps }) => {
const data = buildExecute(commands, inputs, 9999999999n); const data = buildExecute(commands, inputs, 9999999999n);
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(steps); expect(detail(result, "Steps").value).toBe(steps);
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(
@@ -1099,13 +1046,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( const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_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");
}); });
@@ -1123,7 +1064,7 @@ describe("uniswap decoder", () => {
2n ** 256n - 1n, 2n ** 256n - 1n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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(
@@ -1138,7 +1079,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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");
@@ -1213,7 +1154,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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
@@ -1247,7 +1188,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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.
@@ -1287,7 +1228,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
@@ -1332,7 +1273,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1361,7 +1302,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1398,7 +1339,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH"); expect(detail(result, "Token In").value).toContain("WETH");
@@ -1415,12 +1356,9 @@ describe("uniswap decoder", () => {
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone. // sides to the swap actions alone.
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
// pays the user, so it is not said to be received.
const V4_STEP_ENDINGS = [ const V4_STEP_ENDINGS = [
{ {
ending: "SETTLE_ALL and TAKE_ALL", ending: "SETTLE_ALL and TAKE_ALL",
minLabel: "Min. received",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [ params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n), encodeV4SettleAll(USDT_ADDR, 1000000000n),
@@ -1429,13 +1367,11 @@ describe("uniswap decoder", () => {
}, },
{ {
ending: "SETTLE and TAKE", ending: "SETTLE and TAKE",
minLabel: "Min. received",
ids: [V4_SETTLE_ID, V4_TAKE_ID], ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
}, },
{ {
ending: "CLOSE_CURRENCY", ending: "CLOSE_CURRENCY",
minLabel: "Min. paid out",
ids: [ ids: [
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
@@ -1584,7 +1520,7 @@ describe("uniswap decoder", () => {
), ),
)( )(
"a V4 step of $step, then $ending, reads Swap USDT → USDC", "a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min, minLabel }) => { ({ swaps, ids, params, amount, min }) => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
[ [
@@ -1596,18 +1532,12 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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);
expect(detail(result, "Amount")?.value).toBe(amount); expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, minLabel)?.value).toBe(min); expect(detail(result, "Min. received")?.value).toBe(min);
}, },
); );
@@ -1654,13 +1584,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode( const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
data,
ROUTER_ADDR,
"0x1",
{},
USER_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)",
); );
@@ -1719,7 +1643,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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(),
@@ -1775,7 +1699,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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)",
@@ -1798,7 +1722,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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)",
); );
@@ -1832,7 +1756,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
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);
@@ -1856,7 +1780,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT"); expect(detail(result, "Token In").value).toContain("USDT");
@@ -1887,7 +1811,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH"); expect(detail(result, "Token Out").value).toContain("WETH");
@@ -1908,7 +1832,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1916,8 +1840,7 @@ describe("uniswap decoder", () => {
}); });
// With no minimum stated before it, a zero balance check is what sets the // With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. No step pays anyone, so the // output side, and it guarantees nothing.
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => { test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -1928,11 +1851,11 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT"); expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. paid out").value).toBe( expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)", "None (no minimum guaranteed)",
); );
}); });
@@ -1948,7 +1871,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
@@ -1968,7 +1891,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
-953
View File
@@ -1,953 +0,0 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the
// address it pays, and TAKE_ALL pays the sender. The decoder read none of
// them: a swap whose output went to another address still read "Min.
// received", as if the user received it. The router reads address(1) as the
// sender and address(2) as the router itself, which holds what it is paid
// until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
const { AbiCoder, Interface, 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 uniswap = require("../src/shared/uniswap");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
// UniversalRouterV2 on Ethereum Mainnet.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// execute() of `steps`, each [command, input].
function execute(...steps) {
const commands = steps.map(([command]) => command);
return routerIface.encodeFunctionData("execute", [
solidityPacked(
commands.map(() => "uint8"),
commands,
),
steps.map(([, input]) => input),
9999999999n,
]);
}
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
return [
command,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amount, limit, path, payerIsUser],
),
];
}
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
return [
0x00,
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
recipient,
amountIn,
amountOutMin,
solidityPacked(
["address", "uint24", "address"],
[tokenIn, 3000, tokenOut],
),
true,
],
),
];
}
function sweep(token, recipient) {
return [
0x04,
coder.encode(["address", "address", "uint256"], [token, recipient, 0n]),
];
}
function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
}
function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
function balanceCheck(token, minBalance) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
),
];
}
// `step`, a [command or action, input] pair, with the top byte of the input's
// word `index` set. The decoder cannot read that word as an address.
function withDirtyWord([id, input], index) {
const at = 2 + index * 64;
return [id, input.slice(0, at) + "ff" + input.slice(at + 2)];
}
// The V4 actions a step below is built from.
const SWAP_USDT_FOR_USDC = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [
0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
];
function take(currency, recipient) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, 0n],
),
];
}
function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
}
// A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) {
return [
0x10,
coder.encode(
["bytes", "bytes[]"],
[
solidityPacked(
actions.map(() => "uint8"),
actions.map(([action]) => action),
),
actions.map(([, params]) => params),
],
),
];
}
function decode(data, sender = USER, router = ROUTER) {
return uniswap.decode(data, router, "0x1", {}, sender);
}
function labels(result) {
return result.details.map((d) => d.label);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
function paidTo(result) {
return result.details
.filter((d) => d.label === "Paid to")
.map((d) => d.value);
}
// A swap paid to the user, the lines it has always had.
const PAID_TO_USER = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Min. received",
"Steps",
"Deadline",
];
const PAID_ELSEWHERE = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Min. paid out",
"Steps",
"Deadline",
];
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("each swap command's recipient", () => {
test.each([
{
kind: "V2_SWAP_EXACT_IN",
data: (r) =>
execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V2_SWAP_EXACT_OUT",
data: (r) =>
execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V3_SWAP_EXACT_IN",
data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH",
},
{
kind: "V4_SWAP with TAKE",
data: (r) =>
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it reads as before; paid to another address it names that address",
({ data, min }) => {
const viaPlaceholder = decode(data(MSG_SENDER));
expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER);
expect(detail(viaPlaceholder, "Min. received").value).toBe(min);
// The sender's own address, in any case, reads the same.
expect(decode(data(USER), USER.toLowerCase()).details).toEqual(
viaPlaceholder.details,
);
const toOther = decode(data(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
});
describe("an output the router holds", () => {
const toRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
test("SWEEP pays it to the SWEEP's recipient", () => {
const direct = decode(
execute(
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
);
const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(toUser)).toEqual(withoutSteps(direct));
const toOther = decode(execute(toRouter, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("with nothing paying it out, it stays in the router", () => {
const result = decode(execute(toRouter));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
});
test("the router's own address as a recipient is read as address(2) is", () => {
const toRouterAddress = v3SwapExactIn(
ROUTER,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([
STAYS_IN_ROUTER,
]);
expect(
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
).toEqual(PAID_TO_USER);
});
test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => {
const toWeth = swapToWeth(ADDRESS_THIS);
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH");
const toOther = decode(execute(toWeth, unwrapWeth(OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH");
});
test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => {
const toUser = decode(
execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(paidTo(toUser)).toEqual([]);
const toOther = decode(
execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(paidTo(toOther)).toEqual([OTHER]);
});
test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => {
const step = v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
);
const toUser = decode(execute(step, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const toOther = decode(execute(step, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
});
});
// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the
// router's output out. A payment to the user of another token, of nothing, or
// before the swap leaves the output in the router.
describe("an output left in the router is not received", () => {
const toRouter = swapToWeth(ADDRESS_THIS);
test.each([
{
kind: "a SWEEP of WETH to the user, then the swap",
data: execute(sweep(WETH, MSG_SENDER), toRouter),
min: "1.0000 WETH",
},
{
kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 ETH",
},
{
kind: "the swap, then a WRAP_ETH of zero to the user",
data: execute(toRouter, wrapEth(MSG_SENDER, 0n)),
min: "1.0000 WETH",
},
{
kind: "the swap, then a SWEEP of a token it does not buy to the user",
data: execute(toRouter, sweep(USDC, MSG_SENDER)),
min: "1.0000 WETH",
},
])("$kind", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe(min);
});
test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
takeAll(USDC),
),
),
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
// are the addresses the routers find for the first two tokens of that V2
// swap's path.
describe("a swap paid to the pair the next V2 swap spends", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
];
const swapTo = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH);
const swapToPair = swapTo(PAIR);
const pairSpends = (amountIn, path) =>
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
test.each(AMOUNTS_IN)(
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = decode(
execute(swapToPair, pairSpends(amountIn, [WETH, USDC])),
);
expect(result.name).toBe("Swap USDT → USDC");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Amount").value).toBe("2000.0000 USDT");
expect(detail(result, "Min. received").value).toBe(
"1990.0000 USDC",
);
},
);
test.each(AMOUNTS_IN)(
"a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = decode(
execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])),
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(
"1990.0000 USDC",
);
},
);
test.each([
{ recipient: PAIR, lines: PAID_TO_USER, paid: [] },
{ recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] },
])(
"a V4 step whose TAKE pays $recipient, then the V2 swap",
({ recipient, lines, paid }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, recipient),
),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
false,
),
),
);
expect(labels(result)).toEqual(lines);
expect(paidTo(result)).toEqual(paid);
},
);
describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia.
const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3";
// The WETH/USDC pair the routers on Sepolia find. Sepolia's V2
// factory reports another address for it, and no contract sits at
// this one (see V2_FACTORIES in src/shared/uniswap.js).
const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb";
const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
// UNI for WETH, paid to `recipient`, then WETH for USDC through the
// pair.
const route = (recipient, amountIn) =>
execute(
v3SwapExactIn(
recipient,
ONE_WETH,
0n,
SEPOLIA_UNI,
SEPOLIA_WETH,
),
v2Swap(
0x08,
MSG_SENDER,
amountIn,
USDC_1990,
[SEPOLIA_WETH, SEPOLIA_USDC],
false,
),
);
test.each(AMOUNTS_IN)(
"paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = uniswap.decode(
route(SEPOLIA_PAIR, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_TO_USER);
},
);
test.each(AMOUNTS_IN)(
"paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = uniswap.decode(
route(OTHER, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test("the same route on Ethereum Mainnet names the Sepolia pair", () => {
const result = decode(route(SEPOLIA_PAIR, 0n));
expect(paidTo(result)).toEqual([SEPOLIA_PAIR]);
});
});
test.each([
{
kind: "takes its input from the user",
next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
},
{
kind: "spends another token",
next: pairSpends(0n, [USDC, WETH]),
},
])("when the V2 swap $kind, the pair is named", ({ next }) => {
const result = decode(execute(swapToPair, next));
expect(paidTo(result)).toEqual([PAIR]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("every step's payee counts, whichever step sets the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
const toUser = decode(
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
);
expect(labels(toUser)).toEqual(PAID_TO_USER);
// TAKE with an amount of zero takes the whole open credit, so
// TAKE_ALL after it pays the user nothing.
const split = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDC),
),
),
);
expect(paidTo(split)).toEqual([OTHER]);
expect(detail(split, "Min. received")).toBeUndefined();
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a swap paid to another address, then a V4 step that only takes the same token", () => {
const emptyStep = v4Swap(takeAll(WETH));
const result = decode(execute(swapToWeth(OTHER), emptyStep));
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
const checked = decode(
execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)),
);
expect(paidTo(checked)).toEqual([OTHER]);
expect(detail(checked, "Min. received")).toBeUndefined();
expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => {
const result = decode(
execute(
v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC),
v4Swap(takeAll(USDC)),
sweep(USDC, OTHER),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
test("a swap paid to another address, then a small swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(OTHER),
v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
});
test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDT),
),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("a payout that cannot be read is never said to be received", () => {
test("a V4 TAKE whose recipient word has its top byte set", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
withDirtyWord(take(USDC, OTHER), 1),
),
),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test.each([
{
kind: "V2_SWAP_EXACT_IN",
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
},
{
kind: "V2_SWAP_EXACT_OUT",
step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]),
},
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
])(
"a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
// The router reads only the low 32 bits of the action list's length, so
// the step still runs, and may pay anyone.
test("a V4_SWAP whose action list's length word has its top byte set", () => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER));
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("an output set only by BALANCE_CHECK_ERC20", () => {
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
expect(paidTo(result)).toEqual([UNNAMED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
test.each([
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
{ kind: "TRANSFER", step: transferShape(0x05) },
{ kind: "PAY_PORTION", step: transferShape(0x06) },
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
{
kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [
0x0d,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER, OTHER, 1n, WETH]]],
),
],
},
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{
kind: "EXECUTE_SUB_PLAN",
step: [
0x21,
coder.encode(
["bytes", "bytes[]"],
["0x05", [transferShape(0x05)[1]]],
),
],
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])],
},
{
kind: "V4 TAKE_PORTION",
step: v4Swap([
0x10,
coder.encode(
["address", "address", "uint256"],
[WETH, OTHER, 10000n],
),
]),
},
])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("paths whose recipients were untested", () => {
test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => {
const check = balanceCheck(WETH, ONE_WETH);
const toUser = decode(execute(swapToWeth(MSG_SENDER), check));
expect(paidTo(toUser)).toEqual([]);
expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH");
const toOther = decode(execute(swapToWeth(OTHER), check));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a V4 step whose swap action cannot be read still has its TAKE read", () => {
const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r));
const toUser = decode(execute(step(MSG_SENDER)));
expect(detail(toUser, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(paidTo(toUser)).toEqual([]);
expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]);
});
});
// The DOM stub both screens are drawn into.
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: () => {},
};
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
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);
}
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapPaidTo = (recipient) => execute(swapToWeth(recipient));
// Open the approval screen for a transaction from USER sending `data` to the
// router.
async function showApproval(data) {
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: USER,
approvedTx: {
type: 2,
from: USER,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: ROUTER,
value: "0x0",
data,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
return node("approve-tx-decoded-details").innerHTML;
}
// The transaction the explorer reports: from USER, sending `mockSentData` to
// the router.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentData = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
from: { hash: USER },
to: { hash: ROUTER },
raw_input: mockSentData,
method: "execute",
};
}
return {};
}
// Open the transaction detail screen for that transaction, sending `data`.
async function showDetail(data) {
mockSentData = data;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: USER,
to: ROUTER,
value: "",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: "0x1",
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
return node("tx-detail-calldata-details").innerHTML;
}
describe("the screens judge the output against the transaction's sender", () => {
test("the approval screen, against its From address", async () => {
const toUser = await showApproval(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showApproval(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).toContain(OTHER);
expect(toOther).not.toContain("Min. received");
});
test("the transaction detail screen, against the sender the explorer reports", async () => {
const toUser = await showDetail(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showDetail(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).not.toContain("Min. received");
});
});
+1 -8
View File
@@ -53,14 +53,7 @@ function unwrapData() {
} }
function detail(data, label, sources) { function detail(data, label, sources) {
// Sent by the address the swap pays, so the output is the user's. const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {});
const decoded = uniswap.decode(
data,
ROUTER,
"0x1",
sources || {},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
+2 -3
View File
@@ -87,7 +87,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => { describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding // A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The // this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a minimum // BALANCE_CHECK still names the output, so the screen has a Min. received
// figure and a Token Out, and previously claimed the user was paying ETH // figure and a Token Out, and previously claimed the user was paying ETH
// for them. // for them.
const data = () => const data = () =>
@@ -132,8 +132,7 @@ describe("an execute() that names only an output token", () => {
test("still states the output it did establish", () => { test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT"); expect(detail(data(), "Token Out").value).toContain("USDT");
// No step pays anyone, so the minimum is not said to be received. expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
}); });
}); });
+2 -4
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", {}, USER); const decoded = uniswap.decode(data, ROUTER, "0x1", {});
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -134,9 +134,7 @@ describe("a V4 step that states a Min. received but no output currency", () => {
}); });
test("refuses to scale Min. received rather than assuming 18", () => { test("refuses to scale Min. received rather than assuming 18", () => {
// With no TAKE, no step pays the user either, so the line is not expect(detail(data(), "Min. received").value).toBe(
// labelled received.
expect(detail(data(), "Min. paid out").value).toBe(
unknownDecimalsAmount(MIN_OUT), unknownDecimalsAmount(MIN_OUT),
); );
}); });
+1 -2
View File
@@ -82,8 +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) {
// Sent by the address the swap pays, so the output is the user's. const decoded = decodeCalldata(data, ROUTER, "0x1");
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }