diff --git a/src/app/(sidebar)/transaction-dashboard/components/CallStackTrace.tsx b/src/app/(sidebar)/transaction-dashboard/components/CallStackTrace.tsx new file mode 100644 index 000000000..9bd5d5fc7 --- /dev/null +++ b/src/app/(sidebar)/transaction-dashboard/components/CallStackTrace.tsx @@ -0,0 +1,641 @@ +import React, { JSX, useState } from "react"; +import { Alert, Icon, Label, Text, Toggle } from "@stellar/design-system"; + +import { Box } from "@/components/layout/Box"; +import { SdsLink } from "@/components/SdsLink"; +import { TransactionTabEmptyMessage } from "@/components/TransactionTabEmptyMessage"; + +import { + DiagnosticEventJson, + formatDiagnosticEvents, + FormattedEventData, + ProcessedEvent, +} from "@/helpers/formatDiagnosticEvents"; +import { shortenStellarAddress } from "@/helpers/shortenStellarAddress"; +import { getStellarExpertNetwork } from "@/helpers/getStellarExpertNetwork"; +import { buildContractExplorerHref } from "@/helpers/buildContractExplorerHref"; + +import { getContractIdError } from "@/validate/methods/getContractIdError"; +import { useStore } from "@/store/useStore"; +import { STELLAR_EXPERT } from "@/constants/settings"; + +import { AnyObject, NetworkType } from "@/types/types"; + +export const CallStackTrace = ({ + diagnosticEvents, +}: { + diagnosticEvents: DiagnosticEventJson[] | AnyObject | undefined; +}) => { + const { network } = useStore(); + + const data = + diagnosticEvents && Array.isArray(diagnosticEvents) + ? formatDiagnosticEvents(diagnosticEvents) + : null; + + const [isCollapsedView, setIsCollapsedView] = useState(false); + + if (!data?.callStack?.length) { + return ( + + This transaction has no call stack trace. + + ); + } + + const truncateParams = ( + data: FormattedEventData[], + maxItems: number, + ): FormattedEventData[] => { + let itemCount = 0; + let ellipsisAdded = false; + + const isContainer = (type: string): boolean => { + return type === "vec" || type === "map"; + }; + + const addEllipsisIfNeeded = (items: any[], wasTruncated: boolean) => { + if (wasTruncated && !ellipsisAdded && items.length > 0) { + items.push({ value: "...", type: "ellipsis" }); + ellipsisAdded = true; + } + }; + + const truncateArray = (items: any[]) => { + const truncatedArray: any[] = []; + let wasTruncated = false; + + for (const item of items) { + if (itemCount >= maxItems) { + wasTruncated = true; + break; + } + + const result = traverse(item); + + if (result !== undefined) { + truncatedArray.push(result); + } + } + + addEllipsisIfNeeded(truncatedArray, wasTruncated); + + return { + truncatedArray, + wasTruncated, + }; + }; + + const traverse = (node: any): any => { + if ( + node && + typeof node === "object" && + "type" in node && + "value" in node + ) { + const isContainerType = isContainer(node.type); + + if (!isContainerType) { + itemCount++; + + if (itemCount > maxItems) { + return undefined; + } + + return node; + } + + if (Array.isArray(node.value)) { + const { truncatedArray } = truncateArray(node.value); + + if (truncatedArray.length > 0) { + return { ...node, value: truncatedArray }; + } + + return undefined; + } + + return node; + } + + if (Array.isArray(node)) { + const { truncatedArray } = truncateArray(node); + + return truncatedArray.length > 0 ? truncatedArray : undefined; + } + + return node; + }; + + const result: FormattedEventData[] | undefined = traverse(data); + + return result || []; + }; + + const hasEllipsisAnywhere = (data: any): boolean => { + if (!data) return false; + + if (Array.isArray(data)) { + return data.some((item) => { + if (item?.type === "ellipsis") return true; + + if (Array.isArray(item?.value)) return hasEllipsisAnywhere(item.value); + + return false; + }); + } + + return false; + }; + + const renderData = ({ + dataItem, + parentId, + voidAsEmptyFn, + isFnParams, + }: { + dataItem: FormattedEventData; + parentId?: string; + voidAsEmptyFn?: boolean; + isFnParams?: boolean; + }) => { + const { type, value } = dataItem; + + // Ellipsis (special case) for collapsed params + if (type === "ellipsis") { + return {value}; + } + + // Array + if (type === "vec") { + let renderVal = value; + + if (isFnParams && isCollapsedView) { + renderVal = truncateParams(value, 4); + } + + const hasEllipsis = hasEllipsisAnywhere(renderVal); + + return ( + + {/* Don’t show square brackets for param arrays */} + {parentId ? : null} + + {renderVal.map((v, vIndex) => { + const id = `vec-${vIndex}`; + + return ( + + {renderData({ dataItem: v, parentId: id })} + + + ); + })} + + {/* We need to hide extra brackets if there is ellipsis */} + {parentId && !hasEllipsis ? : null} + + ); + } + + // Object + if (type === "map") { + return ( + + + {value.map((v, vIndex) => { + return ( + + {renderData({ dataItem: v.key })} + + {renderData({ dataItem: v.val })} + + + ); + })} + {!hasEllipsisAnywhere(value.map((item) => item.val)) && ( + + )} + + ); + } + + // Void + if (type === "void") { + // For function params, we want to show void as () + return ; + } + + if (type === "address") { + return ; + } + + // Primitive + return ( + + ); + }; + + const renderContractId = (contractId: string) => ( + + at + + {shortenStellarAddress(contractId)} + + + ); + + const renderItemContent = (event: ProcessedEvent) => { + // Contract event + if (event.type === "contract_event") { + return ( + + + + + {event.name} + + {event.dataContractParams?.length ? ( + <> + + + {renderData({ + dataItem: { + type: "vec", + value: event.dataContractParams, + }, + voidAsEmptyFn: true, + isFnParams: true, + })} + + + + ) : null} + + + {event.contractId ? renderContractId(event.contractId) : null} + + + + data: + {renderData({ + dataItem: event.data, + voidAsEmptyFn: true, + parentId: `data-${event.name}`, + })} + + + ); + } + + // Function + return ( + + + + {event.name} + + + {renderData({ + dataItem: event.data, + voidAsEmptyFn: true, + isFnParams: true, + })} + + + + + {event.contractId ? renderContractId(event.contractId) : null} + + + {event.return ? ( + + + + + {renderData({ + dataItem: event.return.data, + parentId: event.name, + })} + {event.return?.contractId + ? renderContractId(event.return.contractId) + : null} + + ) : null} + + ); + }; + + const renderNested = ( + events: ProcessedEvent[], + parentId?: string, + depth: number = 0, + ) => { + return events.map((event, eventIndex) => { + return ( +
+ +
+ ); + }); + }; + + return ( + + + + This log shows all contract-related events that occurred during the + transaction in chronological order. + + + + + + setIsCollapsedView(!isCollapsedView)} + /> + + + + {data.errorLevel === "all" ? ( + + This transaction failed and was fully rolled back. All state changes + were reverted, and no contract effects were applied. + + ) : null} + + {data.errorLevel === "some" ? ( + + This transaction succeeded, but some internal contract calls failed + and their effects were rolled back. + + ) : null} + +
+
+ {renderNested(data.callStack)} +
+
+
+ ); +}; + +// ============================================================================= +// Helpers +// ============================================================================= +const isAsset = (value: unknown) => { + return typeof value === "string" && value?.split(":")?.length === 2; +}; + +const renderAssetString = (value: string) => { + if (isAsset(value)) { + const [code, issuer] = value.split(":"); + + return `${code}:${shortenStellarAddress(issuer)}`; + } + + return value; +}; + +// ============================================================================= +// Components +// ============================================================================= +const TypedValueItem = ({ + value, + type, + title, +}: { + value: any; + type?: string; + title?: string; +}) => { + const val = + type && ["string", "symbol"].includes(type) ? ( + {renderAssetString(value)} + ) : ( + `${value}` + ); + + return ( + + {val} + + ); +}; + +const AddressItem = ({ + value, + networkId, +}: { + value: any; + networkId: NetworkType; +}) => { + const seNetwork = getStellarExpertNetwork(networkId); + const isContract = !getContractIdError(value); + + let linkEl: string | JSX.Element; + + // Stellar account on Futurnet or Custom network + if (!isContract && !seNetwork) { + linkEl = shortenStellarAddress(value); + } else { + const props = { + href: isContract + ? buildContractExplorerHref(value) + : `${STELLAR_EXPERT}/${seNetwork}/account/${value}`, + ...(isContract ? { target: "_blank" } : {}), + }; + + linkEl = ( + + {shortenStellarAddress(value)} + + ); + } + + return ( + + {linkEl} + + ); +}; + +const Comma = ({ enabled }: { enabled?: boolean }) => { + if (enabled) { + return ,; + } + + return null; +}; + +const Colon = () => { + return :; +}; + +const Bracket = ({ + char, + hasComma, +}: { + char: "{" | "}" | "[" | "]" | "(" | ")"; + hasComma?: boolean; +}) => ( + + {char} + {hasComma ? : null} + +); + +const Quotes = ({ children }: { children: React.ReactNode }) => ( + <> + {'"'} + {children} + {'"'} + +); + +const EventItem = ({ + event, + depth, + renderContent, + renderNested, +}: { + event: ProcessedEvent; + depth: number; + renderContent: (event: ProcessedEvent) => JSX.Element; + renderNested: ( + events: ProcessedEvent[], + parentId?: string, + depth?: number, + ) => JSX.Element[]; +}) => { + const [isExpanded, setIsExpanded] = useState(true); + const hasNestedItems = event.nested.length > 0; + + const getVerticalLineTop = () => { + const basePoint = depth === 1 ? 16 : 0; + + return hasNestedItems ? basePoint + 26 : basePoint; + }; + + const getVerticalLineHeight = () => { + const basePoint = depth === 1 ? 16 : 0; + + return basePoint + verticalLineTop; + }; + + const verticalLineTop = getVerticalLineTop(); + + return ( + <> +
+ {hasNestedItems && isExpanded ? ( +
+ ) : null} + + {/* Don’t render arrow if it’s a top-level item without nested items */} + {depth === 0 && !hasNestedItems ? null : ( + setIsExpanded(!isExpanded)} + > + + + )} + + {event.isError ? ( + + + + ) : null} + + + {event.type} + + + {renderContent(event)} +
+ + {event.nested.length && isExpanded ? ( +
+ {renderNested(event.nested, undefined, depth + 1)} +
+ ) : null} + + ); +}; diff --git a/src/app/(sidebar)/transaction-dashboard/page.tsx b/src/app/(sidebar)/transaction-dashboard/page.tsx index a7f6ea142..e032ac221 100644 --- a/src/app/(sidebar)/transaction-dashboard/page.tsx +++ b/src/app/(sidebar)/transaction-dashboard/page.tsx @@ -43,6 +43,7 @@ import { TokenSummary } from "./components/TokenSummary"; import { Contracts } from "./components/Contracts"; import { ResourceProfiler } from "./components/ResourceProfiler"; import { ClassicOperations } from "./components/ClassicOperations"; +import { CallStackTrace } from "./components/CallStackTrace"; import "./styles.scss"; @@ -54,7 +55,8 @@ export default function TransactionDashboard() { | "tx-state-change" | "tx-resource-profiler" | "tx-signatures" - | "tx-fee-breakdown"; + | "tx-fee-breakdown" + | "tx-call-stack-trace"; const { network, txDashboard } = useStore(); @@ -388,7 +390,7 @@ export default function TransactionDashboard() { , isDisabled: !isDataLoaded, }} @@ -405,8 +407,18 @@ export default function TransactionDashboard() { isDisabled: !isDataLoaded, }} tab4={{ + id: "tx-call-stack-trace", + label: "Call stack trace", + content: ( + + ), + isDisabled: !isDataLoaded, + }} + tab5={{ id: "tx-state-change", - label: "State Change", + label: "State change", content: isDataLoaded ? ( ) : ( @@ -414,21 +426,21 @@ export default function TransactionDashboard() { ), isDisabled: !isDataLoaded, }} - tab5={{ + tab6={{ id: "tx-resource-profiler", - label: "Resource Profiler", + label: "Resource profiler", content: , isDisabled: !isDataLoaded, }} - tab6={{ + tab7={{ id: "tx-signatures", label: "Signatures", content: , isDisabled: !isDataLoaded, }} - tab7={{ + tab8={{ id: "tx-fee-breakdown", - label: "Fee Breakdown", + label: "Fee breakdown", content: , isDisabled: !isDataLoaded, }} diff --git a/src/app/(sidebar)/transaction-dashboard/styles.scss b/src/app/(sidebar)/transaction-dashboard/styles.scss index 0bd9dcc28..613eb2a85 100644 --- a/src/app/(sidebar)/transaction-dashboard/styles.scss +++ b/src/app/(sidebar)/transaction-dashboard/styles.scss @@ -543,3 +543,359 @@ } } } + +// ============================================================================= +// Call Stack Trace +// ============================================================================= +.CallStackTrace { + border: 1px solid var(--sds-clr-gray-06); + padding: pxToRem(24px); + border-radius: pxToRem(8px); + + font-family: var(--sds-ff-monospace); + font-size: pxToRem(14px); + line-height: pxToRem(18px); + letter-spacing: -0.28px; + overflow: hidden; + + &__scrollable { + overflow: auto; + } + + // All failed: Red background on level 1 items for failed transaction + &[data-error-level="all"] { + .CallStackTrace__event__nested { + & > .CallStackTrace__event { + &[data-is-error="true"] { + background-color: var(--sds-clr-red-05) !important; + } + } + } + } + + // Some failed: Make labels red, no background change + &[data-error-level="some"] { + .CallStackTrace__event { + &[data-is-error="true"] { + .CallStackTrace__itemType { + --CallStackTrace--itemType-bg-color: #{var(--sds-clr-red-02)}; + --CallStackTrace--itemType-border-color: #{var(--sds-clr-red-06)}; + --CallStackTrace--itemType-text-color: #{var(--sds-clr-red-11)}; + } + } + } + } + + &__event { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + min-width: fit-content; + position: relative; + overflow-y: hidden; + + &__info { + display: flex; + flex-direction: row; + gap: pxToRem(4px); + + .CallStackTrace__icon { + // Match the actual line height (with type offsets) + height: pxToRem(22px); + + svg { + stroke: var(--sds-clr-gray-09); + display: none; + } + + &[data-visible="true"] { + cursor: pointer; + + svg { + display: block; + } + } + } + } + + &[data-is-error="true"] { + & > .CallStackTrace__event__info { + border-radius: pxToRem(4px); + padding-top: pxToRem(2px); + padding-bottom: pxToRem(2px); + } + } + + &__nested { + display: flex; + gap: pxToRem(8px); + flex-direction: column; + + // Level 1 items + & > .CallStackTrace__event { + background-color: var(--sds-clr-gray-03); + border-radius: pxToRem(4px); + padding: pxToRem(16px); + padding-left: pxToRem(20px); + margin-left: pxToRem(26px); + + // Level 2 and deeper items + .CallStackTrace__event__nested > .CallStackTrace__event { + padding-top: pxToRem(2px); + padding-bottom: pxToRem(2px); + padding-right: 0; + margin-left: pxToRem(8px); + } + } + } + } + + &__verticalLine { + width: 1px; + background-color: var(--sds-clr-gray-08); + position: absolute; + // top, left, and height are set in JS + } + + &__itemType { + --CallStackTrace--itemType-bg-color: #{var(--sds-clr-teal-03)}; + --CallStackTrace--itemType-border-color: #{var(--sds-clr-teal-06)}; + --CallStackTrace--itemType-text-color: #{var(--sds-clr-teal-11)}; + + border: 1px solid var(--CallStackTrace--itemType-border-color); + color: var(--CallStackTrace--itemType-text-color); + background-color: var(--CallStackTrace--itemType-bg-color); + + padding: pxToRem(2px) pxToRem(6px); + border-radius: pxToRem(6px); + font-size: pxToRem(12px); + line-height: pxToRem(16px); + font-weight: var(--sds-fw-semi-bold); + height: fit-content; + + &[data-type="contract_event"] { + --CallStackTrace--itemType-bg-color: #{var(--sds-clr-lilac-03)}; + --CallStackTrace--itemType-border-color: #{var(--sds-clr-lilac-06)}; + --CallStackTrace--itemType-text-color: #{var(--sds-clr-lilac-11)}; + } + } + + &__itemContent { + display: flex; + flex-wrap: wrap; + } + + &__itemFunc { + &__func, + .CallStackTrace__bracket { + color: var(--sds-clr-gray-12); + font-weight: var(--sds-fw-bold); + } + + &__params { + padding: 0 pxToRem(4px); + + .CallStackTrace__comma { + padding-right: pxToRem(4px); + } + } + } + + &__itemContract { + display: inline-flex; + gap: pxToRem(4px); + padding-left: pxToRem(4px); + height: pxToRem(18px); + } + + &__itemData { + display: inline; + flex-basis: 100%; + color: var(--sds-clr-gray-12); + padding-top: pxToRem(4px); + + & > span:first-child { + padding-right: pxToRem(4px); + } + } + + &__itemReturn { + padding-left: pxToRem(4px); + display: inline-flex; + gap: pxToRem(4px); + + .CallStackTrace__icon { + height: pxToRem(18px); + + svg { + stroke: var(--sds-clr-teal-08); + display: block; + } + } + + .CallStackTrace__comma { + padding-right: pxToRem(4px); + } + } + + &__itemError { + padding-right: pxToRem(4px); + + &.CallStackTrace__icon { + svg { + display: block; + stroke: var(--sds-clr-red-09); + } + } + } + + &__item { + display: inline; + vertical-align: top; + } + + &__itemObject { + display: inline; + vertical-align: top; + + &__item { + display: inline; + } + + // Bracket space + .CallStackTrace__bracket + .CallStackTrace__itemObject__item, + .CallStackTrace__itemObject__item + .CallStackTrace__bracket { + padding-left: pxToRem(4px); + } + } + + &__itemArray { + display: inline; + vertical-align: top; + + &__items { + display: inline; + } + + // Bracket space + .CallStackTrace__bracket + .CallStackTrace__itemArray__items:not(:empty) { + padding-left: pxToRem(4px); + padding-right: pxToRem(4px); + + // Remove padding if there is an ellipsis + &[data-has-ellipsis="true"] { + padding-right: 0; + } + } + + &__item { + display: inline; + vertical-align: top; + } + } + + &__typedValueItem { + white-space: nowrap; + color: var(--sds-clr-gray-12); + + &[data-type] { + display: inline-flex; + + &::after { + display: block; + content: attr(data-type); + margin-top: pxToRem(6px); + padding-left: pxToRem(2px); + + white-space: nowrap; + font-size: pxToRem(12px); + line-height: pxToRem(16px); + color: var(--sds-clr-gray-11); + } + } + + // Colors + // Symbol + &[data-type="sym"] { + color: var(--sds-clr-navy-10); + } + + // String + &[data-type="string"] { + color: var(--sds-clr-gray-12); + } + + // Bytes + &[data-type="bytes"], + &[data-type="bytesN"] { + color: var(--sds-clr-gray-10); + } + + // Number + &[data-type="u8"], + &[data-type="u16"], + &[data-type="u32"], + &[data-type="u64"], + &[data-type="i8"], + &[data-type="i16"], + &[data-type="i32"], + &[data-type="i64"] { + color: var(--sds-clr-mint-08); + } + + // Big int + &[data-type="i128"], + &[data-type="i256"], + &[data-type="u128"], + &[data-type="u256"] { + color: var(--sds-clr-teal-08); + } + + // Bool + &[data-type="bool"] { + color: var(--sds-clr-pink-10); + } + + // Address + &[data-type="address"] { + color: var(--sds-clr-gray-10); + } + } + + &__bracket, + &__comma, + &__colon, + &__ellipsis { + color: var(--sds-clr-gray-12); + } + + &__bracket { + font-weight: var(--sds-fw-bold); + } + + &__colon { + margin-right: pxToRem(4px); + } + + &__icon { + flex-shrink: 0; + display: inline-flex; + justify-content: center; + align-items: center; + width: pxToRem(16px); + height: pxToRem(18px); + + svg { + transition: transform ease-in-out 0.2s; + transform: rotate(0deg); + width: 100%; + height: 100%; + } + + &[data-is-expanded="false"] { + svg { + transform: rotate(-90deg); + } + } + } +} diff --git a/src/components/TabView/index.tsx b/src/components/TabView/index.tsx index ccc6edd5e..357c4655f 100644 --- a/src/components/TabView/index.tsx +++ b/src/components/TabView/index.tsx @@ -23,6 +23,9 @@ type TabViewProps = { tab5?: Tab; tab6?: Tab; tab7?: Tab; + tab8?: Tab; + tab9?: Tab; + tab10?: Tab; onTabChange: (id: string) => void; activeTabId: string; rightElement?: React.ReactNode; diff --git a/src/components/Tabs/styles.scss b/src/components/Tabs/styles.scss index f4a5280a8..65615db4a 100644 --- a/src/components/Tabs/styles.scss +++ b/src/components/Tabs/styles.scss @@ -6,14 +6,15 @@ display: flex; align-items: center; - gap: pxToRem(8px); + gap: pxToRem(4px); flex-wrap: wrap; + padding: pxToRem(6px) 0; .Tab { position: relative; font-size: pxToRem(14px); line-height: pxToRem(20px); - font-weight: var(--sds-fw-medium); + font-weight: var(--sds-fw-semi-bold); white-space: nowrap; color: var(--Tabs-default-text); background-color: var(--Tabs-default-background); diff --git a/src/constants/networkLimits.ts b/src/constants/networkLimits.ts index 1a5a697c8..c1f7f542c 100644 --- a/src/constants/networkLimits.ts +++ b/src/constants/networkLimits.ts @@ -78,36 +78,36 @@ export const MAINNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "759949737", - "760533009", - "760981473", - "761873993", - "762654761", - "763211913", - "763986321", - "764372305", - "765310973", - "766353473", - "766526733", - "767200145", - "767862469", - "768736417", - "769079513", - "769346157", - "769900365", - "770671501", - "771407257", - "771707833", - "772057009", - "772711109", - "773748293", - "774298277", - "774596885", - "774964765", - "775748333", - "776769589", - "777132513", - "777472237" + "734987214", + "735395346", + "736292614", + "737094086", + "737381086", + "737995950", + "738818622", + "739372270", + "739950126", + "739779302", + "740500630", + "741589278", + "742031213", + "742814137", + "743101929", + "743393749", + "744321737", + "744617157", + "745035633", + "745157193", + "745977605", + "746877909", + "746900665", + "747337629", + "747627725", + "748413573", + "748909625", + "749249961", + "749541173", + "749725505" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -146,36 +146,36 @@ export const TESTNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "513740736", - "513758204", - "514444783", - "514455423", - "514471523", - "514459011", - "514425827", - "508299283", - "509220104", - "509238804", - "509246772", - "509285549", - "508842657", - "508859077", - "510177526", - "510129282", - "506441710", - "506461146", - "506468034", - "506472790", - "506476438", - "506484658", - "506486030", - "506512246", - "506447318", - "506462382", - "506652376", - "507206030", - "507211734", - "507222702" + "517984233", + "518608717", + "517961834", + "517967810", + "517974038", + "517989562", + "517477838", + "517991097", + "518694029", + "519561095", + "520305875", + "520949604", + "521559027", + "522340228", + "523407390", + "524986607", + "525947964", + "526820560", + "527579212", + "528572058", + "529394401", + "529648481", + "530473809", + "530900493", + "530907849", + "531092996", + "531750170", + "531742446", + "532135555", + "532434588" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -214,36 +214,36 @@ export const FUTURENET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232", - "49213232" + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481", + "49939481" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", diff --git a/src/helpers/formatDiagnosticEvents.ts b/src/helpers/formatDiagnosticEvents.ts new file mode 100644 index 000000000..a080de02c --- /dev/null +++ b/src/helpers/formatDiagnosticEvents.ts @@ -0,0 +1,401 @@ +// ============================================================================= +// Constants +// ============================================================================= +const EVENT_TOPIC_SYMBOLS = { + FN_CALL: "fn_call", + FN_RETURN: "fn_return", + CORE_METRICS: "core_metrics", +} as const; + +const EVENT_TYPES = { + DIAGNOSTIC: "diagnostic", + CONTRACT: "contract", +} as const; + +// ============================================================================= +// Type Definitions +// ============================================================================= +export type ErrorLevel = "all" | "some" | undefined; +export type EventTopicSymbol = + (typeof EVENT_TOPIC_SYMBOLS)[keyof typeof EVENT_TOPIC_SYMBOLS]; +export type EventType = (typeof EVENT_TYPES)[keyof typeof EVENT_TYPES]; + +type ScError = + | { contract: number } + | { wasm_vm: unknown } + | { context: unknown } + | { storage: unknown } + | { object: unknown } + | { crypto: unknown } + | { events: unknown } + | { budget: unknown } + | { value: unknown } + | { auth: unknown }; + +type ScInt128 = { hi: number; lo: number }; +type ScInt256 = { + hi_hi: number; + hi_lo: number; + lo_hi: number; + lo_lo: number; +}; + +type ScMapEntry = { key: ScValType; val: ScValType }; +type FormattedMapEntry = { key: FormattedEventData; val: FormattedEventData }; + +type FormattedPrimitive = + | { type: "symbol"; value: string } + | { type: "bytes"; value: string } + | { type: "address"; value: string } + | { type: "i32"; value: number } + | { type: "i64"; value: bigint | string } + | { type: "i128"; value: ScInt128 | string } + | { type: "i256"; value: ScInt256 } + | { type: "u32"; value: number } + | { type: "u64"; value: bigint | string } + | { type: "u128"; value: ScInt128 | string } + | { type: "u256"; value: ScInt256 } + | { type: "bool"; value: boolean } + | { type: "string"; value: string } + | { type: "error"; value: ScError } + | { type: "void"; value: null }; + +export type FormattedEventData = + | FormattedPrimitive + | { type: "vec"; value: FormattedEventData[] } + | { type: "map"; value: FormattedMapEntry[] } + | { type: "literal"; value: string } + | { type: "ellipsis"; value: string } + | { type: undefined; value: unknown }; + +interface CoreMetric { + key: string | undefined; + value: any; + type: string; +} + +export interface ProcessedEvent { + type: string; + name: string; + contractId: string | null; + data: FormattedEventData; + dataContractParams?: FormattedEventData[]; + isError: boolean; + nested: ProcessedEvent[]; + return?: ProcessedEvent; +} + +interface ScValType { + symbol?: string; + bytes?: string; + address?: string; + i32?: number; + i64?: bigint | string; + i128?: ScInt128 | string; + i256?: ScInt256; + u32?: number; + u64?: bigint | string; + u128?: ScInt128 | string; + u256?: ScInt256; + bool?: boolean; + string?: string; + vec?: ScValType[]; + map?: ScMapEntry[]; + error?: ScError; + void?: null; +} + +interface DiagnosticEventBody { + v0: { + topics: ScValType[]; + data: ScValType | string; + }; +} + +interface DiagnosticEvent { + ext: string; + contract_id: string | null; + type_: EventType; + body: DiagnosticEventBody; +} + +export interface DiagnosticEventJson { + in_successful_contract_call: boolean; + event: DiagnosticEvent; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +const getEventType = (event: DiagnosticEvent): string => { + switch (event.type_) { + case EVENT_TYPES.DIAGNOSTIC: + return event.body?.v0?.topics[0]?.symbol || ""; + case EVENT_TYPES.CONTRACT: + return "contract_event"; + default: + return ""; + } +}; + +const getEventName = (event: DiagnosticEvent): string => { + const topics = event.body?.v0?.topics; + if (!topics || topics.length === 0) return ""; + + const topicSymbol = topics[0]?.symbol; + + if (event.type_ === EVENT_TYPES.DIAGNOSTIC) { + if (topicSymbol === EVENT_TOPIC_SYMBOLS.FN_RETURN) { + // Use the function name (2nd topic) + return topics?.[1]?.symbol || ""; + } else if (topicSymbol === EVENT_TOPIC_SYMBOLS.FN_CALL) { + // Use the function name (3rd topic) + return topics?.[2]?.symbol || ""; + } + + return topicSymbol || ""; + } + + // No name for contract events + if (event.type_ === EVENT_TYPES.CONTRACT) { + return ""; + } + + return ""; +}; + +type PrimitiveKey = Exclude; + +const isPrimitiveKey = (key: keyof ScValType): key is PrimitiveKey => + key !== "vec" && key !== "map"; + +const formatEventData = (data: ScValType | string): FormattedEventData => { + // Handle plain string case (e.g., "void" literal) + if (typeof data === "string") { + if (data === "void") { + return { value: null, type: "void" }; + } + + return { value: data, type: "literal" }; + } + + if (typeof data === "object" && data !== null) { + const keys = Object.keys(data) as Array; + + if (keys.length === 1) { + const key = keys[0]; + const value = data[key]; + + switch (key) { + // Array + case "vec": + if (Array.isArray(value)) { + return { + value: value.map((item: ScValType) => formatEventData(item)), + type: "vec", + }; + } + break; + // Object + case "map": + if (Array.isArray(value)) { + return { + value: (value as ScMapEntry[]).map((item) => ({ + key: formatEventData(item.key), + val: formatEventData(item.val), + })), + type: "map", + }; + } + break; + // Primitives + default: + if (isPrimitiveKey(key) && value !== undefined) { + switch (key) { + case "symbol": + case "bytes": + case "address": + case "string": + return { value: value as string, type: key }; + case "i32": + case "u32": + return { value: value as number, type: key }; + case "i64": + case "u64": + return { value: value as bigint | string, type: key }; + case "i128": + case "u128": + return { value: value as ScInt128 | string, type: key }; + case "i256": + case "u256": + return { value: value as ScInt256, type: key }; + case "bool": + return { value: value as boolean, type: key }; + case "error": + return { value: value as ScError, type: key }; + case "void": + return { value: null, type: "void" }; + } + } + } + } + } + + // Non-object or unrecognized case + return { value: data, type: undefined }; +}; + +const calculateErrorLevel = (callStack: ProcessedEvent[]): ErrorLevel => { + // Recursively flattens the event tree to get all events including nested ones. + const getAllEvents = (events: ProcessedEvent[]): ProcessedEvent[] => { + const allEvents: ProcessedEvent[] = []; + + events.forEach((event) => { + allEvents.push(event); + + if (event.nested && event.nested.length > 0) { + allEvents.push(...getAllEvents(event.nested)); + } + + if (event.return) { + allEvents.push(event.return); + } + }); + + return allEvents; + }; + + const allEvents = getAllEvents(callStack); + + if (allEvents.length === 0) return undefined; + + const errorEvents = allEvents.filter((event) => event.isError === true); + + if (errorEvents.length === 0) return undefined; + if (errorEvents.length === allEvents.length) return "all"; + + return "some"; +}; + +// ============================================================================= +// Main Processing +// ============================================================================= +const formatContractParams = ( + eventType: string | undefined, + topics: ScValType[] | undefined, +): FormattedEventData[] | undefined => { + if (eventType !== EVENT_TYPES.CONTRACT || !topics) { + return undefined; + } + + return topics.map((topic) => formatEventData(topic)); +}; + +const processEvents = ( + dEvents: DiagnosticEventJson[], +): { + callStack: ProcessedEvent[]; + coreMetrics: CoreMetric[]; +} => { + const currentStack: ProcessedEvent[] = []; + const result: ProcessedEvent[] = []; + const coreMetrics: CoreMetric[] = []; + + dEvents.forEach((ev) => { + const evType = ev.event?.type_; + const evTopics = ev.event?.body?.v0?.topics; + const topicSymbol = evTopics?.[0]?.symbol; + + // Core metrics + if (topicSymbol === EVENT_TOPIC_SYMBOLS.CORE_METRICS) { + const dataEntries = ev.event?.body?.v0?.data + ? Object.entries(ev.event.body.v0.data) + : []; + + if (dataEntries.length > 0) { + const [type, value] = dataEntries[0]; + coreMetrics.push({ + key: evTopics?.[1]?.symbol, + value, + type, + }); + } + return; + } + + // Diagnostic and contract events + if (evType !== EVENT_TYPES.DIAGNOSTIC && evType !== EVENT_TYPES.CONTRACT) { + return; + } + + const eventObj: ProcessedEvent = { + type: getEventType(ev.event), + name: getEventName(ev.event), + contractId: ev.event?.contract_id || null, + data: formatEventData(ev.event?.body?.v0?.data), + dataContractParams: formatContractParams(evType, evTopics), + isError: !ev.in_successful_contract_call, + nested: [], + }; + + const addEvent = (event: ProcessedEvent): void => { + if (currentStack.length > 0) { + currentStack[currentStack.length - 1].nested.push(event); + } else { + result.push(event); + } + }; + + // Process event based on type and add to appropriate location in call stack + if (evType === EVENT_TYPES.DIAGNOSTIC) { + if (topicSymbol === EVENT_TOPIC_SYMBOLS.FN_CALL) { + // Add to parent/result and push to current stack + addEvent(eventObj); + currentStack.push(eventObj); + } else if (topicSymbol === EVENT_TOPIC_SYMBOLS.FN_RETURN) { + // Add to current function's return property and remove from current stack + if (currentStack.length > 0) { + currentStack[currentStack.length - 1].return = eventObj; + currentStack.pop(); + } else { + // Edge case: fn_return without matching fn_call - add to root + result.push(eventObj); + } + } else { + // Add to current stack + addEvent(eventObj); + } + } else if (evType === EVENT_TYPES.CONTRACT) { + // Add to current stack + addEvent(eventObj); + } + }); + + return { callStack: result, coreMetrics }; +}; + +export const formatDiagnosticEvents = ( + dEvents: DiagnosticEventJson[], +): { + callStack: ProcessedEvent[]; + // In case we want to include core metrics in the future + // coreMetrics: CoreMetric[]; + errorLevel: ErrorLevel; +} => { + // Early return for empty input + if (!dEvents || dEvents.length === 0) { + return { + callStack: [], + // coreMetrics: [], + errorLevel: undefined, + }; + } + + const { callStack } = processEvents(dEvents); + + return { + callStack, + // coreMetrics, + errorLevel: calculateErrorLevel(callStack), + }; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 7ce4acebd..856179f7e 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -2,6 +2,7 @@ import React from "react"; import { NetworkError, rpc as StellarRpc, xdr } from "@stellar/stellar-sdk"; import type { JSONSchema7 } from "json-schema"; import { TransactionBuildParams } from "@/store/createStore"; +import type { DiagnosticEventJson } from "@/helpers/formatDiagnosticEvents"; // ============================================================================= // Generic @@ -598,7 +599,7 @@ export type RpcTxJsonResponse = { envelopeJson: AnyObject; resultJson: AnyObject; resultMetaJson: AnyObject; - diagnosticEventsJson: AnyObject; + diagnosticEventsJson: DiagnosticEventJson[] | AnyObject | undefined; }; // =============================================================================