diff --git a/src/Address.jsx b/src/Address.jsx index 77227df..505af23 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -1,45 +1,52 @@ import React from "react"; -import { Typography, Popover } from "@mui/material"; +import { Typography, Popover, Link } from "@mui/material"; import Chip from "@mui/material/Chip"; import { shortAddress } from "./utils"; +import { explorerAddressUrl } from "./chain-utils"; +import { useSafe } from "./safe-ui"; function Address({ displayName = null, address }) { const [popAnchor, setPopAnchor] = React.useState(null); - - const handlePopoverOpen = (event) => { - setPopAnchor(event.currentTarget); - }; - - const handlePopoverClose = () => { - setPopAnchor(null); - }; + const { chainId } = useSafe(); const popOpen = Boolean(popAnchor); + const url = explorerAddressUrl(chainId, address); return ( <> setPopAnchor(e.currentTarget)} + onDelete={undefined} + variant="outlined" /> setPopAnchor(null)} disableRestoreFocus > - {address} + + {url ? ( + setPopAnchor(null)} + > + {address} + + ) : ( + address + )} + ); diff --git a/src/SearchPage.jsx b/src/SearchPage.jsx index deb48cf..ec57b01 100644 --- a/src/SearchPage.jsx +++ b/src/SearchPage.jsx @@ -1,26 +1,18 @@ import * as React from "react"; import { Box, Stack, TextField, MenuItem, Button, Paper, Typography, Link } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; + import { useSafe } from "./safe-ui"; -import { getTransactionDetailsByKey } from "./safe-api"; +import { getChangesetAndSafeTxHashByKey, getSafeTransaction } from "./safe-api"; +import { chainPrefixFromId } from "./chain-utils"; +import TransactionCard from "./TransactionCard"; const KEY_SEARCH = "tx-search"; -function chainPrefixFromId(chainId) { - switch (Number(chainId)) { - case 1: - return "eth"; - case 137: - return "matic"; - case 42161: - return "arb1"; - default: - return String(chainId); - } -} - export default function SearchPage() { - const { chainId, safeAddress } = useSafe(); + const safe = useSafe(); + const { chainId, safeAddress } = safe; + const [mode, setMode] = React.useState("safeTxHash"); const [searchText, setSearchText] = React.useState(""); const [submittedQuery, setSubmittedQuery] = React.useState(null); @@ -33,16 +25,19 @@ export default function SearchPage() { enabled: hasSubmitted, queryFn: async () => { if (!submittedQuery) return null; - const changeset = await getTransactionDetailsByKey( - { chainId, safeAddress }, - { type: submittedQuery.mode, key: submittedQuery.text.trim() } - ); - const safeTxHash = - submittedQuery.mode === "safeTxHash" - ? submittedQuery.text.trim() - : changeset?.safeTxHash || changeset?.safe_tx_hash || submittedQuery.text.trim(); - return { safeTxHash, changeset }; + + const base = await getChangesetAndSafeTxHashByKey(safe, { + type: submittedQuery.mode, + key: submittedQuery.text, + }); + + if (!base?.safeTxHash) return null; + + const txMeta = await getSafeTransaction(safe, base.safeTxHash); + + return { ...base, txMeta }; }, + refetchOnWindowFocus: false, }); function onSubmit(e) { @@ -56,6 +51,7 @@ export default function SearchPage() { ? `https://app.safe.global/transactions/tx?safe=${chainPrefix}:${safeAddress}` : null; + const isSearching = hasSubmitted && query.fetchStatus === "fetching"; return ( @@ -89,7 +85,7 @@ export default function SearchPage() { - {hasSubmitted && query.fetchStatus === "fetching" && Searching…} + {isSearching && Searching…} {hasSubmitted && query.isError && ( @@ -112,13 +108,14 @@ export default function SearchPage() { )} - {hasSubmitted && query.isSuccess && ( - - - Changeset for safeTxHash {query.data.safeTxHash} - -
{JSON.stringify(query.data.changeset, null, 2)}
-
+ {!isSearching && hasSubmitted && query.isSuccess && query.data?.changeset && ( + )}
); diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 5f649e3..7272788 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -1,21 +1,957 @@ import React from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { Accordion, AccordionDetails, AccordionSummary, Grid, Paper, Stack, Typography } from "@mui/material"; +import LaunchIcon from "@mui/icons-material/Launch"; + +import { + Accordion, + AccordionDetails, + AccordionSummary, + Paper, + Stack, + Typography, + Skeleton, + Box, + Chip, + Button, + Collapse, + Link as MuiLink, +} from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import { KEY_TRANSACTION_DETAILS, KEY_ADDRESS_BOOK, getAddressBook, getTransactionDetails } from "./safe-api"; +import { chainPrefixFromId } from "./chain-utils"; import Address from "./Address"; import { useWallet } from "./wallet"; import { useSafe } from "./safe-ui"; import WalletActionButton from "./WalletActionButton"; -function TransactionCard({ transaction, onConfirm, readOnly = false }) { - const txKey = transaction.safeTxHash || transaction.transactionHash || transaction.txHash; +function truncateMiddle(str, visible = 6) { + if (!str) return ""; + if (str.length <= visible * 2 + 3) return str; + return `${str.slice(0, visible)}…${str.slice(-visible)}`; +} + +function isHexAddress(v) { + return typeof v === "string" && /^0x[a-fA-F0-9]{40}$/.test(v); +} + +function safeJsonStringify(v, space = 2) { + try { + return JSON.stringify(v, null, space); + } catch { + return String(v); + } +} + +function asBigIntMaybe(v) { + if (v == null) return null; + if (typeof v === "bigint") return v; + + if (typeof v === "number" && Number.isFinite(v)) { + return BigInt(Math.trunc(v)); + } + + if (typeof v === "string") { + const s = v.trim(); + if (!s) return null; + try { + if (s.startsWith("0x") || s.startsWith("0X")) return BigInt(s); + if (/^[0-9]+$/.test(s)) return BigInt(s); + } catch { + return null; + } + } + + return null; +} + +function formatUnitsDecimal(raw, decimals) { + const bi = asBigIntMaybe(raw); + const d = Number(decimals); + if (bi == null || !Number.isFinite(d) || d < 0) return null; + + const neg = bi < 0n; + const abs = neg ? -bi : bi; + + const s = abs.toString(); + const whole = s.length > d ? s.slice(0, -d) : "0"; + const fracRaw = s.length > d ? s.slice(-d) : s.padStart(d, "0"); + const frac = fracRaw.replace(/0+$/, ""); + + return `${neg ? "-" : ""}${whole}${frac ? "." + frac : ""}`; +} + +function inferDecimalsFromStep(step) { + return ( + step?.decimals ?? step?.tokenDecimals ?? step?.token_decimals ?? step?.assetDecimals ?? step?.asset_decimals ?? null + ); +} + +function renderAmountWithRaw({ value, token, step }) { + const isObj = value && typeof value === "object" && !Array.isArray(value); + + const raw = isObj ? value.raw ?? value.value ?? value.amount ?? null : value; + const rawStr = raw != null ? String(raw) : ""; + + const decimals = isObj + ? value.decimals ?? value.tokenDecimals ?? value.token_decimals ?? inferDecimalsFromStep(step) + : inferDecimalsFromStep(step); + + const pretty = + (isObj ? value.formatted ?? value.pretty ?? null : null) || + (decimals != null ? formatUnitsDecimal(raw, decimals) : null); + + if (pretty) { + return ( + <> + {pretty} + {token ? ` ${token}` : ""} + {rawStr && ( + + {` (raw: ${rawStr})`} + + )} + + ); + } + + return ( + <> + {rawStr || "-"} + {token ? ` ${token}` : ""} + + ); +} + +function cleanTypeLabel(t) { + if (!t) return null; + return String(t).trim().replace(/\s+/g, " "); +} + +function buildChangesetTransforms({ addresses, addressBook }) { + const getAddressFromKeyOrAddress = (value, preferLabel = null) => { + const resolved = (addresses && value != null ? addresses[value] : null) || value; + if (isHexAddress(resolved)) { + const displayName = preferLabel || addressBook?.[resolved]; + return { kind: "address", value: resolved, displayName }; + } + return { kind: "text", value: resolved != null ? String(resolved) : "" }; + }; + + const getRole = (value) => { + if (value && typeof value === "object") { + const role = value.role ?? value.key ?? value.value ?? value.name ?? value.id; + return { kind: "text", value: role != null ? String(role) : safeJsonStringify(value, 0) }; + } + return { kind: "text", value: value != null ? String(value) : "" }; + }; + + const getComponentRole = (value) => { + if (value && typeof value === "object") { + const component = value.component ?? value.address_key ?? value.addressKey ?? value.addr ?? value.address; + const role = value.role ?? value.key ?? value.value ?? value.name ?? value.id; + const comp = component != null ? getAddressFromKeyOrAddress(component) : null; + const roleTxt = role != null ? String(role) : ""; + if (comp?.kind === "address") return { kind: "text", value: `${comp.value}:${roleTxt}` }; + return { kind: "text", value: `${String(component)}:${roleTxt}` }; + } + return { kind: "text", value: safeJsonStringify(value, 0) }; + }; + + const getEnumValue = (value, parsedValue = null) => { + if (value && typeof value === "object") { + const enumName = value.enum || value.name || value.type; + const key = value.key ?? value.value ?? value.id ?? value.label; + if (enumName && key != null) { + const suffix = parsedValue != null && parsedValue !== "" ? ` (${String(parsedValue)})` : ""; + return { kind: "text", value: `${enumName}.${String(key)}${suffix}` }; + } + if (key != null) return { kind: "text", value: String(key) }; + return { kind: "text", value: safeJsonStringify(value, 0) }; + } + return { kind: "text", value: value != null ? String(value) : "" }; + }; + + const getBucket = (value) => { + const label = value != null ? String(value) : ""; + return { kind: "text", value: label }; + }; + + return { + address_key: (value) => getAddressFromKeyOrAddress(value, typeof value === "string" ? value : null), + role: (value) => getRole(value), + component_role: (value) => getComponentRole(value), + enum: (value, parsedValue = null) => getEnumValue(value, parsedValue), + bucket: (value, parsedValue = null) => getBucket(value, parsedValue), + }; +} + +function transformArgumentSpec(spec, transforms, parsedValue = null) { + if (!spec || typeof spec !== "object") return null; + const t = spec.transform || spec.type || null; + const v = spec.value !== undefined ? spec.value : spec; + + if (t && transforms[t]) return transforms[t](v, parsedValue); + + if (typeof v === "string" && isHexAddress(v)) return { kind: "address", value: v }; + if (Array.isArray(v)) return { kind: "list", value: v.map((x) => ({ kind: "text", value: String(x) })) }; + if (v && typeof v === "object") return { kind: "json", value: v }; + return { kind: "text", value: v != null ? String(v) : "" }; +} + +function renderTransformedValue(v) { + if (!v) return null; + + if (v.kind === "address") { + return
; + } + + if (v.kind === "list") { + const items = Array.isArray(v.value) ? v.value : []; + return ( + + {items.map((it, idx) => ( + {renderTransformedValue(it)} + ))} + + ); + } + + const commonSx = { + fontFamily: "monospace", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + display: "block", + }; + + if (v.kind === "json") { + return ( + + {safeJsonStringify(v.value, 2)} + + ); + } + + return ( + + {String(v.value)} + + ); +} + +function getFunctionAbi(abis, contractType, fnName) { + if (!abis || !contractType || !fnName) return null; + return abis?.[contractType]?.[fnName] || null; +} + +function inferSignatureTypes({ abiInputs, argsSpec, parsedArgs }) { + const n = Math.max( + Array.isArray(abiInputs) ? abiInputs.length : 0, + Array.isArray(argsSpec) ? argsSpec.length : 0, + Array.isArray(parsedArgs) ? parsedArgs.length : 0 + ); + + const out = []; + for (let i = 0; i < n; i++) { + const spec = Array.isArray(argsSpec) ? argsSpec[i] : null; + const parsed = Array.isArray(parsedArgs) ? parsedArgs[i] : null; + const inp = Array.isArray(abiInputs) ? abiInputs[i] : null; + + const specT = spec && typeof spec === "object" ? spec.transform || spec.type : null; + + if (specT === "enum") { + const enumName = spec?.value?.enum || spec?.value?.name; + out.push(cleanTypeLabel(enumName || "enum")); + continue; + } + if (specT === "bucket") { + out.push("bucket"); + continue; + } + if (specT === "address_key") { + out.push("address"); + continue; + } + + const abiType = cleanTypeLabel(inp?.type); + if (abiType) { + out.push(abiType); + continue; + } + + if (typeof parsed === "string" && isHexAddress(parsed)) { + out.push("address"); + continue; + } + + out.push(null); + } + + return out.filter(Boolean); +} + +function formatMethodSignature(methodName, types) { + if (!methodName) return null; + const t = Array.isArray(types) ? types.map(cleanTypeLabel).filter(Boolean) : []; + return `${methodName}(${t.join(", ")})`; +} + +function defaultNameFromSpec(spec, i) { + const t = spec && typeof spec === "object" ? spec.transform || spec.type : null; + if (t === "bucket") return "bucket"; + if (t === "address_key") return "address"; + if (t === "role") return "role"; + if (t === "component_role") return "component_role"; + if (t === "enum") return spec?.value?.enum || "enum"; + if (t) return String(t); + return `arg${i}`; +} + +function isAmountArg(name, type, spec) { + const n = String(name || "").toLowerCase(); + const t = String(type || "").toLowerCase(); + const st = String(spec?.transform || spec?.type || "").toLowerCase(); + + if (n === "amount" || n.endsWith("_amount")) return true; + if (t === "amount" || st === "amount") return true; + + if ((t.includes("uint") || t.includes("int")) && n.includes("amount")) return true; + + return false; +} + +function buildArgumentRows({ abiInputs, argsSpec, parsedArgs, transforms }) { + const n = Math.max( + Array.isArray(abiInputs) ? abiInputs.length : 0, + Array.isArray(argsSpec) ? argsSpec.length : 0, + Array.isArray(parsedArgs) ? parsedArgs.length : 0 + ); + + const rows = []; + for (let i = 0; i < n; i++) { + const inp = Array.isArray(abiInputs) ? abiInputs[i] : null; + const spec = Array.isArray(argsSpec) ? argsSpec[i] : null; + const parsed = Array.isArray(parsedArgs) ? parsedArgs[i] : null; + + const name = + (inp?.name && String(inp.name).trim()) || + (spec?.name && String(spec.name).trim()) || + (spec && typeof spec === "object" ? defaultNameFromSpec(spec, i) : `arg${i}`); + + const type = + cleanTypeLabel(inp?.type) || + cleanTypeLabel(spec?.type) || + (spec?.transform ? cleanTypeLabel(spec.transform) : null); + + let tv = null; + if (spec && typeof spec === "object") { + tv = transformArgumentSpec(spec, transforms, parsed); + } else if (parsed !== null && parsed !== undefined) { + if (typeof parsed === "string" && isHexAddress(parsed)) tv = { kind: "address", value: parsed }; + else tv = { kind: "text", value: String(parsed) }; + } else if (spec != null) { + tv = { kind: "text", value: String(spec) }; + } else { + tv = { kind: "text", value: "" }; + } + + const isAmount = isAmountArg(name, type, spec || {}); + const amountValue = + parsed !== null && parsed !== undefined + ? parsed + : spec && typeof spec === "object" && spec.value !== undefined + ? spec.value + : tv?.kind === "text" + ? tv.value + : null; + + rows.push({ key: `${i}-${name}`, name, type, tv, isAmount, amountValue }); + } + + return rows; +} + +function buildSteps(txDetails) { + if (!txDetails) return []; + + const batch = txDetails.batch; + if (batch && Array.isArray(batch.targets) && batch.targets.length) { + const targets = batch.targets || []; + const values = batch.values || []; + const payloads = batch.payloads || []; + const asArgs = batch.asArgs || []; + + return targets.map((target, index) => { + const value = values[index]; + const payload = payloads[index]; + const args = asArgs[index]; + let method = null; + if (args && typeof args === "object") { + method = + args.method || + args.function || + args.fn || + args.name || + args.selector || + args.signature || + args.contractMethod || + args.contract_method || + null; + } + + return { + id: `batch-${index}`, + title: `Call #${index + 1}`, + to: target, + value, + token: null, + summary: payload ? `Calldata: ${truncateMiddle(payload, 10)}` : null, + rawArgs: args, + method, + }; + }); + } + + const rawSteps = Array.isArray(txDetails.steps) ? txDetails.steps : null; + if (rawSteps && rawSteps.length) return rawSteps; + + const summary = txDetails.summary || null; + const description = txDetails.description || null; + + const title = summary || description || "Transaction"; + + const to = txDetails.to || txDetails.target || null; + const value = txDetails.value ?? null; + const token = txDetails.tokenSymbol || (txDetails.token && txDetails.token.symbol) || null; + + const bodySummary = + summary && summary !== title ? summary : description && description !== title ? description : null; + + return [ + { + id: "main", + title, + to, + value, + token, + summary: bodySummary, + }, + ]; +} + +function StepItem({ index, step, addressBook, addresses, abis }) { + const title = step.title || step.name || step.action || step.description || `Step ${index + 1}`; + const to = step.to || step.recipient || step.target || null; + const value = step.value ?? step.amount ?? null; + const token = step.token || step.tokenSymbol || step.asset || null; + const summary = step.summary || null; + const displayName = to && addressBook ? addressBook[to] : undefined; + + const methodName = + step.method || + step.function || + step.functionName || + step.selector || + step.signature || + step.contractMethod || + step.contract_method || + (step.rawArgs && typeof step.rawArgs === "object" + ? step.rawArgs.method || + step.rawArgs.function || + step.rawArgs.fn || + step.rawArgs.name || + step.rawArgs.selector || + step.rawArgs.signature + : null); + + const contractAddressKey = step.contract_address_key || step.contractAddressKey || null; + const contractType = step.contract_type || step.contractType || null; + + const contractTarget = (step.contract && step.contract.target) || step.contract_target || null; + const resolvedContractAddress = + contractTarget || (contractAddressKey && addresses && addresses[contractAddressKey]) || null; + const resolvedContractName = + resolvedContractAddress && addressBook ? addressBook[resolvedContractAddress] : undefined; + + const argsSpec = Array.isArray(step.arguments) ? step.arguments : null; + const parsedArgs = Array.isArray(step.parsedArguments) + ? step.parsedArguments + : Array.isArray(step.parsed_args) + ? step.parsed_args + : null; + + const fnAbi = getFunctionAbi(abis, contractType, methodName); + const abiInputs = Array.isArray(fnAbi?.inputs) ? fnAbi.inputs : []; + + const transforms = React.useMemo( + () => buildChangesetTransforms({ addresses, addressBook }), + [addresses, addressBook] + ); + + const signatureTypes = inferSignatureTypes({ abiInputs, argsSpec, parsedArgs }); + const signature = methodName ? formatMethodSignature(methodName, signatureTypes) : null; + const signatureClean = methodName ? signature || `${methodName}()` : null; + + const rows = buildArgumentRows({ abiInputs, argsSpec, parsedArgs, transforms }); + + return ( + `1px solid ${theme.palette.divider}`, + backgroundColor: "rgba(255,255,255,0.02)", + }} + > + + `1px solid ${theme.palette.primary.main}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "0.75rem", + fontWeight: 600, + color: "primary.main", + flexShrink: 0, + mt: 0.1, + }} + > + {index + 1} + + + + + {title} + + + {summary && ( + + {summary} + + )} + + + {to && ( + + To:
+ + )} + + {contractAddressKey && ( + + Contract key:{" "} + + {contractAddressKey} + {contractType ? ` (${contractType})` : ""} + + + )} + + {resolvedContractAddress && ( + + Contract addr:
+ + )} + + {signatureClean && ( + + Method:{" "} + + {signatureClean} + + + )} + + {value != null && ( + + Amount:{" "} + + {renderAmountWithRaw({ value, token, step })} + + + )} + + {rows.length > 0 && ( + + + Arguments: + + + + {rows.map((r) => { + const kind = r.tv?.kind; + const isMultiline = kind === "json" || kind === "list"; + + return ( + + + + {r.name}{" "} + + ({r.type || "raw"}) + + : + + + + + + {r.isAmount ? ( + + {renderAmountWithRaw({ value: r.amountValue, token, step })} + + ) : ( + renderTransformedValue(r.tv) + )} + + + + ); + })} + + + )} + + + + + ); +} + +function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress, txKey, addressBook }) { + const [showYaml, setShowYaml] = React.useState(false); + const [showJson, setShowJson] = React.useState(false); + + const steps = buildSteps(txDetails); + const chainPrefix = chainPrefixFromId(chainId); + const baseSafeUrl = + chainPrefix && safeAddress ? `https://app.safe.global/transactions/tx?safe=${chainPrefix}:${safeAddress}` : null; + + const safeIdParam = safeTxHash ? safeTxHash : txKey && safeAddress ? `multisig_${safeAddress}_${txKey}` : null; + const safeUrl = baseSafeUrl && safeIdParam ? `${baseSafeUrl}&id=${safeIdParam}` : null; + + const networks = txDetails?.networks || txDetails?.network || null; + const via = txDetails?.via || null; + const timelock = txDetails?.timelock || null; + const contract = txDetails?.contract || null; + + const viaAddress = via?.address; + const viaType = via?.type; + const viaDisplayName = viaAddress && addressBook ? addressBook[viaAddress] : undefined; + + const timelockRelayAddress = timelock?.execution_relay?.address; + const timelockRelayDisplayName = timelockRelayAddress && addressBook ? addressBook[timelockRelayAddress] : undefined; + + const contractTarget = contract?.target; + const contractTargetName = contractTarget && addressBook ? addressBook[contractTarget] : undefined; + + const hasTimelockBadge = + !!timelock && ((timelock.delay && String(timelock.delay) !== "min") || !!timelock.execution_relay); + + const rawYaml = txDetails?.original_yaml || ""; + const rawJson = JSON.stringify(txDetails || {}, null, 2); + + const showTxMeta = Boolean(transaction); + + const confirmationsCount = transaction?.confirmations?.length ?? 0; + const confirmationsRequired = transaction?.confirmationsRequired ?? null; + + const signers = Array.isArray(transaction?.confirmations) ? transaction.confirmations : []; + + const commitRef = txDetails?.commit_ref; + const originalPath = txDetails?.original_path; + const changesetSourceUrl = + commitRef && originalPath ? `https://github.com/ensuro/deploy-scripts/blob/${commitRef}/${originalPath}` : null; + + return ( + + + Transaction details + + + + {showTxMeta && ( + <> + + Nonce:{" "} + + {transaction.nonce} + + + + + Confirmations:{" "} + + {confirmationsRequired != null + ? `${confirmationsCount}/${confirmationsRequired}` + : String(confirmationsCount)} + + + + {transaction.modified && ( + + Modified:{" "} + + {transaction.modified} + + + )} + + )} + + {networks && ( + + + Networks: + + {Array.isArray(networks) ? ( + networks.map((n) => ( + + )) + ) : ( + + )} + + )} + + {viaAddress && ( + + Via ({viaType || "via"}):
+ + )} + + {timelock && ( + + + Timelock: delay:{" "} + + {String(timelock.delay)} + + {timelockRelayAddress && ( + <> + {" "} + · relay:
+ + )} + + {hasTimelockBadge && ( + + )} + + )} + + {contractTarget && ( + + Contract target:
+ + )} + + + + Steps + + + {steps.length ? ( + + {steps.map((step, index) => ( + + ))} + + ) : ( + + No structured steps were provided for this transaction. + + )} + + {showTxMeta && ( + + + Signers: + + + {signers.length === 0 && ( + + No signatures yet + + )} + {signers.map((signer) => ( +
+ ))} + + + )} + + + + {safeUrl && ( + + )} + + {changesetSourceUrl && ( + + )} + + + + + + + + + + + {rawYaml} + + + + + + {rawJson} + + + + ); +} + +function TransactionCard({ + transaction, + onConfirm, + readOnly = false, + variant, + txDetails: txDetailsProp, + safeTxHash: safeTxHashProp, +}) { + const hasTransaction = !!transaction; + const { curAccount } = useWallet(); + const { owners, chainId, safeAddress } = useSafe(); + + const txKey = hasTransaction + ? transaction.safeTxHash || transaction.transactionHash || transaction.txHash + : safeTxHashProp; + + const shouldFetchDetails = !txDetailsProp && hasTransaction && !!transaction.safeTxHash; + const txDetailsResponse = useQuery({ queryKey: [KEY_TRANSACTION_DETAILS, txKey], - enabled: !!transaction.safeTxHash, + enabled: shouldFetchDetails, queryFn: async () => getTransactionDetails(transaction.safeTxHash), retry: (failureCount, err) => { if (readOnly && err?.status === 404) return false; @@ -28,26 +964,61 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) { queryFn: getAddressBook, staleTime: Infinity, gcTime: Infinity, + enabled: hasTransaction, }); - const { curAccount } = useWallet(); - const { owners, chainId, safeAddress } = useSafe(); + const addressBook = addressBookResponse.data || {}; + const txDetails = txDetailsProp || txDetailsResponse.data; - function chainPrefixFromId(chainId) { - switch (Number(chainId)) { - case 1: - return "eth"; - case 137: - return "matic"; - case 42161: - return "arb1"; - default: - return String(chainId); - } + const handleApproveClick = React.useCallback( + (event) => { + event.stopPropagation(); + event.preventDefault(); + if (onConfirm) onConfirm(); + }, + [onConfirm] + ); + + if (!hasTransaction || variant === "panel") { + if (!txDetails) return null; + return ( + + ); + } + + if (shouldFetchDetails && txDetailsResponse.isPending) { + return ( + + }> + + + + + + + + + + + + + + + + + + ); } - const isLoading = !!txKey && (txDetailsResponse.fetchStatus === "fetching" || txDetailsResponse.isPending); - if (isLoading) return
Loading...
; - if (txDetailsResponse.isError) { + + if (shouldFetchDetails && txDetailsResponse.isError) { if (txDetailsResponse.error?.status === 404) { const chainPrefix = chainPrefixFromId(chainId); const safeIdPart = `multisig_${safeAddress}_${transaction.safeTxHash}`; @@ -58,126 +1029,86 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) { {txKey} - - - - - Nonce: {transaction.nonce}
- Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
- Modified: {transaction.modified} -
-
-
- - - - - - Signers - - - - {transaction.confirmations?.length === 0 && No signatures yet} - {transaction.confirmations?.map((signer) => ( -
- ))} - - - - - - - - - - Transaction Details - - - No changeset found for transaction{" "} - - {transaction.safeTxHash || txKey} - - - - - + + + No changeset found for transaction{" "} + + {transaction.safeTxHash || txKey} + + + ); } - return
Error: {String(txDetailsResponse.error?.message || txDetailsResponse.error)}
; + return ( + + + Error: {String(txDetailsResponse.error?.message || txDetailsResponse.error)} + + + ); } - const txDetails = txDetailsResponse.data; - - const isOwner = owners.map((o) => o?.toLowerCase()).includes(curAccount); + const safeOwners = owners || []; + const isOwner = safeOwners.map((o) => o?.toLowerCase()).includes(curAccount); const alreadySigned = transaction.confirmations .map((c) => c.owner?.toLowerCase()) .includes(curAccount?.toLowerCase()); - const signEnabled = !alreadySigned && isOwner; return ( } aria-controls="panel1a-content" id="panel1a-header"> - - {txDetails.description} ({transaction.safeTxHash}) - + + + + {txDetails?.description || "Multisig transaction"} + + + {transaction.safeTxHash && ( + + {transaction.safeTxHash} + + )} + + + + + {!readOnly && ( + + + {isOwner ? "Approve" : "Switch to an owner account"} + + + )} + - - - - - Nonce: {transaction.nonce}
- Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
- Modified: {transaction.modified} -
-
-
- - - - - Signers - - - - {transaction.confirmations.length === 0 && No signatures yet} - {transaction.confirmations.map((signer) => ( -
- ))} - - - {!readOnly && ( - - - {isOwner ? "Approve" : "Switch to an owner account"} - - - )} - - - - - - Transaction Details -
{txDetails.original_yaml}
-
-
- + ); diff --git a/src/chain-utils.js b/src/chain-utils.js new file mode 100644 index 0000000..9c842ee --- /dev/null +++ b/src/chain-utils.js @@ -0,0 +1,29 @@ +export const SAFE_PREFIX_BY_CHAIN_ID = { + 1: "eth", + 137: "matic", + 42161: "arb1", + 11155111: "sep", +}; + +export const EXPLORER_BY_CHAIN_ID = { + 1: "https://etherscan.io", + 137: "https://polygonscan.com", + 42161: "https://arbiscan.io", + 11155111: "https://sepolia.etherscan.io", +}; + +export function chainPrefixFromId(chainId) { + return SAFE_PREFIX_BY_CHAIN_ID[Number(chainId)] || String(chainId); +} + +export function explorerAddressUrl(chainId, address) { + const base = EXPLORER_BY_CHAIN_ID[Number(chainId)]; + if (!base) return null; + return `${base}/address/${address}`; +} + +export function explorerTxUrl(chainId, txHash) { + const base = EXPLORER_BY_CHAIN_ID[Number(chainId)]; + if (!base) return null; + return `${base}/tx/${txHash}`; +} diff --git a/src/safe-api.js b/src/safe-api.js index ce090f6..064f34a 100644 --- a/src/safe-api.js +++ b/src/safe-api.js @@ -110,6 +110,17 @@ export async function resolveSafeTxHashFromTxHash(safe, txHash) { return safeTxHash; } +export async function getSafeTransaction(safe, safeTxHash) { + const apiKit = getApi(safe.chainId); + try { + return await apiKit.getTransaction(safeTxHash); + } catch (e) { + const err = new Error(`SafeTxService error fetching txMeta for ${safeTxHash}: ${e?.message || e}`); + err.cause = e; + throw err; + } +} + export async function getTransactionDetailsByKey(safe, { type, key }) { if (type === "txHash") { const safeTxHash = await resolveSafeTxHashFromTxHash(safe, key); @@ -117,3 +128,17 @@ export async function getTransactionDetailsByKey(safe, { type, key }) { } return getTransactionDetails(key); } +export async function getChangesetAndSafeTxHashByKey(safe, { type, key }) { + const k = String(key || "").trim(); + if (!k) return null; + + if (type === "txHash") { + const safeTxHash = await resolveSafeTxHashFromTxHash(safe, k); + const changeset = await getTransactionDetails(safeTxHash); + return { safeTxHash, changeset }; + } + + const safeTxHash = k; + const changeset = await getTransactionDetails(safeTxHash); + return { safeTxHash, changeset }; +}