All checks were successful
check / check (push) Successful in 9s
When a user clicks to copy text (addresses, tx hashes, etc.), the copied element now briefly flashes with inverted colors (bg/fg swap) and fades back over ~300ms. This provides localized visual feedback in addition to the existing flash message. Applied to all click-to-copy elements across all views. closes #100
274 lines
9.9 KiB
JavaScript
274 lines
9.9 KiB
JavaScript
// 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 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);
|
|
}
|
|
|
|
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);
|
|
};
|
|
});
|
|
}
|
|
|
|
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!");
|
|
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 };
|