Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 9a2910358d chore: the native token's label follows the network (closes #372)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing
read it: every screen wrote ETH. The wallet's balances and the Send and
confirmation screens now use the active network's. A transaction's figures use
the network its chain id names, through nativeCurrencyByChainId(): the
approval value and fee, the wait, success and error screens, history entries,
the detail screen and the fee-limit refusal, so a site switching networks
cannot make one read as another network's coin. The "ETH" that selectedToken
and txInfo.token hold is the native token's id and is unchanged. A token
reporting any network's nativeCurrency is a spoof, and the detail screen calls
an entry a token transfer when it has a token contract.

Model: opus-5-5
2026-10-04 22:57:36 +00:00
15 changed files with 290 additions and 92 deletions
+13 -7
View File
@@ -846,13 +846,19 @@ wherever shown. If a formatting rule applies in one place, it applies in every
place. Users should never see the same value rendered differently on two place. Users should never see the same value rendered differently on two
screens. screens.
The native token's label is the active network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. Wherever `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
this document shows ETH as the label of a native balance, value or fee, in a wallet's balances and the Send and confirmation screens, which send on the
"Native ETH transfer" type line, in the contract-recipient warning or in the active network, use the active network's. A transaction's figures use the one of
refusal of a fee above 1 ETH, Sepolia shows `SepoliaETH`. The swap lines keep the network its chain id names, whichever network is active: the value and fee
`ETH`, the router's own name for the native currency, and the ETH/USD price on the approval screen, the amount on the wait, success and error screens, the
line, shown on mainnet only, keeps its fixed wording. 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
ETH transfer" type line, in the contract-recipient warning or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
**Specific Exception — Truncation:** On some non-critical display locations, we **Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely may truncate _a small number_ of characters from the middle of an address solely
+10 -9
View File
@@ -45,17 +45,18 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-04: The native token's label follows the active network - 2026-10-04: The native token's label follows the network
([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives ([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives
each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`, each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`,
so on Sepolia the balance, the value and the fee all read `ETH`. The balance so on Sepolia the balance, the value and the fee all read `ETH`. Every native
lists, Send, confirmation, approval, wait, success and error screens, the figure now reads `nativeCurrency`, which is `ETH` on mainnet and `SepoliaETH`
transaction history, the contract-recipient warning and the refusal of a fee on Sepolia: the balance lists, Send and confirmation screens and the
above the limit now read `nativeCurrency`, which is `ETH` on mainnet and contract-recipient warning the active network's; the approval, wait, success,
`SepoliaETH` on Sepolia. A token claiming any network's `nativeCurrency` is error and transaction detail screens, the transaction history and the refusal
dropped as a fake, as one claiming `ETH` already was, and the transaction of a fee above the limit that of the network the transaction's chain id names.
detail screen calls an entry a token transfer when it has a token contract, A token claiming any network's `nativeCurrency` is dropped as a fake, as one
not by its symbol. claiming `ETH` already was, and the transaction detail screen calls an entry a
token transfer when it has a token contract, not by its symbol.
- 2026-10-04: A popup that is already open when the stored profile becomes - 2026-10-04: A popup that is already open when the stored profile becomes
unreadable moves to the recovery screen unreadable moves to the recovery screen
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
+2 -3
View File
@@ -7,13 +7,12 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
nativeCurrency,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -154,7 +153,7 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
nativeCurrency(), currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
+2 -2
View File
@@ -18,7 +18,7 @@ const {
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { const {
@@ -227,7 +227,7 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
nativeCurrency(), currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
+16 -5
View File
@@ -10,10 +10,12 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee, formatFee,
nativeCurrency,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks"); const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { const {
formatEther, formatEther,
formatUnits, formatUnits,
@@ -220,8 +222,12 @@ function showTxFee(approvedTx) {
const gasLimit = BigInt(approvedTx.gasLimit); const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
// Through formatFee(), as the confirmation screen's fee is, so the same // Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both. // fee reads the same on both. In the native currency of the network shown
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas); // above, as the value is.
$("approve-tx-fee").textContent = formatFee(
gasLimit * feePerGas,
nativeCurrencyByChainId(approvedTx.chainId),
);
let detail = let detail =
gasLimit.toString() + gasLimit.toString() +
@@ -265,6 +271,7 @@ function showTxApproval(details) {
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: approvedTx.chainId,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
@@ -330,10 +337,14 @@ function showTxApproval(details) {
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + ethValueFormatted +
" " + " " +
nativeCurrency() + nativeCurrencyByChainId(approvedTx.chainId) +
(usdStr ? " (" + usdStr + ")" : ""); (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx); showTxFee(approvedTx);
+11 -3
View File
@@ -18,7 +18,7 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
@@ -398,7 +398,8 @@ async function estimateGas(txInfo) {
// The fee line goes through formatFee(), as the approval screen's // The fee line goes through formatFee(), as the approval screen's
// does, so the same fee reads the same on both. // does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) { if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei); $("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
$("confirm-fee-reserve").textContent = $("confirm-fee-reserve").textContent =
"up to " + "up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) + truncateAmountNeverZero(formatEther(gasCostWei)) +
@@ -410,7 +411,10 @@ async function estimateGas(txInfo) {
// No spread to report: either there is no estimate, or the node // No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected // quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone. // cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(gasCostWei); $("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
);
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
@@ -530,6 +534,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx; let tx;
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(
+12 -8
View File
@@ -268,25 +268,29 @@ function unknownableAmount(balance) {
} }
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH` // The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. Every screen labels a native // on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// amount with this. The "ETH" that state.selectedToken and txInfo.token hold // the Send and confirmation screens, which send on the active network, label a
// is the native token's id, not its label, and stays "ETH" on every network. // 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.
function nativeCurrency() { function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// ETH price is known. The USD value is of the exact fee, not of the truncated // native currency of the network the fee is paid on, then its USD value when
// figure. // the ETH price is known. The USD value is of the exact fee, not of the
function formatFee(wei) { // truncated figure.
function formatFee(wei, symbol) {
const eth = formatEther(wei); const eth = formatEther(wei);
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : ""; const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return ( return (
truncateAmountNeverZero(eth) + truncateAmountNeverZero(eth) +
" " + " " +
nativeCurrency() + symbol +
(usd ? " (" + usd + ")" : "") (usd ? " (" + usd + ")" : "")
); );
} }
+7 -2
View File
@@ -15,7 +15,12 @@ const {
attachCopyHandlers, attachCopyHandlers,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const {
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { notify } = require("../../shared/browserApi"); const { notify } = require("../../shared/browserApi");
const { const {
updateSendBalance, updateSendBalance,
@@ -186,7 +191,7 @@ async function loadHomeTxs(ctx) {
fetchRecentTransactions( fetchRecentTransactions(
addr, addr,
state.blockscoutUrl, state.blockscoutUrl,
nativeCurrency(), currentNetwork().chainId,
), ),
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
+12 -7
View File
@@ -18,10 +18,10 @@ const {
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
nativeCurrency,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -45,7 +45,7 @@ function getTransactionType(tx) {
// By the token contract, not the symbol: a token chooses its own symbol // By the token contract, not the symbol: a token chooses its own symbol
// and can report the native token's, but only a token transfer has one. // and can report the native token's, but only a token transfer has one.
if (tx.contractAddress) return "ERC-20 Token Transfer"; if (tx.contractAddress) return "ERC-20 Token Transfer";
return "Native " + nativeCurrency() + " Transfer"; return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
} }
function blockieHtml(address) { function blockieHtml(address) {
@@ -87,6 +87,11 @@ function show(tx) {
isContractCall: tx.isContractCall || false, isContractCall: tx.isContractCall || false,
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup
// shows this screen again.
chainId: tx.chainId,
}, },
}; };
render(); render();
@@ -182,7 +187,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to); loadFullTxDetails(tx.hash, tx.to, tx.chainId);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -200,7 +205,7 @@ function showDetailField(sectionId, contentId, value) {
section.classList.remove("hidden"); section.classList.remove("hidden");
} }
function populateOnChainDetails(txData) { function populateOnChainDetails(txData, chainId) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = explorerUrl("block", String(txData.block_number));
@@ -230,7 +235,7 @@ function populateOnChainDetails(txData) {
showDetailField( showDetailField(
"tx-detail-fee-section", "tx-detail-fee-section",
"tx-detail-fee", "tx-detail-fee",
feeEth + " " + nativeCurrency(), feeEth + " " + nativeCurrencyByChainId(chainId),
); );
} }
@@ -290,7 +295,7 @@ function populateOnChainDetails(txData) {
} }
} }
async function loadFullTxDetails(txHash, toAddress) { async function loadFullTxDetails(txHash, toAddress, chainId) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -307,7 +312,7 @@ async function loadFullTxDetails(txHash, toAddress) {
const txData = await resp.json(); const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData); populateOnChainDetails(txData, chainId);
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;
+12 -7
View File
@@ -12,11 +12,11 @@ const {
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
nativeCurrency,
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -87,9 +87,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait(); endWait();
const id = waitId; const id = waitId;
// A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrency() ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
@@ -194,9 +198,10 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of // an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup // restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol are deliberately unchecked: they are compared and // txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// coalesced rather than dereferenced, and tokenSymbol is null for ETH. // compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -224,7 +229,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrency() ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
@@ -321,7 +326,7 @@ function showError(txInfo, txHash, message) {
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrency() ? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?"); : displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
+5 -7
View File
@@ -58,7 +58,7 @@ const {
verifyMessage, verifyMessage,
verifyTypedData, verifyTypedData,
} = require("ethers"); } = require("ethers");
const { networkByChainId } = require("./networks"); const { nativeCurrencyByChainId } = require("./networks");
// The only transaction types this wallet signs: legacy, EIP-2930 and // The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can // EIP-1559. populateTransaction() produces nothing else, so nothing else can
@@ -410,12 +410,10 @@ function assertWithinCeilings(tx) {
} }
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) { if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
// The fee is paid in the native currency of the network the // The fee is paid in the native currency of the network the
// transaction is for. Every caller's transaction names it; one // transaction is for. Every caller's transaction names it.
// that does not, or names a network not in networks.js, says ETH. const nativeCurrency = nativeCurrencyByChainId(
const network = present(tx.chainId) present(tx.chainId) ? toQuantity(tx.chainId) : null,
? networkByChainId(toQuantity(tx.chainId)) );
: null;
const nativeCurrency = network ? network.nativeCurrency : "ETH";
throw refuse( throw refuse(
"This transaction would allow a network fee of up to " + "This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) + formatEther(gasLimit * price) +
+10
View File
@@ -76,6 +76,15 @@ function networkByChainId(chainId) {
return null; return null;
} }
// The native currency of the network with this chain id. A transaction's
// value and fee are labelled with the one of the chain the transaction is on,
// which need not be the active network. `ETH` when the chain id is missing or
// no network here has it.
function nativeCurrencyByChainId(chainId) {
const network = networkByChainId(chainId);
return network ? network.nativeCurrency : "ETH";
}
// Build a block explorer link for the given path type and value. // Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block" // type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) { function explorerLink(network, type, value) {
@@ -89,5 +98,6 @@ module.exports = {
isKnownNetworkId, isKnownNetworkId,
networkById, networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId,
explorerLink, explorerLink,
}; };
+13 -9
View File
@@ -11,6 +11,7 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// The uint8 test every scale in this wallet goes through. Shared, not copied: // The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the // a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one. // same thing here as it does on the screens that refuse to format one.
@@ -28,7 +29,7 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
function parseTx(tx, addrLower, nativeCurrency) { function parseTx(tx, addrLower, chainId) {
const from = tx.from?.hash || ""; const from = tx.from?.hash || "";
const to = tx.to?.hash || ""; const to = tx.to?.hash || "";
const rawWei = tx.value || "0"; const rawWei = tx.value || "0";
@@ -36,7 +37,7 @@ function parseTx(tx, addrLower, nativeCurrency) {
const method = tx.method || null; const method = tx.method || null;
// For contract calls, produce a meaningful label instead of "0.0000 ETH" // For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = nativeCurrency; let symbol = nativeCurrencyByChainId(chainId);
let value = formatTxValue(formatEther(rawWei)); let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
@@ -90,10 +91,11 @@ function parseTx(tx, addrLower, nativeCurrency) {
holders: null, holders: null,
isContractCall: toIsContract, isContractCall: toIsContract,
method: method, method: method,
chainId: chainId,
}; };
} }
function parseTokenTransfer(tt, addrLower) { function parseTokenTransfer(tt, addrLower, chainId) {
const from = tt.from?.hash || ""; const from = tt.from?.hash || "";
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of // The explorer's own answer, or null. Never a default: a transfer of
@@ -139,6 +141,7 @@ function parseTokenTransfer(tt, addrLower) {
// low-holder filter declines to judge a null, so a legitimate token // low-holder filter declines to judge a null, so a legitimate token
// is not hidden because a field went missing upstream. // is not hidden because a field went missing upstream.
holders: parseHoldersCount(tt.token?.holders_count), holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
}; };
} }
@@ -221,12 +224,13 @@ function mergeTransactions(txs, tokenTransfers) {
return merged; return merged;
} }
// `nativeCurrency` is the active network's native token symbol from // `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// networks.js (`ETH`, `SepoliaETH`), which a native entry is labelled with. // carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
async function fetchRecentTransactions( async function fetchRecentTransactions(
address, address,
blockscoutUrl, blockscoutUrl,
nativeCurrency, chainId,
count = 25, count = 25,
) { ) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
@@ -261,10 +265,10 @@ async function fetchRecentTransactions(
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions( const txs = mergeTransactions(
(txJson.items || []).map((tx) => (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
parseTx(tx, addrLower, nativeCurrency), (ttJson.items || []).map((tt) =>
parseTokenTransfer(tt, addrLower, chainId),
), ),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
); );
const result = txs.slice(0, count); const result = txs.slice(0, count);
+144 -6
View File
@@ -6,7 +6,8 @@
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted // ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
// on both networks, through the real Send, confirmation and approval screens, // on both networks, through the real Send, confirmation and approval screens,
// with only the node and the DOM stubbed. So is that a token cannot pass for // with only the node and the DOM stubbed. So is that a token cannot pass for
// the native token by reporting its label. // the native token by reporting its label, and that a transaction's figures
// carry its own network's label when another network is active.
"use strict"; "use strict";
@@ -29,6 +30,9 @@ jest.mock("ethers", () => {
async getTransactionCount() { async getTransactionCount() {
return 1; return 1;
} }
async getTransactionReceipt() {
return { blockNumber: 21000000 };
}
} }
return { return {
...actual, ...actual,
@@ -54,6 +58,21 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Signing a send succeeds without a key, and sending answers with a hash.
jest.mock("../src/shared/vault", () => ({
...jest.requireActual("../src/shared/vault"),
decryptWithPassword: async () => "secret",
}));
jest.mock("../src/shared/wallet", () => ({
...jest.requireActual("../src/shared/wallet"),
getSignerForAddress: () => ({
connect: () => ({
populateTransaction: async (request) => request,
sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }),
}),
}),
}));
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
@@ -137,8 +156,10 @@ const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx"); const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval"); const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const txStatus = require("../src/popup/views/txStatus");
const { balanceLinesForAddress } = require("../src/popup/views/helpers"); const { balanceLinesForAddress } = require("../src/popup/views/helpers");
const { filterTransactions } = require("../src/shared/transactions"); const { filterTransactions } = require("../src/shared/transactions");
const { debugFetch } = require("../src/shared/log");
const HOLDER = "0x" + "a".repeat(40); const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
@@ -165,8 +186,8 @@ async function confirmSend(amount) {
} }
// The approval screen for a dApp transaction sending 0.01 of the native token // The approval screen for a dApp transaction sending 0.01 of the native token
// with 21000 gas at up to 20 gwei, on the active network. // with 21000 gas at up to 20 gwei, on the network with `chainId`.
async function approveTx() { async function approveTx(chainId) {
approvalDetails = { approvalDetails = {
type: "tx", type: "tx",
origin: "https://dapp.example", origin: "https://dapp.example",
@@ -175,7 +196,7 @@ async function approveTx() {
to: RECIPIENT, to: RECIPIENT,
value: "10000000000000000", value: "10000000000000000",
data: "0x", data: "0x",
chainId: NETWORKS[state.networkId].chainId, chainId,
gasLimit: "21000", gasLimit: "21000",
maxFeePerGas: "20000000000", maxFeePerGas: "20000000000",
nonce: 0, nonce: 0,
@@ -221,7 +242,7 @@ describe.each([
await confirmSend("0.1"); await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer"); expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol); expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx(); await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol); expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
}); });
@@ -235,7 +256,7 @@ describe.each([
expect(text("confirm-gas-error")).toContain( expect(text("confirm-gas-error")).toContain(
"You do not have enough " + symbol + " to pay the network fee", "You do not have enough " + symbol + " to pay the network fee",
); );
await approveTx(); await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol); expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
}); });
@@ -301,6 +322,7 @@ describe.each([
isError: false, isError: false,
direction: "received", direction: "received",
directionLabel: "Received", directionLabel: "Received",
chainId: NETWORKS[networkId].chainId,
}; };
transactionDetail.show({ ...entry, contractAddress: null }); transactionDetail.show({ ...entry, contractAddress: null });
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer"); expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
@@ -321,3 +343,119 @@ describe.each([
); );
}); });
}); });
// A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. A site can switch the
// active network after its transaction is prepared and back before it is
// signed, and a popup opened after a switch shows a sent or listed transaction
// again. The wallet's balances follow the active network; these do not.
describe.each([
["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"],
])(
"a %s transaction shown with %s active reads %s",
(txNetworkId, activeNetworkId, symbol) => {
const chainId = NETWORKS[txNetworkId].chainId;
const hash = "0x" + "3".repeat(64);
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = activeNetworkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender() {} });
});
afterEach(() => {
txStatus.endWait();
});
test("the approval screen's value and fee", async () => {
await approveTx(chainId);
expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the wait, success and error screens", async () => {
const txInfo = {
from: HOLDER,
to: RECIPIENT,
amount: "0.0100",
token: "ETH",
tokenSymbol: null,
chainId,
};
txStatus.showWait(txInfo, hash);
expect(text("wait-tx-summary")).toBe("0.0100 " + symbol);
txStatus.showError(txInfo, hash, "Failed.");
expect(text("error-tx-summary")).toBe("0.0100 " + symbol);
// A later popup resumes the wait, and the receipt is there.
state.viewData = {
pendingWait: { txInfo, hash, broadcastTime: Date.now() },
};
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(text("success-tx-summary")).toBe("0.0100 " + symbol);
});
// Sent from the Send screen on the transaction's network, then
// resumed by a popup that opens after the active network changed.
test("the wait screen after a send", async () => {
state.networkId = txNetworkId;
await confirmSend("0.1");
confirmTx.init({});
global.document.getElementById("confirm-tx-password").value = "pw";
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: async () => ({ fee: { value: "21000000000000" } }),
}));
transactionDetail.show({
hash,
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
contractAddress: null,
chainId,
});
expect(text("tx-detail-type")).toBe(
"Native " + symbol + " Transfer",
);
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
expect(
global.document.getElementById("tx-detail-fee").innerHTML,
).toContain("0.000021 " + symbol);
});
},
);
+21 -17
View File
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("queries only the two Blockscout endpoints for the address", async () => { test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []); respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(debugFetch).toHaveBeenCalledTimes(2); expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]); const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain( expect(urls).toContain(
@@ -1283,7 +1283,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
const merged = txs[0]; const merged = txs[0];
// The received leg (the swap output) supplies the display amount. // The received leg (the swap output) supplies the display amount.
@@ -1320,7 +1320,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000"); expect(txs[0].value).toBe("1500.5000");
@@ -1346,7 +1346,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([], [leg("1000000"), leg("2000000")]); respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins. // Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0"); expect(txs[0].exactValue).toBe("2.0");
@@ -1386,7 +1386,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
}); });
@@ -1427,12 +1427,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0"); expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent"); expect(txs[0].direction).toBe("sent");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT); expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
expect(txs[0].chainId).toBe("0x1");
// The surviving row is the token row, and the filters keep it. // The surviving row is the token row, and the filters keep it.
const kept = filterTransactions(txs, filters()).transactions; const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1); expect(kept).toHaveLength(1);
@@ -1452,13 +1453,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([item(6), item(8), item(7)], []); respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH", 2); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in // https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in
// the active network's nativeCurrency, and a native entry carries it. // the chain id of the network the explorer serves. Every entry carries
test("a native entry is labelled with the native token symbol handed in", async () => { // it, and a native entry is labelled with that network's nativeCurrency.
test("a native entry is labelled by the chain id handed in", async () => {
respondWith( respondWith(
[ [
{ {
@@ -1477,16 +1479,18 @@ describe("fetchRecentTransactions merge and dedup", () => {
const sepolia = await fetchRecentTransactions( const sepolia = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"SepoliaETH", "0xaa36a7",
); );
expect(sepolia[0].symbol).toBe("SepoliaETH"); expect(sepolia[0].symbol).toBe("SepoliaETH");
expect(sepolia[0].value).toBe("0.0100"); expect(sepolia[0].value).toBe("0.0100");
expect(sepolia[0].chainId).toBe("0xaa36a7");
const mainnet = await fetchRecentTransactions( const mainnet = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"ETH", "0x1",
); );
expect(mainnet[0].symbol).toBe("ETH"); expect(mainnet[0].symbol).toBe("ETH");
expect(mainnet[0].chainId).toBe("0x1");
}); });
test("the fake token transfer survives fetching and is then filtered", async () => { test("the fake token transfer survives fetching and is then filtered", async () => {
@@ -1509,7 +1513,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
@@ -1551,7 +1555,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"ETH", "0x1",
); );
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
}); });
@@ -1561,7 +1565,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"ETH", "0x1",
); );
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
}); });
@@ -1571,7 +1575,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"ETH", "0x1",
); );
expect(filterTransactions(txs, filters()).transactions).toEqual( expect(filterTransactions(txs, filters()).transactions).toEqual(
txs, txs,
@@ -1587,7 +1591,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
"ETH", "0x1",
); );
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
expect(filterTransactions(txs, filters()).transactions).toEqual([]); expect(filterTransactions(txs, filters()).transactions).toEqual([]);
@@ -1604,7 +1608,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}, },
})); }));
await expect( await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"), fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
).resolves.toEqual([]); ).resolves.toEqual([]);
}); });