3 Commits
Author SHA1 Message Date
sneak 640f31e0a1 fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
ConfirmTx set the transaction's chain id to the active network's every
time it drew the screen, so a popup reopened onto a saved confirmation
after a network switch in Settings described the reviewed transfer as
the new network's and sent it there.

show() now sets the chain id only on a transaction Send hands over, and
the screen describes the transaction entirely on that network: native
currency, USD values, explorer links, and the node and explorer its
checks ask. explorerUrl(), renderAddressHtml(), getPrice(), formatFee()
and checkEtherscanLabel() take an optional chain id for this. A saved
transaction whose chain id names no network is not restored.

Model: opus-5-5
2026-10-19 05:22:21 +00:00
clawbot b5d2cedb57 fix: the swap decoder reads each command's type with its router's mask (closes #535)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Each Universal Router address now carries the mask its own source reads a
command's type with: 0x1f for UniversalRouterV1, 0x3f for V1_2 and V2, 0x7f
for V2_1_1 and V2_1_2. The fixed 0x1f dropped the 0x20 bit, so
EXECUTE_SUB_PLAN (0x21) was listed as a V3 exact-out swap. A sub-plan step is
now listed as "Execute Sub-Plan (its steps are not shown)". Its commands are
not read and may spend from or pay the user wherever the step stands, so with
one the summary names no swap direction, Token In and Token Out read
"Unknown (could not be decoded)", and there is no Amount or Min. received.

Model: opus-5-5
2026-10-19 07:13:22 +02:00
clawbot f8b050f501 docs: the user guide says switching networks restores the last endpoints used (closes #589)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The user guide said switching the network in Settings resets the RPC and
Blockscout endpoints to that network's defaults. Switching restores the
endpoints last used on that network, or its defaults if it has none, as
README's Settings section says; the guide now says that.

The guide also said all three approval screens show the phishing warning.
There are four: a connected site's request to switch the network has its own
approval screen with the same warning. The guide now describes that request in
a short paragraph and says four.

Model: opus-5-5
2026-10-19 06:51:17 +02:00
12 changed files with 717 additions and 106 deletions
+34 -12
View File
@@ -1013,10 +1013,12 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the
wallet's balances and the Send screen, which sends on the active network, use
the active network's. The confirmation screen uses the one of the network its
transaction was reviewed on, the network active when Send showed it, even after
the network is switched in Settings. A transaction's figures use the one of the
network its chain id names, whichever network is active: the value and fee on
the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
@@ -1195,9 +1197,10 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
with a swap action always sets the output side, and sets the input side only
when no earlier step set it. It finds each side by three rules:
the transaction could not be read or the transaction has an `EXECUTE_SUB_PLAN`
step, which leaves both sides unknown (see Transaction Decoding below). A V4
step with a swap action always sets the output side, and sets the input side
only when no earlier step set it. It finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
@@ -1781,12 +1784,20 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
- "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed, with this screen open or before the popup
reopened onto it → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
carries the chain id of the network active when Send showed this screen,
and keeps it when the screen is drawn again from the saved transaction, so
it cannot be signed for another. The screen describes it on that network,
whichever network is active: its amounts are in that network's native
currency, its USD values are that network's, so a testnet transaction
shows none, its From, To and token contract links open that network's
explorer, 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
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
@@ -2892,7 +2903,18 @@ Currently supported:
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`.
`V4 Swap (could not be decoded)`. Each step is named by its command type,
which the decoder reads from the command byte with the mask the router at that
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. The decoder does not read
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that
step stands they may spend the user's tokens or pay the user. That step is
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`
read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line.
### Non-Goals Forever
+10 -2
View File
@@ -306,7 +306,14 @@ enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require
your password.
If the requesting site's domain is on the phishing blocklist, all three approval
A connected site can also ask to switch the wallet's network. A popup shows the
site's origin, the current network and the requested network. Nothing changes
unless you click "Switch"; clicking "Reject" or closing the popup leaves
everything as it was. Switching works exactly as it does in Settings, and
changes the network for the whole wallet and for every site, not only for the
one that asked.
If the requesting site's domain is on the phishing blocklist, all four approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be
@@ -378,7 +385,8 @@ Click the gear icon on the home screen to access settings:
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
timestamps to UTC, and choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
restores the RPC and Blockscout endpoints you last used on that network, or
that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
+6 -2
View File
@@ -13,6 +13,7 @@
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
// The views this page load has rendered.
//
@@ -125,13 +126,16 @@ function isRenderableDecoded(value) {
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
isAddressText(value.to) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
+61 -25
View File
@@ -11,7 +11,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -20,6 +19,10 @@ const {
formatFee,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -68,11 +71,11 @@ function restore() {
}
}
function confirmAddressHtml(address, ensName, title) {
function confirmAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
@@ -87,8 +90,10 @@ function valueWithUsd(text, usdAmount) {
// once its amount is re-derived from the fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
@@ -101,16 +106,22 @@ function show(txInfo) {
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
// The network this screen is drawn for, and the only one it sends on: the
// one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, and the node and explorer its
// checks ask. The wait, success and error screens label amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -126,7 +137,7 @@ function show(txInfo) {
if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token,
{},
{ chainId: txInfo.chainId },
);
tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection);
@@ -140,6 +151,7 @@ function show(txInfo) {
txInfo.from,
null,
fromTitle,
txInfo.chainId,
);
// To (with blockie)
@@ -148,14 +160,15 @@ function show(txInfo) {
txInfo.to,
txInfo.ensName,
toTitle,
txInfo.chainId,
);
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
@@ -212,9 +225,9 @@ function show(txInfo) {
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again.";
// Both shown later, by checkRecipientHistory(). The first sentence says
@@ -222,12 +235,12 @@ function show(txInfo) {
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
@@ -280,7 +293,7 @@ function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
: nativeCurrencyByChainId(txInfo.chainId);
const { canSend, codes } = validateTransfer({
isErc20,
@@ -369,9 +382,23 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -419,12 +446,17 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
"~" +
formatFee(
estimateWei,
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
@@ -433,7 +465,8 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
setVisible("confirm-fee-reserve", false);
}
@@ -523,9 +556,10 @@ function showSendError(message) {
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
});
for (const w of asyncWarnings) {
if (w.type === "contract") {
@@ -564,8 +598,10 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
// The screen describes the network the transaction was reviewed for,
// which Settings may have switched away from since: with this screen
// open, where Back only unhides it, or before the popup reopened onto
// it.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
+27 -20
View File
@@ -22,6 +22,7 @@ const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
@@ -284,11 +285,12 @@ function unknownableAmount(balance) {
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
// the Send screen, which sends on the active network, label a native amount
// with this; the confirmation screen, and a transaction already made or
// requested, label it by the transaction's own chain id, through
// nativeCurrencyByChainId() in networks.js. The "ETH" that state.selectedToken
// and txInfo.token hold is the native token's id, not its label, and stays
// "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
@@ -307,11 +309,12 @@ function tokenLabel(address) {
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
// the ETH price is known. The USD value is of the exact fee, not of the
// truncated figure.
function formatFee(wei, symbol) {
// the ETH price is known on the network with `chainId`, or on the active
// network when no chain id is given. The USD value is of the exact fee, not of
// the truncated figure.
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const ethPrice = getPrice("ETH", chainId);
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) +
@@ -604,17 +607,19 @@ const EXT_ICON =
`</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
// data, and it comes out of explorer JSON (a transaction's from/to, a token's
// address_hash), which nothing upstream validates as hex. percent-encoding it
// keeps a segment that contains a slash, a query or a fragment from
// re-pointing the link somewhere else in the explorer.
function explorerUrl(kind, value, chainId) {
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) {
return explorerUrl("address", address);
function etherscanAddressUrl(address, chainId) {
return explorerUrl("address", address, chainId);
}
// The URL still has to be escaped on the way into href="...": encoding
@@ -680,16 +685,18 @@ function attachCopyHandlers(container) {
// with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link
// chainId — the network whose explorer the link opens; the active
// network's when absent
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const { title, ensName, maxLen, noLink, chainId } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const link = etherscanAddressUrl(address, chainId);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
+6 -1
View File
@@ -58,6 +58,8 @@ function getLocalWarnings(address, options = {}) {
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted.
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
@@ -110,7 +112,10 @@ async function getFullWarnings(address, provider, options = {}) {
// Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try {
const etherscanWarning = await checkEtherscanLabel(address);
const etherscanWarning = await checkEtherscanLabel(
address,
options.chainId,
);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
+9 -4
View File
@@ -2,6 +2,8 @@
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks");
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
@@ -72,18 +74,21 @@ function parseEtherscanPage(html) {
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* Uses the current network's explorer URL so the lookup works on both
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
* Uses the explorer of the network with `chainId`, or of the active network
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
*
* @param {string} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
async function checkEtherscanLabel(address, chainId) {
try {
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state");
const etherscanBase = currentNetwork().explorerUrl + "/address/";
const network = chainId ? networkByChainId(chainId) : currentNetwork();
const etherscanBase = network.explorerUrl + "/address/";
const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" },
});
+6 -3
View File
@@ -2,6 +2,7 @@
const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -36,10 +37,12 @@ function clearPrices() {
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) {
// Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet / unknown.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (network.isTestnet) return null;
return prices[symbol] || null;
}
+74 -27
View File
@@ -19,39 +19,54 @@ const ROUTER_IFACE = new Interface([
// The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there.
// The files also list `UnsupportedProtocol`, which is not a router.
//
// Beside each address is the mask that router reads a command's type with:
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source verified
// for that address:
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
const ROUTER_ADDRESSES = new Map([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
"0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2
"0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1
"0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2
"0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1
"0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
],
],
]);
// Whether `address` is a Universal Router deployment on the network with this
// chain id. Address case is ignored.
function isRouterAddress(address, chainId) {
// The mask the Universal Router deployment at `address` on the network with
// this chain id reads a command's type with, or null when `address` is not one
// of them. Address case is ignored.
function routerCommandTypeMask(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase();
return routers.some((router) => router.toLowerCase() === wanted);
const router = routers.find(([a]) => a.toLowerCase() === wanted);
return router ? router[1] : null;
}
// Universal Router command IDs (lower 5 bits of each command byte)
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
@@ -90,8 +105,10 @@ function formatAmount(raw, decimals) {
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()). That action names both of its currencies, so saying the
// calldata did not name one would be false.
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// 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
// name it would be false.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// Explicit presence, never truthiness. Every gate in this file that guards a
@@ -603,7 +620,8 @@ function decodeV4Swap(input) {
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources) {
if (!isRouterAddress(toAddress, chainId)) return null;
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -621,6 +639,7 @@ function decode(data, toAddress, chainId, sources) {
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
let hasSubPlan = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -660,13 +679,21 @@ function decode(data, toAddress, chainId, sources) {
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f;
const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push(
COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
try {
if (cmdId === 0x21) {
// EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step
// says so, and after this loop neither side is named.
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
}
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an
@@ -767,14 +794,28 @@ function decode(data, toAddress, chainId, sources) {
}
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among
// them would replace the input side. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
if (hasSubPlan) {
inputToken = null;
inputAmount = null;
outputToken = null;
minOutput = null;
outputEstablished = true;
}
// Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step with a swap action left it
// unknown; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A V4 step
// with a swap action did set it, even when it set it unknown. Any other
// output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC.
// a currency for it, a V4 step with a swap action left it unknown, or
// an EXECUTE_SUB_PLAN step did; tokenInfo() refuses rather than calling
// it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side. A
// V4 step with a swap action, or an EXECUTE_SUB_PLAN step, did set it,
// even when it set it unknown. Any other output keeps its own token
// and figure: a swap that buys USDC and then unwraps the WETH it did
// not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
@@ -819,7 +860,10 @@ function decode(data, toAddress, chainId, sources) {
// screen must not describe the same condition in two ways.
details.push({
label: "Token In",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
value:
swapUndecoded || hasSubPlan
? UNDECODED_CURRENCY
: UNNAMED_CURRENCY,
});
}
@@ -885,7 +929,10 @@ function decode(data, toAddress, chainId, sources) {
// calldata did not state.
details.push({
label: "Token Out",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
value:
swapUndecoded || hasSubPlan
? UNDECODED_CURRENCY
: UNNAMED_CURRENCY,
});
}
+288 -9
View File
@@ -7,25 +7,52 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// The same holds when the popup was closed after the switch and reopened, and
// Back drew the screen again from the transaction it saved
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for
// the new network, and Sign & Send sent the reviewed transfer there. Drawn
// again, the screen describes the transaction entirely on the network it was
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
// only the node, fetch and the DOM stubbed. The stub node is on the network
// its provider was built for, so a broadcast records the network it went to.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
// The questions the stub node was asked for the fee and the recipient checks,
// by method, network name and RPC URL, in order. The signed transactions it
// was handed, with its network's chain id and RPC URL. And whether it quotes
// fees as a network with no type-2 pricing does.
const mockNode = { asked: [], broadcast: [], legacyFees: false };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.url = url;
this.network = network;
}
record(method) {
mockNode.asked.push({
method,
network: this.network.name,
url: this.url,
});
}
async getNetwork() {
return this.network;
}
async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
return {
maxFeePerGas: null,
maxPriorityFeePerGas: null,
gasPrice: 10000000000n,
};
}
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -36,16 +63,22 @@ jest.mock("ethers", () => {
return 21000n;
}
async getCode() {
this.record("getCode");
return "0x";
}
async getTransactionCount() {
this.record("getTransactionCount");
return 1;
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
mockNode.broadcast.push({
chainId: this.network.chainId,
url: this.url,
signed,
});
return { hash: "0x" + "ab".repeat(32) };
}
}
@@ -75,6 +108,8 @@ jest.mock("../src/popup/views/txStatus", () => ({
showError: jest.fn(),
}));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
@@ -125,15 +160,27 @@ global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { state, loadState, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks");
const { prices } = require("../src/shared/prices");
const {
goBack,
pushCurrentView,
setBackRenderer,
showView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const PASSWORD = "correct horse battery staple";
function el(id) {
@@ -150,7 +197,10 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.asked = [];
mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
@@ -160,10 +210,16 @@ afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for the transfer `changes` makes of it, as Review on the Send screen does,
// with its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
}
// review() on the network already active, without switching to it again.
async function reviewOnActiveNetwork(changes = {}) {
state.wallets = [
{
type: "key",
@@ -186,7 +242,90 @@ async function review(networkId) {
tokenBalance: null,
tokenDecimals: null,
max: false,
...changes,
});
await settle();
}
// The user's own node for a network, set in Settings.
function ownNode(networkId) {
return "https://" + networkId + ".node.example.invalid";
}
// After review() on `reviewedOn`: the user's own node set for it, the Settings
// gear, `switchedTo` chosen there, the popup closed and reopened onto
// Settings, and Back, which draws the confirmation screen again from the
// transaction it saved. The reopened page holds an ETH price of $2,000 and a
// USDC price of $1 whichever network is active, so each USD value shows which
// network it was priced for.
async function switchReopenAndGoBack(reviewedOn, switchedTo) {
state.rpcUrl = ownNode(reviewedOn);
pushCurrentView();
showView("settings");
await onChainSwitch(switchedTo);
await loadState();
elements.clear();
resetRenderedViews();
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
prices.ETH = 2000;
prices.USDC = 1;
mockNode.asked = [];
global.fetch.mockClear();
goBack();
await settle();
}
// The USD value `text` as the screen appends it for `networkId`: nothing on a
// testnet, which has no USD values.
function usdOn(networkId, text) {
return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")";
}
// The screen for the transfer review() shows by default, described on
// `networkId`: in its native currency, priced by it, linking to its explorer.
function expectNativeTransferOn(networkId) {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId];
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
);
expect(el("confirm-fee-amount").textContent).toBe(
"~0.0002 " + symbol + usdOn(networkId, "$0.42"),
);
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(el("confirm-gas-error").textContent).toBe(
"You do not have enough " +
symbol +
" to pay the network fee for this transfer. Please add " +
symbol +
" to this address and try again.",
);
expect(el("confirm-recipient-warning").textContent).toContain(
" holds no " + symbol + ". ",
);
expect(el("confirm-contract-warning").textContent).toContain(
" Sending " + symbol + " or tokens ",
);
expect(el("confirm-from").innerHTML).toContain(
`href="${explorerUrl}/address/${HOLDER}"`,
);
expect(el("confirm-to").innerHTML).toContain(
`href="${explorerUrl}/address/${RECIPIENT}"`,
);
}
// show() estimates the fee and checks the recipient without awaiting either;
// this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
@@ -208,6 +347,24 @@ test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () =>
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
// Saving an RPC endpoint in Settings (src/popup/views/settings.js) sets only
// state.rpcUrl: the endpoint remembered for the active network keeps its old
// value until the next switch away from it.
test("a transfer reviewed after the active network's RPC endpoint was changed in Settings asks the new endpoint", async () => {
const newNode = "https://new.node.example.invalid";
await onChainSwitch("sepolia");
state.rpcUrl = newNode;
await saveState();
await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([
{ method: "getFeeData", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode },
]);
});
describe.each([
["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"],
@@ -238,6 +395,128 @@ describe.each([
},
);
describe.each([
["sepolia", "mainnet"],
["mainnet", "sepolia"],
])(
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
(reviewedOn, switchedTo) => {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getCode",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getTransactionCount",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT,
]);
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
// The Settings gear, the reviewed network chosen, and Back, which
// only unhides the screen as it was drawn.
pushCurrentView();
showView("settings");
await onChainSwitch(reviewedOn);
goBack();
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
await signAndSend();
const chainId = BigInt(NETWORKS[reviewedOn].chainId);
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(chainId);
expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn));
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
chainId,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
test("for more than the balance, says so in that network's native currency", async () => {
await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-errors").innerHTML).toContain(
"Insufficient balance. You have 5.0000 " +
symbol +
" but are trying to send 10 " +
symbol +
".",
);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
await review(reviewedOn);
mockNode.legacyFees = true;
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-fee-amount").textContent).toBe(
"0.0002 " + symbol + usdOn(reviewedOn, "$0.42"),
);
});
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
await review(reviewedOn, {
token: TOKEN,
tokenSymbol: "USDC",
tokenBalance: "50",
tokenDecimals: 6,
amount: "10",
});
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-type").textContent).toBe(
"ERC-20 token transfer (USDC)",
);
expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
);
});
},
);
// The check in the click handler tells the user; the send itself carries the
// chain id the screen was drawn for, so a signer on another network's node
// cannot sign it even if the network changes after that check.
+14
View File
@@ -453,6 +453,20 @@ const CONTRACT = [
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: "0x5",
},
},
views: ["confirm-tx"],
restored: false,
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the
// regression guard.
+182 -1
View File
@@ -1,8 +1,12 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1.
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
// which reads a command's type with the mask 0x3f.
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// Two more there, which read it with 0x1f and 0x7f.
const ROUTER_V1_ADDR = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const ROUTER_V2_1_2_ADDR = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -105,6 +109,12 @@ function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [actions, params]);
}
// Helper: encode an EXECUTE_SUB_PLAN input (command 0x21), which holds the
// sub-plan's own commands and inputs
function encodeSubPlan(commands, inputs) {
return coder.encode(["bytes", "bytes[]"], [commands, inputs]);
}
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
@@ -864,6 +874,177 @@ describe("uniswap decoder", () => {
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/535 — every command's type
// was read from its low five bits, as only UniversalRouterV1 reads it, so
// the later routers listed 0x21 as a V3 exact-out swap, and
// UniversalRouterV2_1_2 listed 0x40 as a V3 exact-in swap.
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x21",
steps: "V3 Swap (Exact Out)",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x21",
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x21",
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x40",
steps: "V3 Swap (Exact In)",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x40",
steps: "V3 Swap (Exact In)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x40",
steps: "Command 0x40",
},
])(
"command $command sent to $router is listed as $steps",
({ address, command, steps }) => {
const data = buildExecute(command, ["0x"], 9999999999n);
const result = uniswap.decode(data, address, "0x1");
expect(detail(result, "Steps").value).toBe(steps);
},
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
]);
const data = buildExecute(
solidityPacked(["uint8"], [command]),
[subPlan],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// The steps inside a sub-plan may spend the user's tokens or pay the user,
// so the steps read beside it are not the whole swap, whether they come
// before it or after it. No side is named and no figure is given.
test.each([
{
// 2,000 USDT for at least 1 WETH, paid to the router; the sub-plan
// swaps that WETH for USDC.
beside: "a swap before",
commands: "0x0021",
inputs: [
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000000000n, [
WETH_ADDR,
USDC_ADDR,
]),
]),
],
steps: "V3 Swap (Exact In) \u2192 Execute Sub-Plan (its steps are not shown)",
},
{
// The sub-plan buys WETH with the user's USDC; 1 WETH is then
// swapped for at least 2,000 DAI.
beside: "a swap after",
commands: "0x2100",
inputs: [
encodeSubPlan("0x00", [
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
USDC_ADDR,
WETH_ADDR,
]),
]),
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000n * ONE_WETH, [
WETH_ADDR,
DAI_ADDR,
]),
],
steps: "Execute Sub-Plan (its steps are not shown) \u2192 V3 Swap (Exact In)",
},
{
// A permit among the sub-plan's steps would replace this one.
beside: "a permit before",
commands: "0x0a21",
inputs: [encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), "0x"],
steps: "Permit2 Permit \u2192 Execute Sub-Plan (its steps are not shown)",
},
{
// The sub-plan buys USDC for the user; an UNWRAP_WETH of 0
// follows. No step outside the sub-plan sets the output side, and
// UNWRAP_WETH would then make Token Out ETH.
beside: "an UNWRAP_WETH after",
commands: "0x210c",
inputs: [
encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000000n, 1900000000n, [
USDT_ADDR,
USDC_ADDR,
]),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
steps: "Execute Sub-Plan (its steps are not shown) \u2192 Unwrap WETH",
},
])(
"$beside an EXECUTE_SUB_PLAN step does not name either side",
({ commands, inputs, steps }) => {
const data = buildExecute(commands, inputs, 9999999999n);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(detail(result, "Steps").value).toBe(steps);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");