From 7baa2ea9e7a802df0fd1f828665d81785228501d Mon Sep 17 00:00:00 2001 From: Yunus Abdulmajid Date: Fri, 16 Oct 2026 12:36:42 +0100 Subject: [PATCH] fix(dashboard): show XDR decode errors instead of crashing the inspector decodeXdr throws on unknown payloads, but it ran inside XdrViewer's render, so the try/catch around in PaymentDetailDialog never caught it and invalid XDR crashed the dialog. Decode inside XdrViewer and render the error panel there. Adds tests for envelope, ScVal, invalid input and close. --- dashboard/components/PaymentDetailDialog.tsx | 44 +++++++----- .../__tests__/PaymentDetailDialog.test.tsx | 72 +++++++++++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 dashboard/components/__tests__/PaymentDetailDialog.test.tsx diff --git a/dashboard/components/PaymentDetailDialog.tsx b/dashboard/components/PaymentDetailDialog.tsx index 71a26e2..396518d 100644 --- a/dashboard/components/PaymentDetailDialog.tsx +++ b/dashboard/components/PaymentDetailDialog.tsx @@ -536,9 +536,35 @@ function JsonNode({ data, name, level = 0 }: JsonNodeProps) { ); } +function XdrDecodeError({ message }: { message: string }) { + return ( +
+

Failed to decode XDR

+

{message}

+
+ ); +} + function XdrViewer({ xdr }: { xdr: string }) { + // decodeXdr throws on unknown payloads. Decode here rather than relying on + // a try/catch around , which cannot catch render-time errors. + const result = useMemo(() => { + try { + return { decoded: decodeXdr(xdr) }; + } catch (e) { + return { error: e instanceof Error ? e.message : "Unknown error" }; + } + }, [xdr]); + + if ("error" in result) return ; + return ; +} + +function DecodedXdrView({ xdr, decoded }: { xdr: string; decoded: XdrDecodeResult }) { const [copied, setCopied] = useState<"raw" | "json" | null>(null); - const decoded = useMemo(() => decodeXdr(xdr), [xdr]); const jsonString = useMemo( () => JSON.stringify(decoded.data, null, 2), [decoded], @@ -709,20 +735,6 @@ export default function PaymentDetailDialog({ }, [onClose, selectedXdr, xdr]); if (selectedXdr) { - let content: React.ReactNode; - try { - content = ; - } catch (e) { - content = ( -
-

Failed to decode XDR

-

- {e instanceof Error ? e.message : "Unknown error"} -

-
- ); - } - const headerTitle = title ?? (xdr @@ -763,7 +775,7 @@ export default function PaymentDetailDialog({
- {content} + {decodeError && (
{decodeError} diff --git a/dashboard/components/__tests__/PaymentDetailDialog.test.tsx b/dashboard/components/__tests__/PaymentDetailDialog.test.tsx new file mode 100644 index 0000000..ad4b612 --- /dev/null +++ b/dashboard/components/__tests__/PaymentDetailDialog.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import PaymentDetailDialog from "../PaymentDetailDialog"; + +vi.mock("@/lib/api", () => ({ + api: { userPayments: vi.fn().mockResolvedValue([]) }, +})); + +function buildEnvelopeXdr(memo: string): { xdr: string; source: string } { + const source = StellarSdk.Keypair.random().publicKey(); + const destination = StellarSdk.Keypair.random().publicKey(); + const tx = new StellarSdk.TransactionBuilder(new StellarSdk.Account(source, "41"), { + fee: "100", + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination, + asset: StellarSdk.Asset.native(), + amount: "12.5", + }), + ) + .addMemo(StellarSdk.Memo.text(memo)) + .setTimeout(0) + .build(); + return { xdr: tx.toXDR(), source }; +} + +describe("PaymentDetailDialog XDR inspector (#1029)", () => { + it("decodes a transaction envelope into a summary and tree view", () => { + const { xdr, source } = buildEnvelopeXdr("payout-7"); + render( {}} />); + + expect(screen.getByText("Transaction Details")).toBeInTheDocument(); + expect(screen.getByText("TransactionEnvelope")).toBeInTheDocument(); + expect(screen.getByText("TESTNET")).toBeInTheDocument(); + expect(screen.getAllByText(source).length).toBeGreaterThan(0); + expect(screen.getAllByText("42").length).toBeGreaterThan(0); + expect(screen.getAllByText(/payout-7/).length).toBeGreaterThan(0); + expect(screen.getByText("Operations (1)")).toBeInTheDocument(); + expect(screen.getAllByText("payment").length).toBeGreaterThan(0); + expect(screen.getByText("Decoded Structure")).toBeInTheDocument(); + }); + + it("decodes a bare ScVal payload without a transaction summary", () => { + const xdr = StellarSdk.nativeToScVal(7, { type: "u32" }).toXDR("base64"); + render( {}} />); + + expect(screen.queryByText("Transaction Summary")).not.toBeInTheDocument(); + expect(screen.getByText("Decoded Structure")).toBeInTheDocument(); + }); + + it("shows an error panel instead of crashing on undecodable XDR", () => { + render( {}} />); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Failed to decode XDR"); + expect(screen.queryByText("Decoded Structure")).not.toBeInTheDocument(); + }); + + it("closes on Escape and on the close button", () => { + const onClose = vi.fn(); + const { xdr } = buildEnvelopeXdr("close-me"); + render(); + + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.click(screen.getByRole("button", { name: "Close dialog" })); + expect(onClose).toHaveBeenCalledTimes(2); + }); +});