All checks were successful
check / check (push) Successful in 22s
The transaction detail view was dynamically changing its title to match the transaction type (e.g. 'Swap' for contract interactions), causing inconsistency with the Screen Map specification. The heading is now always 'Transaction' regardless of type. The action type is still shown in the 'Action' detail section below. Closes #65
270 lines
9.7 KiB
JavaScript
270 lines
9.7 KiB
JavaScript
// Transaction detail view — shows full details for a single transaction.
|
|
// Shared by addressDetail and addressToken via ctx.showTransactionDetail().
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
isoDate,
|
|
timeAgo,
|
|
} = require("./helpers");
|
|
const { state } = require("../../shared/state");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const { log, debugFetch } = require("../../shared/log");
|
|
const { decodeCalldata } = require("./approval");
|
|
|
|
const EXT_ICON =
|
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
|
`</svg></span>`;
|
|
|
|
let ctx;
|
|
|
|
function copyableHtml(text, extraClass) {
|
|
const cls =
|
|
"underline decoration-dashed cursor-pointer" +
|
|
(extraClass ? " " + extraClass : "");
|
|
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
|
|
}
|
|
|
|
function blockieHtml(address) {
|
|
const src = makeBlockie(address);
|
|
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
|
}
|
|
|
|
function etherscanLinkHtml(url) {
|
|
return (
|
|
`<a href="${url}" target="_blank" rel="noopener" ` +
|
|
`class="inline-flex items-center"` +
|
|
`>${EXT_ICON}</a>`
|
|
);
|
|
}
|
|
|
|
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 = `<div class="mb-1">${blockie}</div>`;
|
|
if (title) {
|
|
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
|
|
}
|
|
if (ensName) {
|
|
html +=
|
|
`<div class="flex items-center">${dot}` +
|
|
copyableHtml(ensName, "") +
|
|
`</div>` +
|
|
`<div class="flex items-center">${dot}` +
|
|
copyableHtml(address, "break-all") +
|
|
extLink +
|
|
`</div>`;
|
|
} else {
|
|
html +=
|
|
`<div class="flex items-center">${dot}` +
|
|
copyableHtml(address, "break-all") +
|
|
extLink +
|
|
`</div>`;
|
|
}
|
|
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,
|
|
},
|
|
};
|
|
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");
|
|
}
|
|
|
|
// Show type label for contract interactions (Swap, Execute, etc.)
|
|
const typeSection = $("tx-detail-type-section");
|
|
const typeEl = $("tx-detail-type");
|
|
const headingEl = $("tx-detail-heading");
|
|
if (tx.direction === "contract" && tx.directionLabel) {
|
|
if (typeSection) {
|
|
typeEl.textContent = tx.directionLabel;
|
|
typeSection.classList.remove("hidden");
|
|
}
|
|
} else {
|
|
if (typeSection) typeSection.classList.add("hidden");
|
|
}
|
|
if (headingEl) headingEl.textContent = "Transaction";
|
|
|
|
// Hide calldata and raw data sections; re-fetch if this is a contract call
|
|
const calldataSection = $("tx-detail-calldata-section");
|
|
if (calldataSection) calldataSection.classList.add("hidden");
|
|
const rawDataSection = $("tx-detail-rawdata-section");
|
|
if (rawDataSection) rawDataSection.classList.add("hidden");
|
|
|
|
if (tx.isContractCall || tx.direction === "contract") {
|
|
loadCalldata(tx.hash, tx.to);
|
|
}
|
|
|
|
$("tx-detail-time").textContent =
|
|
isoDate(tx.timestamp) + " (" + 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!");
|
|
};
|
|
});
|
|
}
|
|
|
|
async function loadCalldata(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();
|
|
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 dot + address + Etherscan link
|
|
const dot = addressDotHtml(d.address);
|
|
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
|
|
if (tokenSymbol) {
|
|
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
|
|
}
|
|
const etherscanUrl = `https://etherscan.io/token/${d.address}`;
|
|
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
|
|
} 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 += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
|
|
} 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) {
|
|
container.querySelectorAll("[data-copy]").forEach((el) => {
|
|
el.onclick = () => {
|
|
navigator.clipboard.writeText(el.dataset.copy);
|
|
showFlash("Copied!");
|
|
};
|
|
});
|
|
}
|
|
} 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 };
|