From e9de1a571132079617f487fbf24aa9a0e4d3a9f4 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Fri, 17 Jul 2026 21:20:22 -0700 Subject: [PATCH] support multiple concatenated XDR values --- src/app/(sidebar)/xdr/view/page.tsx | 142 ++++++++++++++++++++++------ src/helpers/decodeXdr.ts | 54 +++++++++++ src/helpers/splitXdrBlocks.ts | 25 +++++ tests/unit/splitXdrBlocks.test.ts | 38 ++++++++ 4 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 src/helpers/splitXdrBlocks.ts create mode 100644 tests/unit/splitXdrBlocks.test.ts diff --git a/src/app/(sidebar)/xdr/view/page.tsx b/src/app/(sidebar)/xdr/view/page.tsx index e52c9d864..3374fead3 100644 --- a/src/app/(sidebar)/xdr/view/page.tsx +++ b/src/app/(sidebar)/xdr/view/page.tsx @@ -12,6 +12,7 @@ import { } from "@stellar/design-system"; import { TransactionBuilder } from "@stellar/stellar-sdk"; import { useQueryClient } from "@tanstack/react-query"; +import { stringify as stringifyLosslessJson } from "lossless-json"; import { useLatestTxn } from "@/query/useLatestTxn"; import { XDR_TYPE_TRANSACTION_ENVELOPE } from "@/constants/settings"; @@ -30,7 +31,7 @@ import { JsonCodeWrapToggle } from "@/components/JsonCodeWrapToggle"; import { delayedAction } from "@/helpers/delayedAction"; import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; import { prettifyJsonString } from "@/helpers/prettifyJsonString"; -import { decodeXdr } from "@/helpers/decodeXdr"; +import { decodeXdr, decodeXdrList } from "@/helpers/decodeXdr"; import { useIsXdrInit } from "@/hooks/useIsXdrInit"; import { useCodeWrappedSetting } from "@/hooks/useCodeWrappedSetting"; @@ -38,6 +39,7 @@ import { useStore } from "@/store/useStore"; import { trackEvent, TrackingEvent } from "@/metrics/tracking"; import { AnyObject } from "@/types/types"; +import { splitXdrBlocks } from "@/helpers/splitXdrBlocks"; export default function ViewXdr() { const { xdr, network } = useStore(); @@ -69,16 +71,38 @@ export default function ViewXdr() { const isFetchingLatestTxn = isLatestTxnFetching || isLatestTxnLoading; - const xdrJsonDecoded = decodeXdr({ - xdrType: xdr.type, - xdrBlob: xdr.blob, - isReady: isXdrInit, - trackingEvents: { - success: TrackingEvent.XDR_TO_JSON_SUCCESS, - successStream: TrackingEvent.XDR_TO_JSON_STREAM_SUCCESS, - error: TrackingEvent.XDR_FROM_JSON_ERROR, - }, - }); + const blocks = splitXdrBlocks(xdr.blob); + const isMulti = blocks.length > 1; + + const multiDecoded = isMulti + ? decodeXdrList({ + xdrBlobs: blocks, + isReady: isXdrInit, + trackingEvents: { + success: TrackingEvent.XDR_TO_JSON_SUCCESS, + successStream: TrackingEvent.XDR_TO_JSON_STREAM_SUCCESS, + error: TrackingEvent.XDR_FROM_JSON_ERROR, + }, + }) + : null; + + // Flatten every successfully decoded entry into a single array for rendering, + // and collect any entries that could not be decoded. + const multiJsonArray = multiDecoded?.flatMap((r) => r.jsonArray) ?? []; + const multiErrors = multiDecoded?.filter((r) => r.error) ?? []; + + const xdrJsonDecoded = !isMulti + ? decodeXdr({ + xdrType: xdr.type, + xdrBlob: xdr.blob, + isReady: isXdrInit, + trackingEvents: { + success: TrackingEvent.XDR_TO_JSON_SUCCESS, + successStream: TrackingEvent.XDR_TO_JSON_STREAM_SUCCESS, + error: TrackingEvent.XDR_FROM_JSON_ERROR, + }, + }) + : null; const txnFromXdr = () => { try { @@ -162,15 +186,13 @@ export default function ViewXdr() { {`${jsonArray.length} items`} {jsonArray.map((j, index) => ( - <> - - + ))} ] @@ -240,19 +262,25 @@ export default function ViewXdr() { disabled={isFetchingLatestTxn} /> - + {!isMulti ? ( + <> + - + + + ) : null} <> - {!xdr.blob || !xdr.type ? ( + {!xdr.blob ? ( - {!xdr.blob - ? "Enter a Base64 encoded XDR blob to decode." - : "Please select a XDR type"} + Enter a Base64 encoded XDR blob to decode. + + ) : !isMulti && !xdr.type ? ( + + Please select a XDR type ) : null} @@ -273,7 +301,61 @@ export default function ViewXdr() { <> - {xdrJsonDecoded?.jsonString && xdrJsonDecoded?.jsonArray ? ( + {isMulti ? ( + multiJsonArray.length > 0 || multiErrors.length > 0 ? ( + + {multiErrors.length > 0 ? ( + + <> + {multiErrors.map((e) => ( +
+ {e.error} +
+ ))} + +
+ ) : null} + + {multiJsonArray.length > 0 ? ( + <> +
+ {renderJsonContent({ + jsonArray: multiJsonArray, + xdr: "", + })} +
+ + + { + setIsCodeWrapped(isChecked); + }} + /> + + + + + ) : null} +
+ ) : null + ) : xdrJsonDecoded?.jsonString && xdrJsonDecoded?.jsonArray ? ( <>{renderClaimableBalanceIds()} diff --git a/src/helpers/decodeXdr.ts b/src/helpers/decodeXdr.ts index 3270bdac4..f273368e6 100644 --- a/src/helpers/decodeXdr.ts +++ b/src/helpers/decodeXdr.ts @@ -77,3 +77,57 @@ export const decodeXdr = ({ return decoded(); }; + +/** + * Decodes a list of Base64 XDR blocks, guessing the XDR type of each block + * independently. Used by the View XDR page when several XDR values are pasted + * at once (e.g. the per-entry `ScSpecEntry` output of the Contract Explorer). + * + * For each block it runs `StellarXdr.guess()` and decodes with the first + * candidate type that succeeds. Blocks that no candidate can decode are + * returned with an `error` and an empty `jsonArray`, so callers can render the + * successful entries alongside a list of failures. + */ +export const decodeXdrList = ({ + xdrBlobs, + isReady, + trackingEvents, +}: { + xdrBlobs: string[]; + isReady: boolean; + trackingEvents?: { + success: TrackingEvent; + successStream: TrackingEvent; + error: TrackingEvent; + }; +}) => { + if (!isReady) { + return null; + } + + return xdrBlobs.map((xdrBlob, index) => { + let guesses: string[] = []; + + try { + guesses = StellarXdr.guess(xdrBlob); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + guesses = []; + } + + for (const xdrType of guesses) { + const res = decodeXdr({ xdrType, xdrBlob, isReady, trackingEvents }); + + if (res?.jsonString) { + return { index, xdrType, jsonArray: res.jsonArray ?? [], error: "" }; + } + } + + return { + index, + xdrType: "", + jsonArray: [] as ReturnType[], + error: `Entry ${index + 1}: unable to decode as any known XDR type.`, + }; + }); +}; diff --git a/src/helpers/splitXdrBlocks.ts b/src/helpers/splitXdrBlocks.ts new file mode 100644 index 000000000..3680c5e56 --- /dev/null +++ b/src/helpers/splitXdrBlocks.ts @@ -0,0 +1,25 @@ +/** + * Splits pasted XDR input into individual base64 blocks. Drops `//` comment + * lines (e.g. the `// contractspecv0` header the Contract Explorer emits), + * splits on blank lines so each entry is its own block, and collapses + * whitespace within a block. A single blob returns `[blob]`. + * + * @param raw - The raw text pasted into the XDR input. + * @returns An array of clean base64 blocks (empty blocks are dropped). + */ +export const splitXdrBlocks = (raw: string): string[] => { + if (!raw) { + return []; + } + + return raw + .split(/\n\s*\n/) // blank lines separate entries + .map((block) => + block + .split("\n") + .filter((line) => !line.trim().startsWith("//")) // drop comment lines + .join("") + .replace(/\s+/g, ""), + ) // base64 has no internal whitespace + .filter(Boolean); +}; diff --git a/tests/unit/splitXdrBlocks.test.ts b/tests/unit/splitXdrBlocks.test.ts new file mode 100644 index 000000000..f1ba22a58 --- /dev/null +++ b/tests/unit/splitXdrBlocks.test.ts @@ -0,0 +1,38 @@ +import { splitXdrBlocks } from "../../src/helpers/splitXdrBlocks"; + +describe("splitXdrBlocks", () => { + it("returns an empty array for empty input", () => { + expect(splitXdrBlocks("")).toEqual([]); + expect(splitXdrBlocks(" \n \n")).toEqual([]); + }); + + it("returns a single block unchanged for one blob", () => { + const blob = "AAAAAgAAAAA="; + expect(splitXdrBlocks(blob)).toEqual([blob]); + }); + + it("splits multiple blobs separated by blank lines", () => { + const input = "AAAA\n\nBBBB\n\nCCCC"; + expect(splitXdrBlocks(input)).toEqual(["AAAA", "BBBB", "CCCC"]); + }); + + it("splits on blank lines that contain whitespace", () => { + const input = "AAAA\n \nBBBB"; + expect(splitXdrBlocks(input)).toEqual(["AAAA", "BBBB"]); + }); + + it("drops `//` comment lines, including a leading section header", () => { + const input = "// contractspecv0\n\nAAAA\n\nBBBB"; + expect(splitXdrBlocks(input)).toEqual(["AAAA", "BBBB"]); + }); + + it("drops a comment line directly above its entry", () => { + const input = "// contractspecv0\nAAAA\n\nBBBB"; + expect(splitXdrBlocks(input)).toEqual(["AAAA", "BBBB"]); + }); + + it("collapses whitespace inside a block", () => { + const input = "AA AA\nBB\tBB"; + expect(splitXdrBlocks(input)).toEqual(["AAAABBBB"]); + }); +});