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