The screen decoded a transaction's data against the history entry's `to`, which for a token transfer row is the transfer's recipient. The decoded Token line named the recipient, and the amount read "decimals unknown" for a token the wallet tracks. It now decodes against the address the explorer's answer for that transaction says it was sent to, which is the right address for every kind of row. tests/tokenTransferDetail.test.js opens a row made by the real history parser and checks the Token, Recipient and Amount lines. Model: opus-5-5
381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
// Transaction detail view — shows full details for a single transaction.
|
|
// Shared by addressDetail and addressToken via ctx.showTransactionDetail().
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
flashCopyFeedback,
|
|
addressTitle,
|
|
CONTRACT_CREATION_TEXT,
|
|
addressDotHtml,
|
|
escapeHtml,
|
|
isoDate,
|
|
timeAgo,
|
|
renderAddressHtml,
|
|
blockieHtml,
|
|
attachCopyHandlers,
|
|
copyableHtml,
|
|
etherscanLinkHtml,
|
|
explorerUrl,
|
|
displaySymbol,
|
|
goBack,
|
|
} = require("./helpers");
|
|
const { state } = require("../../shared/state");
|
|
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
|
const { formatEther, formatUnits } = require("ethers");
|
|
const { log, debugFetch } = require("../../shared/log");
|
|
const { decodeCalldata } = require("./approval");
|
|
|
|
/**
|
|
* 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";
|
|
}
|
|
// 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.
|
|
if (tx.contractAddress) return "ERC-20 Token Transfer";
|
|
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
|
}
|
|
|
|
function txAddressHtml(address, ensName, title) {
|
|
const blockie = blockieHtml(address);
|
|
return (
|
|
`<div class="mb-1">${blockie}</div>` +
|
|
renderAddressHtml(address, { title, ensName })
|
|
);
|
|
}
|
|
|
|
function txHashHtml(hash) {
|
|
const link = explorerUrl("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,
|
|
// The network the history entry was read from. The type line and
|
|
// the fee are in its native currency, not the active network's:
|
|
// the active network can change, in Settings or when the user
|
|
// approves a site's request, before a later popup shows this
|
|
// screen again.
|
|
chainId: tx.chainId,
|
|
},
|
|
};
|
|
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);
|
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
|
tx.from,
|
|
tx.fromEns,
|
|
fromTitle,
|
|
);
|
|
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
|
if (tx.to) {
|
|
const toTitle = addressTitle(tx.to, state.wallets);
|
|
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
|
} else {
|
|
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
|
}
|
|
|
|
// Exact amount (full precision, copyable)
|
|
const detailSym = displaySymbol(tx.symbol);
|
|
const exactStr = tx.exactValue
|
|
? tx.exactValue + " " + detailSym
|
|
: tx.directionLabel + " " + detailSym;
|
|
$("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 = explorerUrl("token", tx.contractAddress);
|
|
// Hand-rolled rather than renderAddressHtml() because the
|
|
// link goes to the explorer's /token/ page, not /address/.
|
|
// Same two-row shape though: dot and link on the strip, the
|
|
// contract address alone on the row below it.
|
|
tokenContractEl.innerHTML =
|
|
`<div class="flex items-center">${dot}` +
|
|
etherscanLinkHtml(link) +
|
|
`</div>` +
|
|
`<div class="am-address">` +
|
|
copyableHtml(tx.contractAddress) +
|
|
`</div>`;
|
|
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",
|
|
"tx-detail-network-section",
|
|
]) {
|
|
const el = $(id);
|
|
if (el) el.classList.add("hidden");
|
|
}
|
|
|
|
loadFullTxDetails(tx.hash, tx.chainId);
|
|
|
|
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");
|
|
attachCopyHandlers("view-transaction");
|
|
}
|
|
|
|
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, chainId) {
|
|
// Block number
|
|
if (txData.block_number != null) {
|
|
const blockLink = explorerUrl("block", String(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 + " " + nativeCurrencyByChainId(chainId),
|
|
);
|
|
}
|
|
|
|
// 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),
|
|
);
|
|
}
|
|
|
|
// Show the network details wrapper if any child section is visible
|
|
const networkWrapper = $("tx-detail-network-section");
|
|
if (networkWrapper) {
|
|
const hasVisible = [
|
|
"tx-detail-nonce-section",
|
|
"tx-detail-fee-section",
|
|
"tx-detail-gasprice-section",
|
|
"tx-detail-gasused-section",
|
|
].some((id) => {
|
|
const el = $(id);
|
|
return el && !el.classList.contains("hidden");
|
|
});
|
|
if (hasVisible) networkWrapper.classList.remove("hidden");
|
|
}
|
|
|
|
// 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, chainId) {
|
|
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, chainId);
|
|
|
|
const inputData = txData.raw_input || txData.input || null;
|
|
if (!inputData || inputData === "0x") return;
|
|
|
|
// 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;
|
|
let detailsHtml = "";
|
|
if (decoded.description) {
|
|
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
|
}
|
|
for (const d of decoded.details || []) {
|
|
detailsHtml += `<div class="mb-2">`;
|
|
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
|
if (d.address && d.isToken) {
|
|
// Token entry: show symbol on its own line, then address via shared renderer
|
|
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
|
|
if (tokenSymbol) {
|
|
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
|
}
|
|
detailsHtml += renderAddressHtml(d.address);
|
|
} else if (d.address) {
|
|
detailsHtml += renderAddressHtml(d.address);
|
|
} else {
|
|
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
|
}
|
|
detailsHtml += `</div>`;
|
|
}
|
|
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) {
|
|
attachCopyHandlers(container);
|
|
}
|
|
} catch (e) {
|
|
log.errorf("loadCalldata failed:", e.message);
|
|
}
|
|
}
|
|
|
|
// The ctx this view is initialized with is unused: this module is the leaf of
|
|
// the navigation, and the other views reach it through their own ctx.
|
|
function init(_ctx) {
|
|
$("btn-tx-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show, render };
|