diff --git a/src/__tests__/wallet-flow.test.tsx b/src/__tests__/wallet-flow.test.tsx index 370174c..5259f4f 100644 --- a/src/__tests__/wallet-flow.test.tsx +++ b/src/__tests__/wallet-flow.test.tsx @@ -92,4 +92,138 @@ describe("Wallet Connect Flow Integration", () => { expect(screen.getByRole("button", { name: /connect/i })).toBeInTheDocument(); expect(screen.queryByText(/^Sequence$/i)).not.toBeInTheDocument(); }); + + it("aborts cleanly and displays a reconnect prompt when wallet disconnects during simulation (#967)", async () => { + const { TransactionPanel } = await import("@/components/TransactionPanel"); + const ConnectDirect = () => { + const { connectWallet } = useSorokit(); + return ; + }; + + (mockClient as unknown as Record).transaction = { + estimateFee: vi.fn().mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve({ data: { baseFee: "100", recommended: "100" }, error: null }), 100), + ), + ), + submit: vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }), + }; + + render( + + + + + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Test Sign In" })); + }); + + + await waitFor(() => { + expect(screen.getByLabelText("Destination Address")).toBeInTheDocument(); + }); + + // Disconnect while simulation / preview is active + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /sign out/i })); + }); + + expect( + screen.getByText(/Connect your wallet to send transactions/i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /connect/i })).toBeInTheDocument(); + }); + + it("triggers sequence refetch when transaction fails with tx_bad_seq and resets draft form on account switch (#967)", async () => { + const { TransactionPanel } = await import("@/components/TransactionPanel"); + const addr1 = "GBRPYHIL2CI3WHGSUJGY6O7SROQOMJG7QBCACN4QPKUOQNXJDGONXHPA"; + const addr2 = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; + + vi.mocked(mockClient.wallet.connect) + .mockResolvedValueOnce({ data: { address: addr1 }, error: null }) + .mockResolvedValueOnce({ data: { address: addr2 }, error: null }); + + vi.mocked(mockClient.account.getAccount) + .mockResolvedValueOnce({ data: { sequence: "100" }, error: null }) + .mockResolvedValueOnce({ data: { sequence: "101" }, error: null }) + .mockResolvedValue({ data: { sequence: "200" }, error: null }); + + (mockClient as unknown as Record).transaction = { + estimateFee: vi.fn().mockResolvedValue({ + data: { baseFee: "100", recommended: "100" }, + error: null, + }), + submit: vi.fn().mockResolvedValueOnce({ + data: null, + error: "Transaction failed: tx_bad_seq", + }), + }; + + const SwitchControls = () => { + const { connectWallet } = useSorokit(); + return ; + }; + + render( + + + + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Connect Or Switch" })); + }); + + await waitFor(() => { + expect(screen.getByRole("option", { name: /XLM/ })).toHaveTextContent("100.5"); + }); + + const destInput = screen.getByLabelText("Destination Address") as HTMLInputElement; + const amountInput = screen.getByLabelText("Amount (XLM)") as HTMLInputElement; + + fireEvent.change(destInput, { target: { value: addr2 } }); + fireEvent.change(amountInput, { target: { value: "5" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("submit-transaction")); + }); + + await waitFor(() => { + expect( + screen.getByText(/Your account sequence is out of date/i), + ).toBeInTheDocument(); + }); + // Sequence refetch was triggered upon tx_bad_seq + expect(mockClient.account.getAccount).toHaveBeenCalledTimes(2); + + // Return to form and populate draft, then switch active account + fireEvent.click(screen.getByRole("button", { name: /new transaction/i })); + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: addr2 }, + }); + expect( + (screen.getByLabelText("Destination Address") as HTMLInputElement).value, + ).toBe(addr2); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Connect Or Switch" })); + }); + + await waitFor(() => { + expect( + (screen.getByLabelText("Destination Address") as HTMLInputElement).value, + ).toBe(""); + }); + }); }); + diff --git a/src/components/TransactionPanel.tsx b/src/components/TransactionPanel.tsx index c600484..a223509 100644 --- a/src/components/TransactionPanel.tsx +++ b/src/components/TransactionPanel.tsx @@ -69,6 +69,7 @@ export function TransactionPanel({ isLoadingAccount, isConnected, balances = [], + refreshAccount, } = useSorokit(); const [dest, setDest] = useState(defaultDestination); const [destDirty, setDestDirty] = useState(false); @@ -84,8 +85,27 @@ export function TransactionPanel({ const [isBuildingPreview, setIsBuildingPreview] = useState(false); const [estimatedFeeXlm, setEstimatedFeeXlm] = useState(0); const abortControllerRef = useRef(null); + const prevAddressRef = useRef(address); const formId = useId(); + // Reset active transaction draft form and abort in-flight operations when switching accounts or disconnecting (#967) + useEffect(() => { + if (prevAddressRef.current !== undefined && prevAddressRef.current !== address) { + abortControllerRef.current?.abort(); + setDest(defaultDestination); + setDestDirty(false); + setAmount(defaultAmount); + setAmountDirty(false); + setMemo(defaultMemo); + setState("idle"); + setResult(null); + setError(null); + setPreview(null); + setIsBuildingPreview(false); + } + prevAddressRef.current = address; + }, [address, defaultDestination, defaultAmount, defaultMemo]); + // Fetch the current network fee estimate once a wallet is connected so the // balance check below can warn the user before they submit, rather than // only after Horizon rejects an underfunded transaction. @@ -111,6 +131,7 @@ export function TransactionPanel({ }; }, [client, isConnected]); + const assetOptions = balances ?? []; // Derive the effective asset: keep the user's selection while it is valid, @@ -186,6 +207,9 @@ export function TransactionPanel({ }); if (signal.aborted) return; if (err) { + if (err.toLowerCase().includes("tx_bad_seq") || err.toLowerCase().includes("bad sequence")) { + void refreshAccount?.(); + } const message = friendlyError(err); setError(message); setState("error"); @@ -203,12 +227,16 @@ export function TransactionPanel({ } catch (e) { if (!signal.aborted) { const msg = e instanceof Error ? e.message : "Unknown error"; + if (msg.toLowerCase().includes("tx_bad_seq") || msg.toLowerCase().includes("bad sequence")) { + void refreshAccount?.(); + } const message = friendlyError(msg); setError(message); setState("error"); onError?.(message); } } finally { + setPreview(null); } }