From 1dea738d8e4e4e14dc0cbf69638200c0bcde6a29 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 13 Jun 2026 11:04:22 +0000 Subject: [PATCH 1/3] Add "remove and withdraw all" option to order removal Issue #2024 asks to offer withdrawing an order's vault balances at the same time as removing the order, without making withdrawal automatic (orders can share vaults). In OrderDetail.svelte the standalone Remove button becomes a dropdown (when the new optional onRemoveAndWithdrawAll callback is provided) offering: - "Remove" (unchanged behaviour), and - "Remove and withdraw all vaults". The webapp wires the new option to handleRemoveOrderAndWithdrawAll, which mirrors handleVaultsWithdrawAll but builds a single multicall([removeOrder, withdraw]) so removal and withdrawal execute atomically. Withdraw calldata is only included when the order actually has withdrawable (positive-balance) vaults, so an empty order is still removed. When onRemoveAndWithdrawAll is not supplied, OrderDetail renders the original single Remove button, so existing callers are unaffected. Tests: - ui-components OrderDetail: new tests assert the dropdown replaces the bare button, that each item invokes the correct callback (Remove -> onRemove with the order; Remove-and-withdraw -> onRemoveAndWithdrawAll with the order and its vaultsList), and that the dropdown is hidden for inactive orders. - webapp handleRemoveOrderAndWithdrawAll: new tests assert the exact multicall calldata bytes and decode it to prove the flat [removeOrder, withdraw] order, that an empty order produces a remove-only multicall (and never requests withdraw calldata), the combined modal title, the remove-order transaction on confirm, and that remove/withdraw/lookup errors toast and abort. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/OrderDetail.test.ts | 88 +++++++ .../lib/components/detail/OrderDetail.svelte | 47 +++- .../handleRemoveOrderAndWithdrawAll.test.ts | 219 ++++++++++++++++++ .../handleRemoveOrderAndWithdrawAll.ts | 118 ++++++++++ .../+page.svelte | 17 ++ 5 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 packages/webapp/src/__tests__/handleRemoveOrderAndWithdrawAll.test.ts create mode 100644 packages/webapp/src/lib/services/handleRemoveOrderAndWithdrawAll.ts diff --git a/packages/ui-components/src/__tests__/OrderDetail.test.ts b/packages/ui-components/src/__tests__/OrderDetail.test.ts index df1d6f54ad..79ea19e0cc 100644 --- a/packages/ui-components/src/__tests__/OrderDetail.test.ts +++ b/packages/ui-components/src/__tests__/OrderDetail.test.ts @@ -248,6 +248,94 @@ describe("OrderDetail", () => { }); }); + it("renders a remove dropdown (not a bare remove button) when onRemoveAndWithdrawAll is provided", async () => { + mockMatchesAccount.mockReturnValue(true); + render(OrderDetail, { + props: { ...defaultProps, onRemoveAndWithdrawAll: vi.fn() }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + // The dropdown trigger replaces the standalone remove button. + expect(screen.getByTestId("remove-order-menu")).toBeInTheDocument(); + }); + + // Dropdown items are hidden until the trigger is opened. + expect( + screen.queryByTestId("remove-and-withdraw-all-button"), + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("remove-order-menu")); + + await waitFor(() => { + expect(screen.getByTestId("remove-button")).toBeInTheDocument(); + expect( + screen.getByTestId("remove-and-withdraw-all-button"), + ).toBeInTheDocument(); + }); + }); + + it("calls onRemove (not onRemoveAndWithdrawAll) when the Remove dropdown item is clicked", async () => { + mockMatchesAccount.mockReturnValue(true); + const onRemove = vi.fn(); + const onRemoveAndWithdrawAll = vi.fn(); + render(OrderDetail, { + props: { ...defaultProps, onRemove, onRemoveAndWithdrawAll }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByTestId("remove-order-menu")).toBeInTheDocument(); + }); + await userEvent.click(screen.getByTestId("remove-order-menu")); + await userEvent.click(await screen.findByTestId("remove-button")); + + expect(onRemove).toHaveBeenCalledWith(mockRaindexClient, mockOrder); + expect(onRemoveAndWithdrawAll).not.toHaveBeenCalled(); + }); + + it("calls onRemoveAndWithdrawAll with the order's vaultsList when that dropdown item is clicked", async () => { + mockMatchesAccount.mockReturnValue(true); + const onRemove = vi.fn(); + const onRemoveAndWithdrawAll = vi.fn(); + render(OrderDetail, { + props: { ...defaultProps, onRemove, onRemoveAndWithdrawAll }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByTestId("remove-order-menu")).toBeInTheDocument(); + }); + await userEvent.click(screen.getByTestId("remove-order-menu")); + await userEvent.click( + await screen.findByTestId("remove-and-withdraw-all-button"), + ); + + expect(onRemoveAndWithdrawAll).toHaveBeenCalledWith( + mockRaindexClient, + mockOrder, + mockOrder.vaultsList, + ); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it("does not show the remove dropdown for inactive orders even with onRemoveAndWithdrawAll", async () => { + mockMatchesAccount.mockReturnValue(true); + (mockRaindexClient.getOrderByHash as Mock).mockResolvedValue({ + value: { ...mockOrder, active: false }, + }); + render(OrderDetail, { + props: { ...defaultProps, onRemoveAndWithdrawAll: vi.fn() }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByText("Order")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("remove-order-menu")).not.toBeInTheDocument(); + expect(screen.queryByTestId("remove-button")).not.toBeInTheDocument(); + }); + it("does not show remove button if account does not match owner", async () => { mockMatchesAccount.mockReturnValue(false); diff --git a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte index 058c829ecd..89d4df29d0 100644 --- a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte +++ b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte @@ -12,7 +12,7 @@ import { QKEY_ORDER } from '../../queries/keys'; import CodeMirrorRainlang from '../CodeMirrorRainlang.svelte'; import { createQuery, useQueryClient } from '@tanstack/svelte-query'; - import { Button, TabItem, Tabs, Tooltip } from 'flowbite-svelte'; + import { Button, Dropdown, DropdownItem, TabItem, Tabs, Tooltip } from 'flowbite-svelte'; import { onDestroy } from 'svelte'; // import OrderApy from '../tables/OrderAPY.svelte'; import type { DebugTradeModalHandler, QuoteDebugModalHandler } from '../../types/modal'; @@ -21,6 +21,7 @@ import { ArrowDownToBracketOutline, ArrowUpFromBracketOutline, + ChevronDownOutline, InfoCircleOutline, WalletOutline } from 'flowbite-svelte-icons'; @@ -51,6 +52,19 @@ */ export let onRemove: (raindexClient: RaindexClient, order: RaindexOrder) => void; + /** Callback function when remove-and-withdraw-all action is triggered for an order. + * Removes the order and withdraws all of its vault balances in a single transaction. + * @param order The order to remove + * @param vaultsList The VaultsList struct containing the order's vaults to withdraw from + */ + export let onRemoveAndWithdrawAll: + | (( + raindexClient: RaindexClient, + order: RaindexOrder, + vaultsList: RaindexVaultsList + ) => void) + | undefined = undefined; + /** Callback function when deposit action is triggered for a vault * @param vault The vault to deposit into */ @@ -142,11 +156,32 @@
{#if matchesAccount(data.owner) && data.active} - + {#if onRemoveAndWithdrawAll} + + + onRemove(raindexClient, data)} + data-testid="remove-button">Remove + onRemoveAndWithdrawAll(raindexClient, data, data.vaultsList)} + data-testid="remove-and-withdraw-all-button" + >Remove and withdraw all vaults + + {:else} + + {/if} {/if} {#if data.active && onTakeOrder}