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

(mockClient as unknown as Record<string, unknown>).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(
<ToastProvider>
<SorokitProvider client={mockClient}>
<WalletConnectButton />
<TransactionPanel />
<ConnectDirect />
<DisconnectTrigger />
</SorokitProvider>
</ToastProvider>,
);

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<string, unknown>).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 <button onClick={() => connectWallet()}>Connect Or Switch</button>;
};

render(
<ToastProvider>
<SorokitProvider client={mockClient}>
<AccountCard />
<TransactionPanel previewMode={false} />
<SwitchControls />
</SorokitProvider>
</ToastProvider>,
);

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

28 changes: 28 additions & 0 deletions src/components/TransactionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function TransactionPanel({
isLoadingAccount,
isConnected,
balances = [],
refreshAccount,
} = useSorokit();
const [dest, setDest] = useState(defaultDestination);
const [destDirty, setDestDirty] = useState(false);
Expand All @@ -84,8 +85,27 @@ export function TransactionPanel({
const [isBuildingPreview, setIsBuildingPreview] = useState(false);
const [estimatedFeeXlm, setEstimatedFeeXlm] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const prevAddressRef = useRef<string | null | undefined>(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.
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
}
Expand Down