The swap decoder read no recipient, so a swap paying its output to another address still showed Min. received. It now reads the address each swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. Min. received shows only when some step pays the sender, none pays another address, none that may pay one went unread, and the output does not stay in the router with no later SWEEP or UNWRAP_WETH paying it out. A payment to the V2 pair the next V2 swap spends as already paid is a hop, not a payout. Otherwise it reads Min. paid out, with Paid to lines. Model: opus-5-5
388 lines
14 KiB
JavaScript
388 lines
14 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, currentNetwork } = 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.
|
|
// On the active network, the one whose explorer was just asked, and
|
|
// from the sender it reports.
|
|
const decoded = decodeCalldata(
|
|
inputData,
|
|
txData.to?.hash || "",
|
|
currentNetwork().chainId,
|
|
txData.from?.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 };
|