diff --git a/README.md b/README.md index 7f6847b..11c9658 100644 --- a/README.md +++ b/README.md @@ -2387,7 +2387,9 @@ view would leave a wallet one click from deletion. - Contract: color dot + full address + etherscan link, token symbol label if known; for a contract creation, the same sentence as on WaitTx - Value: amount in ETH (4 decimal places with the nonzero floor, USD in - parentheses) + parentheses), the USD value of the exact amount at the price on the + network the transaction was populated on, as on ConfirmTx: none for a + testnet transaction or one on a chain id that names no network - Network fee (max): gas limit × fee per gas in ETH (4 decimal places with the nonzero floor, USD in parentheses), with the gas limit and the fee per gas in gwei below it diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 0344652..bc3bc1c 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -231,10 +231,11 @@ function showTxFee(approvedTx) { const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice); // Through formatFee(), as the confirmation screen's fee is, so the same // fee reads the same on both. In the native currency of the network shown - // above, as the value is. + // above, and at the price on it, as the value is. $("approve-tx-fee").textContent = formatFee( gasLimit * feePerGas, nativeCurrencyByChainId(approvedTx.chainId), + approvedTx.chainId, ); let detail = @@ -367,11 +368,12 @@ function showTxApproval(details) { $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); } - const ethValueFormatted = formatTxValue( - formatEther(approvedTx.value || "0"), - ); - const ethPrice = getPrice("ETH"); - const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; + const ethValueFormatted = formatTxValue(ethValue); + // Priced from the exact value, not the four-decimal figure, at the price + // on the network the transaction is for, as the confirmation screen prices + // its amount, so the same value reads the same on both. + const ethPrice = getPrice("ETH", approvedTx.chainId); + const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null; 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: the active network can diff --git a/src/shared/prices.js b/src/shared/prices.js index 0c010cd..bd28473 100644 --- a/src/shared/prices.js +++ b/src/shared/prices.js @@ -38,11 +38,12 @@ function clearPrices() { } // 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. +// active network when no chain id is given; null on testnet, on a chain id +// that names no network, or for a symbol with no price. function getPrice(symbol, chainId) { const { currentNetwork } = require("./state"); const network = chainId ? networkByChainId(chainId) : currentNetwork(); - if (network.isTestnet) return null; + if (!network || network.isTestnet) return null; return prices[symbol] || null; } diff --git a/tests/approvalValueUsd.test.js b/tests/approvalValueUsd.test.js new file mode 100644 index 0000000..b8404de --- /dev/null +++ b/tests/approvalValueUsd.test.js @@ -0,0 +1,192 @@ +// The USD value on the approval screen's Value and Network fee lines, for a +// site's ETH send. +// +// It was worked out from the four-decimal figure the line shows, at the price +// on the active network, so the same value read a few cents different here than +// on the confirmation screen, which prices the exact amount at the price on the +// transaction's own network, and a transaction on another network than the +// active one was priced at the active one's rate, its fee too +// (https://git.eeqj.de/sneak/AutistMask/issues/614). +// +// Driven through the real approval screen, against a minimal DOM stub in the +// shape tests/approvalTokenNetwork.test.js uses. + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { state } = require("../src/shared/state"); +const { NETWORKS } = require("../src/shared/networks"); +const { prices, clearPrices } = require("../src/shared/prices"); +const approval = require("../src/popup/views/approval"); + +const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + disabled: false, + style: {}, + dataset: {}, + listeners: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: (name, fn) => { + el.listeners[name] = el.listeners[name] || []; + el.listeners[name].push(fn); + }, + querySelectorAll: () => [], + appendChild: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug, non-testnet popup is in. + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// Open the approval screen, with the network `networkId` active, for the +// site's transaction sending 1.23414 ETH to RECIPIENT on the chain `chainId`. +async function showEthSend(chainId, networkId) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (msg, reply) => { + if (!reply) return; + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: FROM, + approvedTx: { + type: 2, + from: FROM, + chainId, + nonce: "0x7", + gasLimit: "0x5208", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: RECIPIENT, + value: "1234140000000000000", + data: "0x", + accessList: [], + }, + }); + }, + }; + state.networkId = networkId; + state.activeAddress = FROM; + state.wallets = [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [{ address: FROM, index: 0, balance: "0" }], + }, + ]; + state.trackedTokens = []; + state.networkTrackedTokens = {}; + state.currentView = null; + approval.init({}); + await approval.show(1); +} + +beforeEach(() => { + prices.ETH = 2000; +}); + +afterEach(() => { + clearPrices(); +}); + +// 1.23414 ETH reads 1.2341 ETH. At $2,000 the exact value is $2,468.28, the +// figure the confirmation screen shows for it; the shown figure would be +// $2,468.20. +test.each([ + [ + "a mainnet transaction is priced from its exact value", + NETWORKS.mainnet.chainId, + "mainnet", + "1.2341 ETH ($2,468.28)", + ], + [ + "a mainnet transaction shown with Sepolia active is priced at the mainnet price", + NETWORKS.mainnet.chainId, + "sepolia", + "1.2341 ETH ($2,468.28)", + ], + [ + "a Sepolia transaction shown with mainnet active has no USD value", + NETWORKS.sepolia.chainId, + "mainnet", + "1.2341 SepoliaETH", + ], + // Chain id 1337 names no network here, so nothing knows its price. + [ + "a transaction on a chain id that names no network has no USD value", + "0x539", + "mainnet", + "1.2341 ETH", + ], +])("%s", async (_label, chainId, networkId, expected) => { + await showEthSend(chainId, networkId); + expect(state.currentView).toBe("approve-tx"); + expect(node("approve-tx-value").textContent).toBe(expected); +}); + +// The Network fee line is priced on the transaction's network too. 21000 gas at +// up to 2 gwei is 0.000042 ETH, $0.08 at $2,000. +test.each([ + [ + "the fee of a mainnet transaction shown with Sepolia active is priced at the mainnet price", + NETWORKS.mainnet.chainId, + "sepolia", + "0.00004 ETH ($0.08)", + ], + [ + "the fee of a Sepolia transaction shown with mainnet active has no USD value", + NETWORKS.sepolia.chainId, + "mainnet", + "0.00004 SepoliaETH", + ], +])("%s", async (_label, chainId, networkId, expected) => { + await showEthSend(chainId, networkId); + expect(state.currentView).toBe("approve-tx"); + expect(node("approve-tx-fee").textContent).toBe(expected); +});