// 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('
') .find((line) => line.startsWith(`
${label}
`), ); } beforeEach(() => { globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; state.blockscoutUrl = EXPLORER; state.wallets = [ { name: "Main", type: "key", addresses: [{ address: HOLDER, balance: "0.0000" }], }, ]; state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }]; state.viewData = {}; state.viewStack = []; state.currentView = null; }); test("a token transfer's decoded details name the token contract and the amount at its scale", async () => { const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1); expect(row.to).toBe(RECIPIENT); expect(row.contractAddress).toBe(TOKEN.toLowerCase()); transactionDetail.show(row); // The decoded details are drawn once the explorer has answered. for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); expect(node("tx-detail-calldata-action").textContent).toBe( "Token Transfer", ); const token = decodedLine("Token"); expect(token).toContain('
TDOL
'); expect(token).toContain(TOKEN); expect(token).not.toContain(RECIPIENT); expect(decodedLine("Recipient")).toContain(RECIPIENT); expect(decodedLine("Amount")).toContain( '
1234.5000 TDOL
', ); });