From 62a7e6be9a308935b80348eb1cafcabec15ee4aa Mon Sep 17 00:00:00 2001 From: area523 Date: Fri, 16 Oct 2026 06:04:30 +0900 Subject: [PATCH] feat(payment): enforce 64-char hex memo hash validation and add payment flow integration tests (#968) --- src/__tests__/payment-flow.test.tsx | 110 ++++++++++++++++++++++++++++ src/components/TransactionPanel.tsx | 34 ++++++++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/__tests__/payment-flow.test.tsx b/src/__tests__/payment-flow.test.tsx index d2630539..7f0fd84d 100644 --- a/src/__tests__/payment-flow.test.tsx +++ b/src/__tests__/payment-flow.test.tsx @@ -112,4 +112,114 @@ describe("Payment Flow Integration", () => { }) ); }); + + it("submits payment to a 69-character Muxed account (M...) with a valid 64-character MEMO_HASH (#968)", async () => { + const ConnectTrigger = () => { + const { connectWallet } = useSorokit(); + return ; + }; + + render( + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Test Connect" })); + }); + await waitFor(() => { + expect(screen.getByRole("option", { name: /XLM/ })).toHaveTextContent("100.5"); + }); + + const muxedDest = + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ"; + const validHash = "a".repeat(64); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: muxedDest }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "10" }, + }); + fireEvent.change(screen.getByLabelText("Memo type"), { + target: { value: "hash" }, + }); + + // Invalid hex memo blocks submission and shows validation message + const memoInput = screen.getByLabelText("Memo Hash"); + fireEvent.change(memoInput, { target: { value: "not-a-valid-64-char-hex" } }); + expect( + screen.getByText(/Memo hash must be a 64-character hexadecimal string/i), + ).toBeInTheDocument(); + expect(screen.getByTestId("submit-transaction")).toBeDisabled(); + + // Valid 64-char hex memo enables submission + fireEvent.change(memoInput, { target: { value: validHash } }); + const submitBtn = screen.getByTestId("submit-transaction"); + expect(submitBtn).not.toBeDisabled(); + fireEvent.click(submitBtn); + + await waitFor(() => screen.getByRole("dialog", { name: /confirm transaction/i })); + fireEvent.click(screen.getByRole("button", { name: /confirm & sign/i })); + + await waitFor(() => { + expect(screen.getByText("Transaction submitted")).toBeInTheDocument(); + }); + + expect(mockClient.transaction.submit).toHaveBeenCalledWith( + expect.objectContaining({ + destination: muxedDest, + amount: "10", + memoType: "hash", + memo: validHash, + }), + ); + }); + + it("displays actionable user guidance when a payment fails due to op_underfunded (#968)", async () => { + vi.mocked(mockClient.transaction.submit).mockResolvedValueOnce({ + data: null, + error: "Transaction failed with op_underfunded", + }); + + const ConnectTrigger = () => { + const { connectWallet } = useSorokit(); + return ; + }; + + render( + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Test Connect" })); + }); + await waitFor(() => { + expect(screen.getByRole("option", { name: /XLM/ })).toHaveTextContent("100.5"); + }); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ" }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "12" }, + }); + fireEvent.click(screen.getByTestId("submit-transaction")); + + await waitFor(() => screen.getByRole("dialog", { name: /confirm transaction/i })); + fireEvent.click(screen.getByRole("button", { name: /confirm & sign/i })); + + await waitFor(() => { + expect(screen.getByText("Transaction failed")).toBeInTheDocument(); + expect( + screen.getByText(/Insufficient balance to submit this transaction/i), + ).toBeInTheDocument(); + }); + }); }); + diff --git a/src/components/TransactionPanel.tsx b/src/components/TransactionPanel.tsx index c6004846..90cca3f2 100644 --- a/src/components/TransactionPanel.tsx +++ b/src/components/TransactionPanel.tsx @@ -32,14 +32,17 @@ const MEMO_TEXT_MAX_BYTES = 28; const STROOPS_PER_XLM = 10_000_000; -export type MemoType = "none" | "text" | "id"; +export type MemoType = "none" | "text" | "id" | "hash" | "return"; export const MEMO_TYPES: { value: MemoType; label: string }[] = [ { value: "none", label: "No memo" }, { value: "text", label: "Text memo" }, { value: "id", label: "Memo ID" }, + { value: "hash", label: "Memo Hash" }, + { value: "return", label: "Memo Return" }, ]; + export interface TransactionPanelProps { defaultDestination?: string; defaultAmount?: string; @@ -129,6 +132,9 @@ export function TransactionPanel({ const isAmountValid = !isNaN(parsedAmount) && parsedAmount >= 0.0000001 && !exceedsDecimals; const isMemoIdValid = memoType !== "id" || (memo.trim() !== "" && /^\d+$/.test(memo.trim())); + const isMemoHexType = memoType === "hash" || memoType === "return"; + const isMemoHashValid = + !isMemoHexType || /^[0-9a-fA-F]{64}$/.test(memo.trim()); // Get XLM balance from balances array const xlmBalance = balances.find((b) => b.asset === "XLM")?.balance || "0"; @@ -154,8 +160,11 @@ export function TransactionPanel({ isDestValid && amount.trim() !== "" && isAmountValid && + isMemoIdValid && + isMemoHashValid && hasSufficientBalance; + /** The actual submission — only ever called from the confirm modal. */ async function submitTransaction() { if (state === "loading") return; @@ -466,8 +475,22 @@ export function TransactionPanel({ {memoType !== "none" && (
{ @@ -484,7 +507,9 @@ export function TransactionPanel({ error={ memoType === "id" && memo.trim() !== "" && !isMemoIdValid ? "Memo ID must be an unsigned integer" - : undefined + : isMemoHexType && memo.trim() !== "" && !isMemoHashValid + ? "Memo hash must be a 64-character hexadecimal string" + : undefined } disabled={state === "loading"} /> @@ -497,6 +522,7 @@ export function TransactionPanel({ )}
)} + )}