// Transaction detail view — shows full details for a single transaction. // Shared by addressDetail and addressToken via ctx.showTransactionDetail(). const { $, showView, showFlash, flashCopyFeedback, addressDotHtml, addressTitle, escapeHtml, isoDate, timeAgo, } = require("./helpers"); const { state } = require("../../shared/state"); const { formatEther, formatUnits } = require("ethers"); const makeBlockie = require("ethereum-blockies-base64"); const { log, debugFetch } = require("../../shared/log"); const { decodeCalldata } = require("./approval"); const EXT_ICON = `` + `` + `` + `` + ``; let ctx; /** * Determine a human-readable transaction type string from tx fields. */ function getTransactionType(tx) { if (!tx.to) return "Contract Creation"; if (tx.direction === "contract") { if (tx.directionLabel === "Swap") return "Swap"; if ( tx.method === "approve" || tx.directionLabel === "Approve" || tx.method === "setApprovalForAll" ) return "Token Approval"; return "Contract Call"; } if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer"; return "Native ETH Transfer"; } function copyableHtml(text, extraClass) { const cls = "underline decoration-dashed cursor-pointer" + (extraClass ? " " + extraClass : ""); return `${escapeHtml(text)}`; } function blockieHtml(address) { const src = makeBlockie(address); return ``; } function etherscanLinkHtml(url) { return ( `${EXT_ICON}` ); } function txAddressHtml(address, ensName, title) { const blockie = blockieHtml(address); const dot = addressDotHtml(address); const link = `https://etherscan.io/address/${address}`; const extLink = etherscanLinkHtml(link); let html = `
${blockie}
`; if (title) { html += `
${escapeHtml(title)}
`; } if (ensName) { html += `
${dot}` + copyableHtml(ensName, "") + `
` + `
${dot}` + copyableHtml(address, "break-all") + extLink + `
`; } else { html += `
${dot}` + copyableHtml(address, "break-all") + extLink + `
`; } return html; } function txHashHtml(hash) { const link = `https://etherscan.io/tx/${hash}`; const extLink = etherscanLinkHtml(link); return copyableHtml(hash, "break-all") + extLink; } function show(tx) { state.viewData = { tx: { hash: tx.hash, from: tx.from, to: tx.to, value: tx.value, exactValue: tx.exactValue || tx.value, rawAmount: tx.rawAmount || "", rawUnit: tx.rawUnit || "", symbol: tx.symbol, timestamp: tx.timestamp, isError: tx.isError, fromEns: tx.fromEns || null, toEns: tx.toEns || null, directionLabel: tx.directionLabel || null, direction: tx.direction || null, isContractCall: tx.isContractCall || false, method: tx.method || null, contractAddress: tx.contractAddress || null, }, }; render(); } function render() { const tx = state.viewData.tx; if (!tx) return; $("tx-detail-hash").innerHTML = txHashHtml(tx.hash); const fromTitle = addressTitle(tx.from, state.wallets); const toTitle = addressTitle(tx.to, state.wallets); $("tx-detail-from").innerHTML = txAddressHtml( tx.from, tx.fromEns, fromTitle, ); $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); // Exact amount (full precision, copyable) const exactStr = tx.exactValue ? tx.exactValue + " " + tx.symbol : tx.directionLabel + " " + tx.symbol; $("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold"); // Native quantity (raw integer, copyable) const nativeEl = $("tx-detail-native"); if (tx.rawAmount && tx.rawUnit) { const nativeStr = tx.rawAmount + " " + tx.rawUnit; nativeEl.innerHTML = copyableHtml(nativeStr, ""); nativeEl.parentElement.classList.remove("hidden"); } else { nativeEl.innerHTML = ""; nativeEl.parentElement.classList.add("hidden"); } // Always show transaction type as the first field const typeSection = $("tx-detail-type-section"); const typeEl = $("tx-detail-type"); const headingEl = $("tx-detail-heading"); if (typeSection && typeEl) { typeEl.textContent = getTransactionType(tx); typeSection.classList.remove("hidden"); } if (headingEl) headingEl.textContent = "Transaction"; // Token contract address (for ERC-20 transfers) const tokenContractSection = $("tx-detail-token-contract-section"); const tokenContractEl = $("tx-detail-token-contract"); if (tokenContractSection && tokenContractEl) { if (tx.contractAddress) { const dot = addressDotHtml(tx.contractAddress); const link = `https://etherscan.io/token/${tx.contractAddress}`; tokenContractEl.innerHTML = `
${dot}` + copyableHtml(tx.contractAddress, "break-all") + etherscanLinkHtml(link) + `
`; tokenContractSection.classList.remove("hidden"); } else { tokenContractSection.classList.add("hidden"); } } // Hide calldata and raw data sections; always fetch full tx details const calldataSection = $("tx-detail-calldata-section"); if (calldataSection) calldataSection.classList.add("hidden"); const rawDataSection = $("tx-detail-rawdata-section"); if (rawDataSection) rawDataSection.classList.add("hidden"); // Hide on-chain detail sections until populated for (const id of [ "tx-detail-block-section", "tx-detail-nonce-section", "tx-detail-fee-section", "tx-detail-gasprice-section", "tx-detail-gasused-section", ]) { const el = $(id); if (el) el.classList.add("hidden"); } loadFullTxDetails(tx.hash, tx.to, tx.isContractCall); const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; showView("transaction"); document .getElementById("view-transaction") .querySelectorAll("[data-copy]") .forEach((el) => { el.onclick = () => { navigator.clipboard.writeText(el.dataset.copy); showFlash("Copied!"); flashCopyFeedback(el); }; }); } function showDetailField(sectionId, contentId, value) { const section = $(sectionId); const el = $(contentId); if (!section || !el) return; el.innerHTML = copyableHtml(value, ""); section.classList.remove("hidden"); } function populateOnChainDetails(txData) { // Block number if (txData.block_number != null) { const blockLink = `https://etherscan.io/block/${txData.block_number}`; const blockSection = $("tx-detail-block-section"); const blockEl = $("tx-detail-block"); if (blockSection && blockEl) { blockEl.innerHTML = copyableHtml(String(txData.block_number), "") + etherscanLinkHtml(blockLink); blockSection.classList.remove("hidden"); } } // Nonce if (txData.nonce != null) { showDetailField( "tx-detail-nonce-section", "tx-detail-nonce", String(txData.nonce), ); } // Transaction fee const feeWei = txData.fee?.value || txData.tx_fee; if (feeWei) { const feeEth = formatEther(String(feeWei)); showDetailField( "tx-detail-fee-section", "tx-detail-fee", feeEth + " ETH", ); } // Gas price const gasPrice = txData.gas_price; if (gasPrice) { const gwei = formatUnits(String(gasPrice), "gwei"); showDetailField( "tx-detail-gasprice-section", "tx-detail-gasprice", gwei + " Gwei", ); } // Gas used const gasUsed = txData.gas_used; if (gasUsed) { showDetailField( "tx-detail-gasused-section", "tx-detail-gasused", String(gasUsed), ); } // Bind copy handlers for newly added elements for (const id of [ "tx-detail-block-section", "tx-detail-nonce-section", "tx-detail-fee-section", "tx-detail-gasprice-section", "tx-detail-gasused-section", ]) { const section = $(id); if (!section) continue; section.querySelectorAll("[data-copy]").forEach((el) => { el.onclick = () => { navigator.clipboard.writeText(el.dataset.copy); showFlash("Copied!"); flashCopyFeedback(el); }; }); } } async function loadFullTxDetails(txHash, toAddress, isContractCall) { const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); const wellEl = $("tx-detail-calldata-well"); const rawSection = $("tx-detail-rawdata-section"); const rawEl = $("tx-detail-rawdata"); if (!section || !actionEl || !detailsEl) return; try { const resp = await debugFetch( state.blockscoutUrl + "/transactions/" + txHash, ); if (!resp.ok) return; const txData = await resp.json(); // Populate on-chain detail fields (block, nonce, gas, fee) populateOnChainDetails(txData); const inputData = txData.raw_input || txData.input || null; if (!inputData || inputData === "0x") return; const decoded = decodeCalldata(inputData, toAddress || ""); if (decoded) { // Render decoded calldata matching approval view style actionEl.textContent = decoded.name; let detailsHtml = ""; if (decoded.description) { detailsHtml += `
${escapeHtml(decoded.description)}
`; } for (const d of decoded.details || []) { detailsHtml += `
`; detailsHtml += `
${escapeHtml(d.label)}
`; if (d.address && d.isToken) { // Token entry: show symbol on its own line, then dot + address + Etherscan link const dot = addressDotHtml(d.address); const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1]; if (tokenSymbol) { detailsHtml += `
${escapeHtml(tokenSymbol)}
`; } const etherscanUrl = `https://etherscan.io/token/${d.address}`; detailsHtml += `
${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}
`; } else if (d.address) { // Protocol/contract entry: show name + Etherscan link const dot = addressDotHtml(d.address); const etherscanUrl = `https://etherscan.io/address/${d.address}`; detailsHtml += `
${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}
`; } else { detailsHtml += `
${escapeHtml(d.value)}
`; } detailsHtml += `
`; } detailsEl.innerHTML = detailsHtml; if (wellEl) wellEl.classList.remove("hidden"); } else { // Unknown contract call — show method name in well const method = txData.method || "Unknown contract call"; actionEl.textContent = method; detailsEl.innerHTML = ""; if (wellEl) wellEl.classList.remove("hidden"); } // Always show raw data if (rawSection && rawEl) { rawEl.innerHTML = copyableHtml(inputData, "break-all"); rawSection.classList.remove("hidden"); } section.classList.remove("hidden"); // Bind copy handlers for new elements (including raw data now outside section) const copyTargets = [section, rawSection].filter(Boolean); for (const container of copyTargets) { container.querySelectorAll("[data-copy]").forEach((el) => { el.onclick = () => { navigator.clipboard.writeText(el.dataset.copy); showFlash("Copied!"); flashCopyFeedback(el); }; }); } } catch (e) { log.errorf("loadCalldata failed:", e.message); } } function init(_ctx) { ctx = _ctx; $("btn-tx-back").addEventListener("click", () => { if (state.selectedToken) { ctx.showAddressToken(); } else { ctx.showAddressDetail(); } }); } module.exports = { init, show, render };