A transaction with no `to` showed a blank address, with a colour dot whose colour was `undefined`, on the wait, success and error screens, the transaction detail view and the history rows on Home, AddressDetail and AddressToken. The approval screen showed "(contract creation)". All of them now say "This transaction creates a new contract. It has no recipient." The three history lists draw a row's counterparty lines through one helper in helpers.js. A transaction with a real `to` is unchanged. The new test drives each screen and list both ways. Model: opus-5-5
374 lines
13 KiB
JavaScript
374 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,
|
|
attachCopyHandlers,
|
|
copyableHtml,
|
|
etherscanLinkHtml,
|
|
explorerUrl,
|
|
displaySymbol,
|
|
goBack,
|
|
} = 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");
|
|
|
|
/**
|
|
* 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 blockieHtml(address) {
|
|
const src = makeBlockie(address);
|
|
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
|
}
|
|
|
|
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,
|
|
},
|
|
};
|
|
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.to);
|
|
|
|
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) {
|
|
// 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 + " 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),
|
|
);
|
|
}
|
|
|
|
// 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, toAddress) {
|
|
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 += `<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 };
|