diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 3b0cd25..0c624be 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -183,7 +183,7 @@ function render() { if (el) el.classList.add("hidden"); } - loadFullTxDetails(tx.hash, tx.to, tx.chainId); + loadFullTxDetails(tx.hash, tx.chainId); const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = @@ -291,7 +291,7 @@ function populateOnChainDetails(txData, chainId) { } } -async function loadFullTxDetails(txHash, toAddress, chainId) { +async function loadFullTxDetails(txHash, chainId) { const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); @@ -313,7 +313,10 @@ async function loadFullTxDetails(txHash, toAddress, chainId) { const inputData = txData.raw_input || txData.input || null; if (!inputData || inputData === "0x") return; - const decoded = decodeCalldata(inputData, toAddress || ""); + // Decoded against the address the transaction was sent to, as the + // explorer reports it, not the history entry's `to`: for a token + // transfer that is the transfer's recipient, not the token contract. + const decoded = decodeCalldata(inputData, txData.to?.hash || ""); if (decoded) { // Render decoded calldata matching approval view style actionEl.textContent = decoded.name; diff --git a/tests/tokenTransferDetail.test.js b/tests/tokenTransferDetail.test.js new file mode 100644 index 0000000..8a7c829 --- /dev/null +++ b/tests/tokenTransferDetail.test.js @@ -0,0 +1,176 @@ +// The decoded details on the transaction detail screen of a token transfer +// (https://git.eeqj.de/sneak/AutistMask/issues/515). +// +// A history row for a token transfer holds the transfer's recipient as its +// `to`, not the contract the transaction was sent to. The screen used to decode +// the transaction's data against that `to`, so its Token line named the +// recipient, and the amount read "decimals unknown" for a token the wallet +// tracks. It decodes against the address the explorer reports the transaction +// was sent to. +// +// The row is the one the real history parser makes from the explorer's answer, +// opened through transactionDetail.show(), which is what a click on the row +// calls. Driven against a minimal DOM stub in the shape +// tests/contractCreation.test.js uses. + +const { Interface } = require("ethers"); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: async (url) => ({ + ok: true, + json: async () => mockExplorerAnswer(url), + }), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { state } = require("../src/shared/state"); +const { fetchRecentTransactions } = require("../src/shared/transactions"); +const transactionDetail = require("../src/popup/views/transactionDetail"); + +const EXPLORER = "https://explorer.example/api/v2"; +const HOLDER = "0x0000000000000000000000000000000000000a11"; +const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; +// A token the wallet tracks and the bundled token list does not name. +const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631"; +const TX_HASH = + "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; + +// HOLDER sends 1234.5 of the 6-decimal token to RECIPIENT. +const TRANSFER_DATA = new Interface([ + "function transfer(address to, uint256 amount)", +]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); + +// The explorer's answers: the transfer in HOLDER's token transfers, and the +// transaction itself, sent to the token contract. +function mockExplorerAnswer(url) { + if ( + url === + EXPLORER + "/addresses/" + HOLDER + "/token-transfers?type=ERC-20" + ) { + return { + items: [ + { + transaction_hash: TX_HASH, + block_number: 100, + timestamp: "2026-10-01T00:00:00Z", + from: { hash: HOLDER }, + to: { hash: RECIPIENT }, + total: { value: "1234500000", decimals: "6" }, + token: { + address_hash: TOKEN, + symbol: "TDOL", + holders_count: "5000", + }, + }, + ], + }; + } + if (url === EXPLORER + "/transactions/" + TX_HASH) { + return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA }; + } + return { items: [] }; +} + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + style: {}, + dataset: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: () => false, + }, + addEventListener: () => {}, + querySelectorAll: () => [], + appendChild: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug, 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); +} + +// One line of the decoded details: its label and what is drawn under it. +function decodedLine(label) { + return node("tx-detail-calldata-details") + .innerHTML.split('