From f1ed219a4d7825f0c05e225aced5f3f2a5d5c8f9 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Thu, 13 Nov 2025 23:05:42 -0300 Subject: [PATCH 01/29] First visual update --- src/SearchPage.jsx | 24 +- src/TransactionCard.jsx | 529 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 492 insertions(+), 61 deletions(-) diff --git a/src/SearchPage.jsx b/src/SearchPage.jsx index deb48cf..72e21da 100644 --- a/src/SearchPage.jsx +++ b/src/SearchPage.jsx @@ -1,8 +1,11 @@ +// src/SearchPage.jsx 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 { TransactionDetailsPanel } from "./TransactionCard"; const KEY_SEARCH = "tx-search"; @@ -58,6 +61,7 @@ export default function SearchPage() { return ( + {/* Formulario de búsqueda */} @@ -89,8 +93,10 @@ export default function SearchPage() { + {/* Estado de carga */} {hasSubmitted && query.fetchStatus === "fetching" && Searching…} + {/* Errores (incluido 404 sin changeset) */} {hasSubmitted && query.isError && ( {query.error?.status === 404 ? ( @@ -112,13 +118,17 @@ export default function SearchPage() { )} - {hasSubmitted && query.isSuccess && ( - - - Changeset for safeTxHash {query.data.safeTxHash} - -
{JSON.stringify(query.data.changeset, null, 2)}
-
+ {/* Resultado: panel amigable reutilizando TransactionDetailsPanel */} + {hasSubmitted && query.isSuccess && query.data?.changeset && ( + )}
); diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index f91492f..4b89292 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -1,7 +1,23 @@ 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, + Grid, + Paper, + Stack, + Typography, + Skeleton, + Box, + Chip, + Tooltip, + Button, + Collapse, +} from "@mui/material"; import { useQuery } from "@tanstack/react-query"; @@ -11,10 +27,348 @@ 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 chainPrefixFromId(chainId) { + switch (Number(chainId)) { + case 1: + return "eth"; + case 137: + return "matic"; + case 42161: + return "arb1"; + default: + return String(chainId); + } +} + +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 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]; + + return { + id: `batch-${index}`, + title: `Call #${index + 1}`, + to: target, + value, + token: null, + summary: payload ? `Calldata: ${truncateMiddle(payload, 10)}` : null, + rawArgs: args, + }; + }); + } + + 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 }) { + const title = step.title || step.name || step.action || `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 || step.description || null; + + const displayName = to && addressBook ? addressBook[to] : undefined; + + 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.25, + }} + > + {index + 1} + + + + + {title} + + + {summary && ( + + {summary} + + )} + + + {to && ( + + To:
+ + )} + + {value != null && ( + + Amount:{" "} + + {String(value)} + {token ? ` ${token}` : ""} + + + )} + + + + + ); +} + +function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addressBook }) { + const [showRaw, setShowRaw] = 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 titleDescription = txDetails?.description || "Multisig transaction"; + + return ( + + + {titleDescription} + + + + Transaction details + + + {safeTxHash && ( + + + safeTxHash:{" "} + + + {truncateMiddle(safeTxHash, 8)} + + + + + )} + + + {networks && ( + + + Networks: + + {Array.isArray(networks) ? ( + networks.map((n) => ( + + )) + ) : ( + + )} + + )} + + {viaAddress && ( + + Via ({viaType || "via"}):
+ + )} + + {timelock && ( + + + Timelock: delay={String(timelock.delay || "n/a")} + {timelockRelayAddress && ( + <> + {" "} + · relay:
+ + )} + + {hasTimelockBadge && ( + + )} + + )} + + {contractTarget && ( + + Contract target:
+ + )} + + + + Steps + + + {steps.length ? ( + + {steps.map((step, index) => ( + + ))} + + ) : ( + + No structured steps were provided for this transaction. + + )} + + + + {safeUrl && ( + + )} + + + + + + + + {JSON.stringify(txDetails, null, 2)} + + + + ); +} + +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: shouldFetchDetails, queryFn: async () => getTransactionDetails(transaction.safeTxHash), }); @@ -23,26 +377,54 @@ 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); - } + if (!hasTransaction || variant === "panel") { + if (!txDetails) return null; + return ( + + ); + } + + if (shouldFetchDetails && txDetailsResponse.isPending) { + return ( + + }> + + + + + + + + + + + + + + + + + + + + + ); } - if (txDetailsResponse.isPending) return
Loading...
; - if (txDetailsResponse.isError) { + if (shouldFetchDetails && txDetailsResponse.isError) { if (txDetailsResponse.error?.status === 404) { const chainPrefix = chainPrefixFromId(chainId); const safeIdPart = `multisig_${safeAddress}_${txKey}`; @@ -54,8 +436,8 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) { - - + + No changeset found for transaction{" "} @@ -69,12 +451,17 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) { ); } - 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()); @@ -84,14 +471,57 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) { return ( } aria-controls="panel1a-content" id="panel1a-header"> - - {txDetails.description} ({transaction.safeTxHash}) - + + + + {txDetails?.description || "Multisig transaction"} + + + {transaction.safeTxHash && ( + + + {truncateMiddle(transaction.safeTxHash, 6)} + + + )} + + + + + {!readOnly && ( + + + {isOwner ? "Approve" : "Switch to an owner account"} + + + )} + - - + + Nonce: {transaction.nonce}
Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
@@ -99,43 +529,34 @@ function TransactionCard({ transaction, onConfirm, readOnly = false }) {
- - + + + - + 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}
-
+ + +
From 6c575c433370e63c5d17df797d48a87e9282d410 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Thu, 13 Nov 2025 23:07:53 -0300 Subject: [PATCH 02/29] Search Page updated --- src/SearchPage.jsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/SearchPage.jsx b/src/SearchPage.jsx index 72e21da..d7d7d81 100644 --- a/src/SearchPage.jsx +++ b/src/SearchPage.jsx @@ -1,11 +1,10 @@ -// src/SearchPage.jsx 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 { TransactionDetailsPanel } from "./TransactionCard"; +import TransactionCard from "./TransactionCard"; const KEY_SEARCH = "tx-search"; @@ -61,7 +60,6 @@ export default function SearchPage() { return ( - {/* Formulario de búsqueda */} @@ -93,10 +91,8 @@ export default function SearchPage() { - {/* Estado de carga */} {hasSubmitted && query.fetchStatus === "fetching" && Searching…} - {/* Errores (incluido 404 sin changeset) */} {hasSubmitted && query.isError && ( {query.error?.status === 404 ? ( @@ -118,17 +114,8 @@ export default function SearchPage() { )} - {/* Resultado: panel amigable reutilizando TransactionDetailsPanel */} {hasSubmitted && query.isSuccess && query.data?.changeset && ( - + )} ); From 52d3c60976c6b02dfbdb4db6519ece2ed8411d3d Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Fri, 14 Nov 2025 16:06:27 -0300 Subject: [PATCH 03/29] Fix elements distribution --- src/TransactionCard.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 4b89292..fda6d15 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -405,14 +405,14 @@ function TransactionCard({ - + - + @@ -520,7 +520,7 @@ function TransactionCard({ - + Nonce: {transaction.nonce}
@@ -530,7 +530,7 @@ function TransactionCard({
- + From 61a69c5ae3263f1b73578501cc42fb2f4abdc82b Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Fri, 14 Nov 2025 18:29:43 -0300 Subject: [PATCH 04/29] Update resume row --- src/TransactionCard.jsx | 112 ++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index fda6d15..46319d9 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -102,14 +102,10 @@ function buildSteps(txDetails) { function StepItem({ index, step, addressBook }) { const title = step.title || step.name || step.action || `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 || step.description || null; - const displayName = to && addressBook ? addressBook[to] : undefined; return ( @@ -185,7 +181,6 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr 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; @@ -404,21 +399,28 @@ function TransactionCard({ - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + + + + + + ); @@ -519,46 +521,46 @@ function TransactionCard({ - - - - - Nonce: {transaction.nonce}
- Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
- Modified: {transaction.modified} -
-
-
+ + + + + + Nonce: {transaction.nonce}
+ Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
+ Modified: {transaction.modified} +
+
+
- - - - - Signers - - - - {transaction.confirmations.length === 0 && No signatures yet} - {transaction.confirmations.map((signer) => ( -
- ))} - + + + + + Signers + + + + {transaction.confirmations.length === 0 && No signatures yet} + {transaction.confirmations.map((signer) => ( +
+ ))} + + - - + + - - - - + + ); From 7e18efbbce7e991c1958c5867bc472163d923d79 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Fri, 14 Nov 2025 19:17:46 -0300 Subject: [PATCH 05/29] Deleted scroll on raw JSON --- src/TransactionCard.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 46319d9..e30a898 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -328,12 +328,12 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr component="pre" sx={{ mt: 1, - maxHeight: 260, - overflow: "auto", fontSize: "0.75rem", backgroundColor: "rgba(255,255,255,0.02)", borderRadius: 1, - p: 1, + p: 1.5, + whiteSpace: "pre-wrap", + wordBreak: "break-word", }} > {JSON.stringify(txDetails, null, 2)} From af55ebcaffd083545cfd5ce9f20733e76e46c710 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 19 Nov 2025 16:59:06 -0300 Subject: [PATCH 06/29] Some visual fixes and button functions --- src/TransactionCard.jsx | 210 ++++++++++++++++++++++++++++++++++------ 1 file changed, 179 insertions(+), 31 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 0763140..b1fa9ee 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -46,6 +46,63 @@ function truncateMiddle(str, visible = 6) { return `${str.slice(0, visible)}…${str.slice(-visible)}`; } +function formatScalarForYaml(v) { + if (v === null) return "null"; + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "number") return String(v); + if (typeof v === "string") { + if (v === "" || /\s/.test(v) || /[:{}[\],&*#?|<>=!%@`]/.test(v)) { + const escaped = v.replace(/"/g, '\\"'); + return `"${escaped}"`; + } + return v; + } + return String(v); +} + +function jsonToYaml(value, indent = 0) { + const ind = " ".repeat(indent); + + if (Array.isArray(value)) { + if (value.length === 0) return ind + "[]"; + return value + .map((item) => { + if (item && typeof item === "object") { + const child = jsonToYaml(item, indent + 1); + const lines = child.split("\n"); + const [first, ...rest] = lines; + let out = `${ind}- ${first.trimStart()}`; + if (rest.length) { + out += "\n" + rest.map((l) => (l.startsWith(" ") ? ind + l : ind + " " + l.trimStart())).join("\n"); + } + return out; + } + return `${ind}- ${formatScalarForYaml(item)}`; + }) + .join("\n"); + } + + if (value && typeof value === "object") { + const entries = Object.entries(value); + if (!entries.length) return ind + "{}"; + return entries + .map(([k, v]) => { + if (v && typeof v === "object") { + const child = jsonToYaml(v, indent + 1); + const lines = child.split("\n"); + if (lines.length === 1 && !Array.isArray(v)) { + return `${ind}${k}: ${lines[0].trim()}`; + } + return `${ind}${k}:\n${child}`; + } + return `${ind}${k}: ${formatScalarForYaml(v)}`; + }) + .join("\n"); + } + + return ind + formatScalarForYaml(value); +} + function buildSteps(txDetails) { if (!txDetails) return []; @@ -60,6 +117,19 @@ function buildSteps(txDetails) { 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}`, @@ -69,6 +139,7 @@ function buildSteps(txDetails) { token: null, summary: payload ? `Calldata: ${truncateMiddle(payload, 10)}` : null, rawArgs: args, + method, }; }); } @@ -108,6 +179,26 @@ function StepItem({ index, step, addressBook }) { const summary = step.summary || step.description || null; const displayName = to && addressBook ? addressBook[to] : undefined; + const method = + 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 args = + step.rawArgs ?? step.args ?? step.parameters ?? step.params ?? step.contractArgs ?? step.contract_args ?? null; + return ( )} + {method && ( + + Method:{" "} + + {String(method)} + + + )} + {value != null && ( Amount:{" "} @@ -165,6 +265,24 @@ function StepItem({ index, step, addressBook }) { )} + + {args != null && ( + + {typeof args === "string" ? args : JSON.stringify(args, null, 2)} + + )} @@ -173,7 +291,8 @@ function StepItem({ index, step, addressBook }) { } function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addressBook }) { - const [showRaw, setShowRaw] = React.useState(false); + const [showYaml, setShowYaml] = React.useState(false); + const [showJson, setShowJson] = React.useState(false); const steps = buildSteps(txDetails); const chainPrefix = chainPrefixFromId(chainId); @@ -203,6 +322,9 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr const titleDescription = txDetails?.description || "Multisig transaction"; + const rawYaml = jsonToYaml(txDetails || {}); + const rawJson = JSON.stringify(txDetails || {}, null, 2); + return ( @@ -217,11 +339,9 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr safeTxHash:{" "} - - - {truncateMiddle(safeTxHash, 8)} - - + + {safeTxHash} + )} @@ -318,12 +438,35 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr )} - + + + + - + + + {rawYaml} + + + + - {JSON.stringify(txDetails, null, 2)} + {rawJson} @@ -382,6 +526,15 @@ function TransactionCard({ const addressBook = addressBookResponse.data || {}; const txDetails = txDetailsProp || txDetailsResponse.data; + const handleApproveClick = React.useCallback( + (event) => { + event.stopPropagation(); + event.preventDefault(); + if (onConfirm) onConfirm(); + }, + [onConfirm] + ); + if (!hasTransaction || variant === "panel") { if (!txDetails) return null; return ( @@ -405,14 +558,14 @@ function TransactionCard({ - + - + @@ -491,21 +644,16 @@ function TransactionCard({ {transaction.safeTxHash && ( - - - {truncateMiddle(transaction.safeTxHash, 6)} - - + + {transaction.safeTxHash} + )} @@ -514,7 +662,7 @@ function TransactionCard({ {!readOnly && ( @@ -527,7 +675,7 @@ function TransactionCard({ - + Nonce: {transaction.nonce}
@@ -537,7 +685,7 @@ function TransactionCard({
- + From a03b300516aca79662f7aba7baa4f08892792fdc Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 19 Nov 2025 17:44:40 -0300 Subject: [PATCH 07/29] Reorganized details panel & information y transaction card --- src/TransactionCard.jsx | 298 ++++++++++++++++++++-------------------- 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index b1fa9ee..9e12409 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -7,14 +7,12 @@ import { Accordion, AccordionDetails, AccordionSummary, - Grid, Paper, Stack, Typography, Skeleton, Box, Chip, - Tooltip, Button, Collapse, } from "@mui/material"; @@ -46,63 +44,6 @@ function truncateMiddle(str, visible = 6) { return `${str.slice(0, visible)}…${str.slice(-visible)}`; } -function formatScalarForYaml(v) { - if (v === null) return "null"; - if (typeof v === "boolean") return v ? "true" : "false"; - if (typeof v === "number") return String(v); - if (typeof v === "string") { - if (v === "" || /\s/.test(v) || /[:{}[\],&*#?|<>=!%@`]/.test(v)) { - const escaped = v.replace(/"/g, '\\"'); - return `"${escaped}"`; - } - return v; - } - return String(v); -} - -function jsonToYaml(value, indent = 0) { - const ind = " ".repeat(indent); - - if (Array.isArray(value)) { - if (value.length === 0) return ind + "[]"; - return value - .map((item) => { - if (item && typeof item === "object") { - const child = jsonToYaml(item, indent + 1); - const lines = child.split("\n"); - const [first, ...rest] = lines; - let out = `${ind}- ${first.trimStart()}`; - if (rest.length) { - out += "\n" + rest.map((l) => (l.startsWith(" ") ? ind + l : ind + " " + l.trimStart())).join("\n"); - } - return out; - } - return `${ind}- ${formatScalarForYaml(item)}`; - }) - .join("\n"); - } - - if (value && typeof value === "object") { - const entries = Object.entries(value); - if (!entries.length) return ind + "{}"; - return entries - .map(([k, v]) => { - if (v && typeof v === "object") { - const child = jsonToYaml(v, indent + 1); - const lines = child.split("\n"); - if (lines.length === 1 && !Array.isArray(v)) { - return `${ind}${k}: ${lines[0].trim()}`; - } - return `${ind}${k}:\n${child}`; - } - return `${ind}${k}: ${formatScalarForYaml(v)}`; - }) - .join("\n"); - } - - return ind + formatScalarForYaml(value); -} - function buildSteps(txDetails) { if (!txDetails) return []; @@ -171,7 +112,7 @@ function buildSteps(txDetails) { ]; } -function StepItem({ index, step, addressBook }) { +function StepItem({ index, step, addressBook, addresses }) { const title = step.title || step.name || step.action || `Step ${index + 1}`; const to = step.to || step.recipient || step.target || null; const value = step.value ?? step.amount ?? null; @@ -199,6 +140,17 @@ function StepItem({ index, step, addressBook }) { const args = step.rawArgs ?? step.args ?? step.parameters ?? step.params ?? step.contractArgs ?? step.contract_args ?? 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 parsedArgs = step.parsedArguments || step.parsed_args || null; + return ( )} + {contractAddressKey && ( + + Contract key:{" "} + + {contractAddressKey} + {contractType ? ` (${contractType})` : ""} + + + )} + + {resolvedContractAddress && ( + + Contract addr:
+ + )} + {method && ( Method:{" "} @@ -283,6 +251,24 @@ function StepItem({ index, step, addressBook }) { {typeof args === "string" ? args : JSON.stringify(args, null, 2)} )} + + {parsedArgs != null && ( + + {typeof parsedArgs === "string" ? parsedArgs : JSON.stringify(parsedArgs, null, 2)} + + )} @@ -290,7 +276,7 @@ function StepItem({ index, step, addressBook }) { ); } -function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addressBook }) { +function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress, txKey, addressBook }) { const [showYaml, setShowYaml] = React.useState(false); const [showJson, setShowJson] = React.useState(false); @@ -320,33 +306,58 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr const hasTimelockBadge = !!timelock && ((timelock.delay && String(timelock.delay) !== "min") || !!timelock.execution_relay); - const titleDescription = txDetails?.description || "Multisig transaction"; - - const rawYaml = jsonToYaml(txDetails || {}); + const rawYaml = txDetails?.original_yaml || ""; const rawJson = JSON.stringify(txDetails || {}, null, 2); - return ( - - - {titleDescription} - + const showTxMeta = Boolean(transaction); + + const confirmationsCount = transaction?.confirmations?.length ?? 0; + const confirmationsRequired = transaction?.confirmationsRequired ?? null; + + const signers = Array.isArray(transaction?.confirmations) ? transaction.confirmations : []; + return ( + Transaction details - {safeTxHash && ( - - - safeTxHash:{" "} - - {safeTxHash} - - - - )} + + {showTxMeta && ( + <> + + Nonce:{" "} + + {transaction.nonce} + + + + + Confirmations:{" "} + + {confirmationsRequired != null + ? `${confirmationsCount}/${confirmationsRequired}` + : String(confirmationsCount)} + + + + {transaction.modified && ( + + Modified:{" "} + + {transaction.modified} + + + )} + + )} - {networks && ( @@ -377,7 +388,10 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr {timelock && ( - Timelock: delay={String(timelock.delay || "n/a")} + Timelock: delay:{" "} + + {String(timelock.delay)} + {timelockRelayAddress && ( <> {" "} @@ -402,6 +416,30 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr Contract target:
)} + + {showTxMeta && ( + + + Signers: + + + {signers.length === 0 && ( + + No signatures yet + + )} + {signers.map((signer) => ( + + ))} + + + )} @@ -411,7 +449,13 @@ function DetailsPanel({ txDetails, safeTxHash, chainId, safeAddress, txKey, addr {steps.length ? ( {steps.map((step, index) => ( - + ))} ) : ( @@ -540,6 +584,7 @@ function TransactionCard({ return ( - - - - - - - - - - - - - - - + + + + + @@ -594,18 +629,14 @@ function TransactionCard({ {txKey} - - - - - No changeset found for transaction{" "} - - {transaction.safeTxHash || txKey} - - - - - + + + No changeset found for transaction{" "} + + {transaction.safeTxHash || txKey} + + + ); @@ -673,46 +704,15 @@ function TransactionCard({ - - - - - - Nonce: {transaction.nonce}
- Confirmations: {`${transaction.confirmations?.length}/${transaction.confirmationsRequired}`}
- Modified: {transaction.modified} -
-
-
- - - - - - Signers - - - - {transaction.confirmations.length === 0 && No signatures yet} - {transaction.confirmations.map((signer) => ( -
- ))} - - - - - - - - - + ); From afbfa3a61472f948b6028725148a5cb1cb43403f Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 19 Nov 2025 18:06:38 -0300 Subject: [PATCH 08/29] Fix signers Chip --- src/TransactionCard.jsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 9e12409..1e9f81e 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -429,13 +429,7 @@ function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress )} {signers.map((signer) => ( - +
))} From 65dba5d2edd59229087ebf9ae536a6aa6fba4f0b Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 26 Nov 2025 19:13:29 -0300 Subject: [PATCH 09/29] Copy address button --- src/Address.jsx | 61 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/src/Address.jsx b/src/Address.jsx index 77227df..b8f2961 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -1,10 +1,18 @@ import React from "react"; -import { Typography, Popover } from "@mui/material"; +import { Typography, Popover, Box, Stack, IconButton } from "@mui/material"; import Chip from "@mui/material/Chip"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { shortAddress } from "./utils"; +async function copyToClipboard(text) { + if (!navigator.clipboard || !window.isSecureContext) return false; + await navigator.clipboard.writeText(text); + return true; +} + function Address({ displayName = null, address }) { const [popAnchor, setPopAnchor] = React.useState(null); + const [copied, setCopied] = React.useState(false); const handlePopoverOpen = (event) => { setPopAnchor(event.currentTarget); @@ -16,30 +24,59 @@ function Address({ displayName = null, address }) { const popOpen = Boolean(popAnchor); + const onCopy = async (e) => { + e.preventDefault(); + e.stopPropagation(); + const ok = await copyToClipboard(address); + setCopied(ok); + window.setTimeout(() => setCopied(false), 900); + }; + return ( <> + - {address} + + + {address} + + + + + + + + + + {copied && ( + + Copied + + )} + + ); From 4e5ba76f2e82b8704f53e91f617af6e49107fda4 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 26 Nov 2025 21:55:13 -0300 Subject: [PATCH 10/29] Fix window clipboard function --- src/Address.jsx | 76 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/src/Address.jsx b/src/Address.jsx index b8f2961..ade3f21 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -1,34 +1,52 @@ import React from "react"; -import { Typography, Popover, Box, Stack, IconButton } from "@mui/material"; +import { Typography, Popover, Box, Stack, IconButton, TextField, Button } from "@mui/material"; import Chip from "@mui/material/Chip"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { shortAddress } from "./utils"; async function copyToClipboard(text) { - if (!navigator.clipboard || !window.isSecureContext) return false; - await navigator.clipboard.writeText(text); - return true; + try { + if (!navigator.clipboard || !window.isSecureContext) return true; + await navigator.clipboard.writeText(text); + return false; + } catch { + return true; + } } function Address({ displayName = null, address }) { const [popAnchor, setPopAnchor] = React.useState(null); const [copied, setCopied] = React.useState(false); + const [manualOpen, setManualOpen] = React.useState(false); + const inputRef = React.useRef(null); - const handlePopoverOpen = (event) => { - setPopAnchor(event.currentTarget); - }; + const popOpen = Boolean(popAnchor); - const handlePopoverClose = () => { - setPopAnchor(null); - }; + const handlePopoverOpen = (event) => setPopAnchor(event.currentTarget); + const handlePopoverClose = () => setPopAnchor(null); - const popOpen = Boolean(popAnchor); + React.useEffect(() => { + if (!manualOpen) return; + const id = window.setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 0); + return () => window.clearTimeout(id); + }, [manualOpen]); const onCopy = async (e) => { e.preventDefault(); e.stopPropagation(); - const ok = await copyToClipboard(address); - setCopied(ok); + + const needsManual = await copyToClipboard(address); + if (needsManual) { + setManualOpen(true); + return; + } + + setCopied(true); window.setTimeout(() => setCopied(false), 900); }; @@ -56,10 +74,12 @@ function Address({ displayName = null, address }) { transformOrigin={{ vertical: "top", horizontal: "left" }} onClose={handlePopoverClose} disableRestoreFocus - sx={{ p: 1, borderRadius: 2 }} + slotProps={{ + paper: { sx: { p: 1, borderRadius: 2, maxWidth: 440 } }, + }} > - - + + {address} @@ -76,6 +96,30 @@ function Address({ displayName = null, address }) { )} + + {manualOpen && ( + + + Clipboard is blocked in embedded mode. Copy manually: + + + + + + + + + )} From 607d4b877ed41af283a515baf5f21180c656fd8d Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 26 Nov 2025 23:19:47 -0300 Subject: [PATCH 11/29] chain utils and url constructor --- src/Address.jsx | 111 ++++++++-------------------------------- src/TransactionCard.jsx | 29 ++--------- src/chain-utils.js | 29 +++++++++++ 3 files changed, 52 insertions(+), 117 deletions(-) create mode 100644 src/chain-utils.js diff --git a/src/Address.jsx b/src/Address.jsx index ade3f21..d3203cd 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -1,54 +1,20 @@ import React from "react"; -import { Typography, Popover, Box, Stack, IconButton, TextField, Button } from "@mui/material"; +import { Typography, Popover, Link, Stack } from "@mui/material"; import Chip from "@mui/material/Chip"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { shortAddress } from "./utils"; - -async function copyToClipboard(text) { - try { - if (!navigator.clipboard || !window.isSecureContext) return true; - await navigator.clipboard.writeText(text); - return false; - } catch { - return true; - } -} +import { explorerAddressUrl } from "./chain-utils"; +import { useSafe } from "./safe-ui"; function Address({ displayName = null, address }) { const [popAnchor, setPopAnchor] = React.useState(null); - const [copied, setCopied] = React.useState(false); - const [manualOpen, setManualOpen] = React.useState(false); - const inputRef = React.useRef(null); - - const popOpen = Boolean(popAnchor); + const { chainId } = useSafe(); const handlePopoverOpen = (event) => setPopAnchor(event.currentTarget); const handlePopoverClose = () => setPopAnchor(null); - React.useEffect(() => { - if (!manualOpen) return; - const id = window.setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, 0); - return () => window.clearTimeout(id); - }, [manualOpen]); - - const onCopy = async (e) => { - e.preventDefault(); - e.stopPropagation(); - - const needsManual = await copyToClipboard(address); - if (needsManual) { - setManualOpen(true); - return; - } + const popOpen = Boolean(popAnchor); - setCopied(true); - window.setTimeout(() => setCopied(false), 900); - }; + const url = explorerAddressUrl(chainId, address); return ( <> @@ -58,14 +24,8 @@ function Address({ displayName = null, address }) { aria-haspopup="true" onClick={handlePopoverOpen} variant="outlined" - sx={{ - height: 26, - borderRadius: 2, - maxWidth: 260, - "& .MuiChip-label": { overflow: "hidden", textOverflow: "ellipsis" }, - }} + sx={{ height: 26, borderRadius: 2 }} /> - - - - {address} - - - - - - - - - - {copied && ( - - Copied - - )} - - - {manualOpen && ( - - - Clipboard is blocked in embedded mode. Copy manually: - - - - - - - - + + {address} + {url && ( + + {url} + )} diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 1e9f81e..5a25d8a 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -20,24 +20,12 @@ import { 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 chainPrefixFromId(chainId) { - switch (Number(chainId)) { - case 1: - return "eth"; - case 137: - return "matic"; - case 42161: - return "arb1"; - default: - return String(chainId); - } -} - function truncateMiddle(str, visible = 6) { if (!str) return ""; if (str.length <= visible * 2 + 3) return str; @@ -317,13 +305,7 @@ function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress const signers = Array.isArray(transaction?.confirmations) ? transaction.confirmations : []; return ( - + Transaction details @@ -540,7 +522,6 @@ function TransactionCard({ const txKey = hasTransaction ? transaction.safeTxHash || transaction.transactionHash || transaction.txHash : safeTxHashProp; - const shouldFetchDetails = !txDetailsProp && hasTransaction && !!transaction.safeTxHash; const txDetailsResponse = useQuery({ @@ -649,7 +630,6 @@ function TransactionCard({ const alreadySigned = transaction.confirmations .map((c) => c.owner?.toLowerCase()) .includes(curAccount?.toLowerCase()); - const signEnabled = !alreadySigned && isOwner; return ( @@ -672,10 +652,7 @@ function TransactionCard({ {transaction.safeTxHash} 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}`; +} From 50f0e50f11b9544c3bf3a38c94d8ac03629354b0 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 26 Nov 2025 23:39:07 -0300 Subject: [PATCH 12/29] Address using address as url instead of clipboard --- src/Address.jsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Address.jsx b/src/Address.jsx index d3203cd..d16119d 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Typography, Popover, Link, Stack } 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"; @@ -9,11 +9,7 @@ function Address({ displayName = null, address }) { const [popAnchor, setPopAnchor] = React.useState(null); const { chainId } = useSafe(); - const handlePopoverOpen = (event) => setPopAnchor(event.currentTarget); - const handlePopoverClose = () => setPopAnchor(null); - const popOpen = Boolean(popAnchor); - const url = explorerAddressUrl(chainId, address); return ( @@ -22,7 +18,8 @@ function Address({ displayName = null, address }) { label={displayName || shortAddress(address)} aria-owns={popOpen ? "address-popover" : undefined} aria-haspopup="true" - onClick={handlePopoverOpen} + onClick={(e) => setPopAnchor(e.currentTarget)} + onDelete={undefined} variant="outlined" sx={{ height: 26, borderRadius: 2 }} /> @@ -32,24 +29,26 @@ function Address({ displayName = null, address }) { anchorEl={popAnchor} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} - onClose={handlePopoverClose} + onClose={() => setPopAnchor(null)} disableRestoreFocus slotProps={{ paper: { sx: { p: 1, borderRadius: 2, maxWidth: 520 } } }} > - - {address} - {url && ( + + {url ? ( setPopAnchor(null)} > - {url} + {address} + ) : ( + address )} - + ); From 5d374e24f503363407284b9fae7767a82394a6a1 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Wed, 26 Nov 2025 23:50:24 -0300 Subject: [PATCH 13/29] Visual update for tooltip --- src/Address.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Address.jsx b/src/Address.jsx index d16119d..505af23 100644 --- a/src/Address.jsx +++ b/src/Address.jsx @@ -21,7 +21,6 @@ function Address({ displayName = null, address }) { onClick={(e) => setPopAnchor(e.currentTarget)} onDelete={undefined} variant="outlined" - sx={{ height: 26, borderRadius: 2 }} /> setPopAnchor(null)} disableRestoreFocus - slotProps={{ paper: { sx: { p: 1, borderRadius: 2, maxWidth: 520 } } }} > {url ? ( From cd92cd267b445c7ec4c5cec2fef765a548ffc9e8 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Thu, 27 Nov 2025 00:06:25 -0300 Subject: [PATCH 14/29] Visual update on each step from transaction card --- src/TransactionCard.jsx | 265 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 242 insertions(+), 23 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index 5a25d8a..c07be4e 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -32,6 +32,199 @@ function truncateMiddle(str, visible = 6) { 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 normalizeArgItems(args) { + if (args == null) return []; + + if (Array.isArray(args)) { + return args.map((a, i) => { + if (a && typeof a === "object" && !Array.isArray(a)) { + const name = a.name ?? a.argName ?? a.key ?? null; + const type = a.type ?? a.argType ?? a.kind ?? null; + const value = a.value !== undefined ? a.value : a; + return { name, type, value, index: i }; + } + return { name: null, type: null, value: a, index: i }; + }); + } + + if (typeof args === "object") { + return Object.entries(args).map(([k, v], i) => ({ name: k, type: null, value: v, index: i })); + } + + return [{ name: null, type: null, value: args, index: 0 }]; +} + +function pickTransformPayload(obj, key) { + if (!obj || typeof obj !== "object") return null; + if (obj[key] !== undefined) return obj[key]; + if (obj.type === key) { + if (obj.value !== undefined) return obj.value; + if (obj[key] !== undefined) return obj[key]; + return obj; + } + return null; +} + +function buildChangesetTransforms({ addresses, addressBook }) { + const getAddressFromKeyOrAddress = (value) => { + const resolved = addresses?.[value] || value; + if (isHexAddress(resolved)) { + return { kind: "address", value: resolved, displayName: addressBook?.[resolved] }; + } + return { kind: "text", value: 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: 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) => { + 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) return { kind: "text", value: `${enumName}.${String(key)}` }; + if (key != null) return { kind: "text", value: String(key) }; + return { kind: "text", value: safeJsonStringify(value, 0) }; + } + return { kind: "text", value: String(value) }; + }; + + return { + address_key: (value) => getAddressFromKeyOrAddress(value), + role: (value) => getRole(value), + component_role: (value) => getComponentRole(value), + enum: (value) => getEnumValue(value), + }; +} + +function transformValue(value, transforms) { + if (value == null) return { kind: "text", value: "" }; + + if (Array.isArray(value)) { + return { kind: "list", value: value.map((v) => transformValue(v, transforms)) }; + } + + if (typeof value === "object") { + for (const k of Object.keys(transforms)) { + const payload = pickTransformPayload(value, k); + if (payload !== null) return transforms[k](payload); + } + return { kind: "json", value }; + } + + if (isHexAddress(value)) return { kind: "address", value }; + return { kind: "text", value: String(value) }; +} + +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)} + ))} + + ); + } + + if (v.kind === "json") { + return ( + + {safeJsonStringify(v.value, 2)} + + ); + } + + return ( + + {String(v.value)} + + ); +} + +function argTypeFromParsed(item) { + if (!item || typeof item !== "object") return null; + + if (item.type && typeof item.type === "string") return item.type; + + if (item.enum) return item.enum; + if (item.type === "enum" && item.enum) return item.enum; + + if (item.address_key != null || item.type === "address_key") return "address"; + if (item.component_role != null || item.type === "component_role") return "component_role"; + if (item.role != null || item.type === "role") return "role"; + + return null; +} + +function methodSignature(methodName, argItems) { + if (!methodName) return null; + + const types = argItems + .map((a) => { + if (!a) return null; + + if (a.type) { + if (a.type === "address_key") return "address"; + if (a.type === "enum") { + const v = a.value; + if (v && typeof v === "object") return v.enum || v.name || "enum"; + return "enum"; + } + return a.type; + } + + const inferred = argTypeFromParsed(a.value); + return inferred || null; + }) + .filter(Boolean); + + return `${methodName}(${types.join(", ")})`; +} + function buildSteps(txDetails) { if (!txDetails) return []; @@ -108,7 +301,7 @@ function StepItem({ index, step, addressBook, addresses }) { const summary = step.summary || step.description || null; const displayName = to && addressBook ? addressBook[to] : undefined; - const method = + const methodName = step.method || step.function || step.functionName || @@ -125,9 +318,11 @@ function StepItem({ index, step, addressBook, addresses }) { step.rawArgs.signature : null); - const args = + const argsRaw = step.rawArgs ?? step.args ?? step.parameters ?? step.params ?? step.contractArgs ?? step.contract_args ?? null; + const parsedArgs = step.parsedArguments || step.parsed_args || null; + const contractAddressKey = step.contract_address_key || step.contractAddressKey || null; const contractType = step.contract_type || step.contractType || null; @@ -137,7 +332,12 @@ function StepItem({ index, step, addressBook, addresses }) { const resolvedContractName = resolvedContractAddress && addressBook ? addressBook[resolvedContractAddress] : undefined; - const parsedArgs = step.parsedArguments || step.parsed_args || null; + const argItems = normalizeArgItems(parsedArgs ?? argsRaw); + const transforms = React.useMemo( + () => buildChangesetTransforms({ addresses, addressBook }), + [addresses, addressBook] + ); + const signature = methodSignature(methodName, argItems); return ( )} - {method && ( + {signature && ( Method:{" "} - {String(method)} + {signature} )} @@ -222,29 +422,48 @@ function StepItem({ index, step, addressBook, addresses }) { )} - {args != null && ( - - {typeof args === "string" ? args : JSON.stringify(args, null, 2)} + {argItems.length > 0 && ( + + + Arguments: + + + + {argItems.map((a) => { + const label = a.name + ? `${a.name}${a.type ? ` (${a.type})` : ""}` + : a.type + ? a.type + : `arg${a.index}`; + const tv = transformValue(a.value, transforms); + return ( + + + {label}: + + {renderTransformedValue(tv)} + + ); + })} + )} - {parsedArgs != null && ( + {parsedArgs == null && argsRaw != null && ( - {typeof parsedArgs === "string" ? parsedArgs : JSON.stringify(parsedArgs, null, 2)} + {typeof argsRaw === "string" ? argsRaw : safeJsonStringify(argsRaw, 2)} )} From cb59d51877f622978b4997943cccb477173e3020 Mon Sep 17 00:00:00 2001 From: LucasCambon Date: Thu, 27 Nov 2025 00:27:51 -0300 Subject: [PATCH 15/29] Fix transformed values --- src/TransactionCard.jsx | 284 ++++++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 125 deletions(-) diff --git a/src/TransactionCard.jsx b/src/TransactionCard.jsx index c07be4e..83798c8 100644 --- a/src/TransactionCard.jsx +++ b/src/TransactionCard.jsx @@ -44,46 +44,13 @@ function safeJsonStringify(v, space = 2) { } } -function normalizeArgItems(args) { - if (args == null) return []; - - if (Array.isArray(args)) { - return args.map((a, i) => { - if (a && typeof a === "object" && !Array.isArray(a)) { - const name = a.name ?? a.argName ?? a.key ?? null; - const type = a.type ?? a.argType ?? a.kind ?? null; - const value = a.value !== undefined ? a.value : a; - return { name, type, value, index: i }; - } - return { name: null, type: null, value: a, index: i }; - }); - } - - if (typeof args === "object") { - return Object.entries(args).map(([k, v], i) => ({ name: k, type: null, value: v, index: i })); - } - - return [{ name: null, type: null, value: args, index: 0 }]; -} - -function pickTransformPayload(obj, key) { - if (!obj || typeof obj !== "object") return null; - if (obj[key] !== undefined) return obj[key]; - if (obj.type === key) { - if (obj.value !== undefined) return obj.value; - if (obj[key] !== undefined) return obj[key]; - return obj; - } - return null; -} - function buildChangesetTransforms({ addresses, addressBook }) { const getAddressFromKeyOrAddress = (value) => { const resolved = addresses?.[value] || value; if (isHexAddress(resolved)) { return { kind: "address", value: resolved, displayName: addressBook?.[resolved] }; } - return { kind: "text", value: resolved }; + return { kind: "text", value: resolved ?? "" }; }; const getRole = (value) => { @@ -91,7 +58,7 @@ function buildChangesetTransforms({ addresses, addressBook }) { 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: String(value) }; + return { kind: "text", value: value != null ? String(value) : "" }; }; const getComponentRole = (value) => { @@ -114,7 +81,7 @@ function buildChangesetTransforms({ addresses, addressBook }) { if (key != null) return { kind: "text", value: String(key) }; return { kind: "text", value: safeJsonStringify(value, 0) }; } - return { kind: "text", value: String(value) }; + return { kind: "text", value: value != null ? String(value) : "" }; }; return { @@ -125,25 +92,6 @@ function buildChangesetTransforms({ addresses, addressBook }) { }; } -function transformValue(value, transforms) { - if (value == null) return { kind: "text", value: "" }; - - if (Array.isArray(value)) { - return { kind: "list", value: value.map((v) => transformValue(v, transforms)) }; - } - - if (typeof value === "object") { - for (const k of Object.keys(transforms)) { - const payload = pickTransformPayload(value, k); - if (payload !== null) return transforms[k](payload); - } - return { kind: "json", value }; - } - - if (isHexAddress(value)) return { kind: "address", value }; - return { kind: "text", value: String(value) }; -} - function renderTransformedValue(v) { if (!v) return null; @@ -185,46 +133,131 @@ function renderTransformedValue(v) { ); } -function argTypeFromParsed(item) { - if (!item || typeof item !== "object") return null; +function transformFallback(value) { + if (value == null) return { kind: "text", value: "" }; + if (Array.isArray(value)) return { kind: "list", value: value.map(transformFallback) }; + if (typeof value === "object") return { kind: "json", value }; + if (isHexAddress(value)) return { kind: "address", value }; + return { kind: "text", value: String(value) }; +} + +function transformArg({ spec, parsed, transforms }) { + if (spec && typeof spec === "object" && !Array.isArray(spec) && spec.transform) { + const t = spec.transform; + const v = spec.value; + const fn = transforms?.[t]; + if (fn) { + const tv = fn(v); + return { primary: tv, secondary: parsed }; + } + return { primary: transformFallback(v), secondary: parsed }; + } + + if (parsed !== undefined) { + return { primary: transformFallback(parsed), secondary: undefined }; + } + + return { primary: transformFallback(spec), secondary: undefined }; +} - if (item.type && typeof item.type === "string") return item.type; +function getStepMethodName(step) { + return ( + 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) + ); +} - if (item.enum) return item.enum; - if (item.type === "enum" && item.enum) return item.enum; +function getStepAbi(txDetails, step) { + const contractType = step?.contract_type || step?.contractType || null; + const methodName = getStepMethodName(step); + if (!contractType || !methodName) return null; + return txDetails?.abis?.[contractType]?.[methodName] || null; +} - if (item.address_key != null || item.type === "address_key") return "address"; - if (item.component_role != null || item.type === "component_role") return "component_role"; - if (item.role != null || item.type === "role") return "role"; +function typeLabelFromSpecOrAbi({ spec, abiInput }) { + if (spec?.transform === "enum") { + const obj = spec.value; + if (obj && typeof obj === "object") { + return obj.enum || obj.name || "enum"; + } + return "enum"; + } + if (spec?.transform === "address_key") return "address"; + if (spec?.transform === "component_role") return "component_role"; + if (spec?.transform === "role") return "role"; + if (abiInput?.type) return String(abiInput.type); return null; } -function methodSignature(methodName, argItems) { +function buildMethodSignature({ methodName, specs, abi }) { if (!methodName) return null; - const types = argItems - .map((a) => { - if (!a) return null; - - if (a.type) { - if (a.type === "address_key") return "address"; - if (a.type === "enum") { - const v = a.value; - if (v && typeof v === "object") return v.enum || v.name || "enum"; - return "enum"; - } - return a.type; - } + const abiInputs = Array.isArray(abi?.inputs) ? abi.inputs : null; - const inferred = argTypeFromParsed(a.value); - return inferred || null; - }) - .filter(Boolean); + const count = Math.max(specs?.length || 0, abiInputs?.length || 0); + if (!count) return `${methodName}()`; + + const types = Array.from({ length: count }).map((_, i) => { + const spec = specs?.[i]; + const abiInput = abiInputs?.[i]; + return typeLabelFromSpecOrAbi({ spec, abiInput }) || "unknown"; + }); return `${methodName}(${types.join(", ")})`; } +function buildArgRows({ step, txDetails }) { + const specs = Array.isArray(step?.arguments) ? step.arguments : null; + const parsed = Array.isArray(step?.parsedArguments) ? step.parsedArguments : null; + + const abi = getStepAbi(txDetails, step); + const abiInputs = Array.isArray(abi?.inputs) ? abi.inputs : null; + + const count = Math.max(specs?.length || 0, parsed?.length || 0, abiInputs?.length || 0); + + return Array.from({ length: count }).map((_, i) => { + const spec = specs?.[i]; + const parsedValue = parsed?.[i]; + + const abiInput = abiInputs?.[i] || null; + const name = abiInput?.name || spec?.name || spec?.argName || null; + + const type = typeLabelFromSpecOrAbi({ spec, abiInput }); + + return { + index: i, + name, + type, + spec, + parsed: parsedValue, + }; + }); +} + +function renderSecondaryParsed({ secondary }) { + if (secondary === undefined || secondary === null) return null; + const asText = typeof secondary === "string" ? secondary : safeJsonStringify(secondary, 0); + return ( + + ({asText}) + + ); +} + function buildSteps(txDetails) { if (!txDetails) return []; @@ -271,7 +304,6 @@ function buildSteps(txDetails) { const summary = txDetails.summary || null; const description = txDetails.description || null; - const title = summary || description || "Transaction"; const to = txDetails.to || txDetails.target || null; @@ -293,35 +325,14 @@ function buildSteps(txDetails) { ]; } -function StepItem({ index, step, addressBook, addresses }) { - const title = step.title || step.name || step.action || `Step ${index + 1}`; +function StepItem({ index, step, addressBook, addresses, txDetails }) { + const title = step.title || step.name || step.action || step.description || `Step ${index + 1}`; + const to = step.to || step.recipient || step.target || null; + const displayName = to && addressBook ? addressBook[to] : undefined; const value = step.value ?? step.amount ?? null; const token = step.token || step.tokenSymbol || step.asset || null; - const summary = step.summary || step.description || 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 argsRaw = - step.rawArgs ?? step.args ?? step.parameters ?? step.params ?? step.contractArgs ?? step.contract_args ?? null; - - const parsedArgs = step.parsedArguments || step.parsed_args || null; + const summary = step.summary || null; const contractAddressKey = step.contract_address_key || step.contractAddressKey || null; const contractType = step.contract_type || step.contractType || null; @@ -332,12 +343,22 @@ function StepItem({ index, step, addressBook, addresses }) { const resolvedContractName = resolvedContractAddress && addressBook ? addressBook[resolvedContractAddress] : undefined; - const argItems = normalizeArgItems(parsedArgs ?? argsRaw); + const methodName = getStepMethodName(step); + const transforms = React.useMemo( () => buildChangesetTransforms({ addresses, addressBook }), [addresses, addressBook] ); - const signature = methodSignature(methodName, argItems); + + const argRows = React.useMemo(() => buildArgRows({ step, txDetails }), [step, txDetails]); + + const signature = React.useMemo(() => { + const specs = Array.isArray(step?.arguments) ? step.arguments : null; + const abi = getStepAbi(txDetails, step); + return buildMethodSignature({ methodName, specs, abi }); + }, [methodName, step, txDetails]); + + const hasAnyArgs = argRows.some((r) => r.spec !== undefined || r.parsed !== undefined) && argRows.length > 0; return ( )} - {argItems.length > 0 && ( + {hasAnyArgs && ( Arguments: - {argItems.map((a) => { - const label = a.name - ? `${a.name}${a.type ? ` (${a.type})` : ""}` - : a.type - ? a.type - : `arg${a.index}`; - const tv = transformValue(a.value, transforms); + {argRows.map((row) => { + // Build a nice label: + const label = row.name + ? `${row.name}${row.type ? ` (${row.type})` : ""}` + : row.type + ? row.type + : `arg${row.index}`; + + const { primary, secondary } = transformArg({ + spec: row.spec, + parsed: row.parsed, + transforms, + }); + + const showSecondary = + secondary !== undefined && (row.spec?.transform === "enum" || primary.kind !== "address"); + return ( {label}: - {renderTransformedValue(tv)} + + {renderTransformedValue(primary)} + {showSecondary && renderSecondaryParsed({ secondary })} + ); })} @@ -459,7 +493,7 @@ function StepItem({ index, step, addressBook, addresses }) { )} - {parsedArgs == null && argsRaw != null && ( + {!hasAnyArgs && step.rawArgs != null && ( - {typeof argsRaw === "string" ? argsRaw : safeJsonStringify(argsRaw, 2)} + {typeof step.rawArgs === "string" ? step.rawArgs : safeJsonStringify(step.rawArgs, 2)} )} @@ -520,7 +554,6 @@ function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress const confirmationsCount = transaction?.confirmations?.length ?? 0; const confirmationsRequired = transaction?.confirmationsRequired ?? null; - const signers = Array.isArray(transaction?.confirmations) ? transaction.confirmations : []; return ( @@ -650,6 +683,7 @@ function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress step={step} addressBook={addressBook} addresses={txDetails?.addresses} + txDetails={txDetails} /> ))} @@ -660,7 +694,7 @@ function DetailsPanel({ txDetails, transaction, safeTxHash, chainId, safeAddress )} - + {safeUrl && ( + )} +