All checks were successful
check / check (push) Successful in 6s
## Summary All address rendering now uses a single `renderAddressHtml()` function in helpers.js that produces consistent output everywhere: - Color dot (deterministic from address) - Full address with dashed-underline click-to-copy affordance - Etherscan external link icon ## Changes Refactored all 9 view files that display addresses to use the shared utility: - **approval.js** (approve-tx, approve-sign, approve-site): addresses now have click-to-copy with dashed underline affordance - **confirmTx.js**: from/to addresses and token contract address use shared renderer - **txStatus.js**: wait/success/error transaction addresses - **transactionDetail.js**: from/to and decoded calldata addresses - **home.js**: active address display - **send.js**: from-address display - **receive.js**: receive address display - **addressDetail.js**: address line and export-privkey address - **addressToken.js**: address line and contract info ## Consolidation - `EXT_ICON` SVG constant: removed 6 duplicates, now in helpers.js - `copyableHtml()`: removed duplicate, now in helpers.js - `etherscanLinkHtml()`: removed duplicates, now in helpers.js - `attachCopyHandlers()`: removed duplicate, now in helpers.js - Net: **-193 lines** (174 added, 367 removed) closes #97 Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #129 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
362 lines
12 KiB
JavaScript
362 lines
12 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,
|
|
escapeHtml,
|
|
isoDate,
|
|
timeAgo,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
copyableHtml,
|
|
etherscanLinkHtml,
|
|
} = require("./helpers");
|
|
const { state, currentNetwork } = require("../../shared/state");
|
|
const { formatEther, formatUnits } = require("ethers");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
const { log, debugFetch } = require("../../shared/log");
|
|
const { decodeCalldata } = require("./approval");
|
|
|
|
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 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 txAddressHtml(address, ensName, title) {
|
|
const blockie = blockieHtml(address);
|
|
return (
|
|
`<div class="mb-1">${blockie}</div>` +
|
|
renderAddressHtml(address, { title, ensName })
|
|
);
|
|
}
|
|
|
|
function txHashHtml(hash) {
|
|
const link = `${currentNetwork().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);
|
|
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 = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
|
|
tokenContractEl.innerHTML =
|
|
`<div class="flex items-center">${dot}` +
|
|
copyableHtml(tx.contractAddress, "break-all") +
|
|
etherscanLinkHtml(link) +
|
|
`</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, 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");
|
|
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 = `${currentNetwork().explorerUrl}/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),
|
|
);
|
|
}
|
|
|
|
// 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, 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 += `<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(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);
|
|
}
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
$("btn-tx-back").addEventListener("click", () => {
|
|
if (state.selectedToken) {
|
|
ctx.showAddressToken();
|
|
} else {
|
|
ctx.showAddressDetail();
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show, render };
|