Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 112 additions & 30 deletions src/app/(sidebar)/xdr/view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,14 +31,15 @@ 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";
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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -162,15 +186,13 @@ export default function ViewXdr() {
<span className="PrettyJson__expandSize">{`${jsonArray.length} items`}</span>
</div>
{jsonArray.map((j, index) => (
<>
<PrettyJsonTransaction
// Using index here because we can't get something unique from the JSON
key={`pretty-json-${index}`}
json={j}
xdr={xdr}
isCodeWrapped={isCodeWrapped}
/>
</>
<PrettyJsonTransaction
// Using index here because we can't get something unique from the JSON
key={`pretty-json-${index}`}
json={j}
xdr={xdr}
isCodeWrapped={isCodeWrapped}
/>
))}
<span className="PrettyJson__bracket">]</span>
</div>
Expand Down Expand Up @@ -240,19 +262,25 @@ export default function ViewXdr() {
disabled={isFetchingLatestTxn}
/>

<TransactionHashReadOnlyField
xdr={xdr.blob}
networkPassphrase={network.passphrase}
/>
{!isMulti ? (
<>
<TransactionHashReadOnlyField
xdr={xdr.blob}
networkPassphrase={network.passphrase}
/>

<XdrTypeSelect error={xdrJsonDecoded?.error} />
<XdrTypeSelect error={xdrJsonDecoded?.error} />
</>
) : null}

<>
{!xdr.blob || !xdr.type ? (
{!xdr.blob ? (
<Text as="div" size="sm">
{!xdr.blob
? "Enter a Base64 encoded XDR blob to decode."
: "Please select a XDR type"}
Enter a Base64 encoded XDR blob to decode.
</Text>
) : !isMulti && !xdr.type ? (
<Text as="div" size="sm">
Please select a XDR type
</Text>
) : null}
</>
Expand All @@ -273,7 +301,61 @@ export default function ViewXdr() {
</Box>

<>
{xdrJsonDecoded?.jsonString && xdrJsonDecoded?.jsonArray ? (
{isMulti ? (
multiJsonArray.length > 0 || multiErrors.length > 0 ? (
<Box gap="lg">
{multiErrors.length > 0 ? (
<Alert
variant="warning"
placement="inline"
title={`${multiErrors.length} of ${blocks.length} entries could not be decoded`}
>
<>
{multiErrors.map((e) => (
<div key={`view-xdr-decode-error-${e.index}`}>
{e.error}
</div>
))}
</>
</Alert>
) : null}

{multiJsonArray.length > 0 ? (
<>
<div
className="PageBody__content PageBody__scrollable"
data-testid="view-xdr-render-json"
>
{renderJsonContent({
jsonArray: multiJsonArray,
xdr: "",
})}
</div>

<Box
gap="md"
direction="row"
justify="space-between"
align="center"
>
<JsonCodeWrapToggle
isChecked={isCodeWrapped}
onChange={(isChecked) => {
setIsCodeWrapped(isChecked);
}}
/>

<CopyJsonPayloadButton
jsonString={
stringifyLosslessJson(multiJsonArray, null, 2) || ""
}
/>
</Box>
</>
) : null}
</Box>
) : null
) : xdrJsonDecoded?.jsonString && xdrJsonDecoded?.jsonArray ? (
<Box gap="lg">
<>{renderClaimableBalanceIds()}</>

Expand Down
54 changes: 54 additions & 0 deletions src/helpers/decodeXdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof parseToLosslessJson>[],
error: `Entry ${index + 1}: unable to decode as any known XDR type.`,
};
});
};
25 changes: 25 additions & 0 deletions src/helpers/splitXdrBlocks.ts
Original file line number Diff line number Diff line change
@@ -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);
};
38 changes: 38 additions & 0 deletions tests/unit/splitXdrBlocks.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading