Skip to content
Merged
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
110 changes: 110 additions & 0 deletions src/__tests__/payment-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <button onClick={() => connectWallet()}>Test Connect</button>;
};

render(
<SorokitProvider client={mockClient}>
<TransactionPanel />
<ConnectTrigger />
</SorokitProvider>,
);

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 <button onClick={() => connectWallet()}>Test Connect</button>;
};

render(
<SorokitProvider client={mockClient}>
<TransactionPanel />
<ConnectTrigger />
</SorokitProvider>,
);

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();
});
});
});

34 changes: 30 additions & 4 deletions src/components/TransactionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -466,8 +475,22 @@ export function TransactionPanel({
{memoType !== "none" && (
<div className="flex flex-col gap-1.5">
<Input
label={memoType === "id" ? "Memo ID" : "Memo (optional)"}
placeholder={memoType === "id" ? "1234567890" : "Text memo"}
label={
memoType === "id"
? "Memo ID"
: memoType === "hash"
? "Memo Hash"
: memoType === "return"
? "Memo Return"
: "Memo (optional)"
}
placeholder={
memoType === "id"
? "1234567890"
: isMemoHexType
? "64-character hex string"
: "Text memo"
}
inputMode={memoType === "id" ? "numeric" : undefined}
value={memo}
onChange={(e) => {
Expand All @@ -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"}
/>
Expand All @@ -497,6 +522,7 @@ export function TransactionPanel({
)}
</div>
)}

</form>
)}
</div>
Expand Down