A connected site's wallet_switchEthereumChain request for the other supported network now opens a prompt in its own window, through the existing approval machinery, naming the site and both networks. The network, endpoints, balances and caches change, and chainChanged is sent, only when the user approves it; rejecting or closing the prompt answers 4001. One such prompt per site at a time; a request for the active network needs none. The approval window no longer shows the connection prompt while it waits for the approval's description, since both prompts answer on the same port. Model: opus-5-5
378 lines
13 KiB
JavaScript
378 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,
|
|
blockieHtml,
|
|
attachCopyHandlers,
|
|
copyableHtml,
|
|
etherscanLinkHtml,
|
|
explorerUrl,
|
|
displaySymbol,
|
|
goBack,
|
|
} = require("./helpers");
|
|
const { state } = 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.to, 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, toAddress, 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;
|
|
|
|
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 };
|