Skip to content
88 changes: 88 additions & 0 deletions packages/ui-components/src/__tests__/OrderDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
// When onRemoveAndWithdrawAll is provided, the dropdown trigger renders.
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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +21,7 @@
import {
ArrowDownToBracketOutline,
ArrowUpFromBracketOutline,
ChevronDownOutline,
InfoCircleOutline,
WalletOutline
} from 'flowbite-svelte-icons';
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -142,11 +156,32 @@

<div class="flex items-center gap-2">
{#if matchesAccount(data.owner) && data.active}
<Button
on:click={() => onRemove(raindexClient, data)}
data-testid="remove-button"
aria-label="Remove order">Remove</Button
>
{#if onRemoveAndWithdrawAll}
<Button
id="remove-order-menu"
data-testid="remove-order-menu"
aria-label="Remove order">
Remove
<ChevronDownOutline size="sm" class="ml-2" />
</Button>
<Dropdown placement="bottom-end" triggeredBy="#remove-order-menu">
<DropdownItem
on:click={() => onRemove(raindexClient, data)}
data-testid="remove-button">Remove</DropdownItem
>
<DropdownItem
on:click={() => onRemoveAndWithdrawAll(raindexClient, data, data.vaultsList)}
data-testid="remove-and-withdraw-all-button"
>Remove and withdraw all vaults</DropdownItem
Comment on lines +159 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Only show the withdraw-all action when the order actually has withdrawable balance.

This branch only checks whether onRemoveAndWithdrawAll exists, so a zero-balance vaultsList still advertises "Remove and withdraw all vaults" even though the downstream handler degrades to a plain remove. The new OrderDetail.test.ts fixture uses that exact all-zero shape, so this misleading path is already being asserted. Gate the extra item on a withdrawable-balance check (or disable/rename it) and keep the plain remove action for empty vaults.

💡 Suggested change
+	const hasWithdrawableVaults = (vaultsList: RaindexVaultsList) =>
+		vaultsList.items.some((vault) => vault.balance > 0n);
...
-					{`#if` onRemoveAndWithdrawAll}
+					{`#if` onRemoveAndWithdrawAll && hasWithdrawableVaults(data.vaultsList)}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{#if onRemoveAndWithdrawAll}
<Button
id="remove-order-menu"
data-testid="remove-order-menu"
aria-label="Remove order">
Remove
<ChevronDownOutline size="sm" class="ml-2" />
</Button>
<Dropdown placement="bottom-end" triggeredBy="#remove-order-menu">
<DropdownItem
on:click={() => onRemove(raindexClient, data)}
data-testid="remove-button">Remove</DropdownItem
>
<DropdownItem
on:click={() => onRemoveAndWithdrawAll(raindexClient, data, data.vaultsList)}
data-testid="remove-and-withdraw-all-button"
>Remove and withdraw all vaults</DropdownItem
const hasWithdrawableVaults = (vaultsList: RaindexVaultsList) =>
vaultsList.items.some((vault) => vault.balance > 0n);
{`#if` onRemoveAndWithdrawAll && hasWithdrawableVaults(data.vaultsList)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui-components/src/lib/components/detail/OrderDetail.svelte` around
lines 159 - 175, The OrderDetail menu is showing “Remove and withdraw all
vaults” whenever onRemoveAndWithdrawAll exists, even for zero-balance
vaultsList. Update the conditional in OrderDetail.svelte around the
Dropdown/DropdownItem branch to also check that the order has withdrawable
balance before rendering the extra action, using the existing data.vaultsList
shape. Keep the plain Remove DropdownItem available for empty vaults, and if
needed align the label/disabled state so the UI does not imply withdrawable
funds when there are none.

>
</Dropdown>
{:else}
<Button
on:click={() => onRemove(raindexClient, data)}
data-testid="remove-button"
aria-label="Remove order">Remove</Button
>
{/if}
{/if}
{#if data.active && onTakeOrder}
<Button
Expand Down
219 changes: 219 additions & 0 deletions packages/webapp/src/__tests__/handleRemoveOrderAndWithdrawAll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
handleRemoveOrderAndWithdrawAll,
type HandleRemoveOrderAndWithdrawAllDependencies,
} from "../lib/services/handleRemoveOrderAndWithdrawAll";
import type {
RaindexClient,
RaindexOrder,
RaindexVaultsList,
} from "@rainlanguage/raindex";
import type { Hex } from "viem";
import { decodeFunctionData } from "viem";
import type { TransactionManager } from "@rainlanguage/ui-components";

const MULTICALL_ABI = [
{
type: "function",
name: "multicall",
inputs: [{ name: "data", type: "bytes[]" }],
outputs: [{ name: "results", type: "bytes[]" }],
stateMutability: "nonpayable",
},
] as const;

const REMOVE_CALLDATA = "0xdeadbeef" as Hex;
const WITHDRAW_CALLDATA = "0xcafe" as Hex;

// Pre-computed via viem: multicall([REMOVE_CALLDATA, WITHDRAW_CALLDATA]).
const EXPECTED_BOTH =
"0xac9650d800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cafe000000000000000000000000000000000000000000000000000000000000";
// Pre-computed via viem: multicall([REMOVE_CALLDATA]).
const EXPECTED_REMOVE_ONLY =
"0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000";

const mockHandleTransactionConfirmationModal = vi.fn();
const mockErrToast = vi.fn();
const mockCreateRemoveOrderTransaction = vi.fn();

const mockManager = {
createRemoveOrderTransaction: mockCreateRemoveOrderTransaction,
} as unknown as TransactionManager;

const mockRaindexClient = {} as unknown as RaindexClient;

const orderHash = "0xorderhash" as Hex;
const raindex = "0xraindexaddress" as Hex;
const chainId = 137;

const makeOrder = (overrides: Partial<RaindexOrder> = {}) =>
({
orderHash,
raindex,
chainId,
getRemoveCalldata: vi
.fn()
.mockReturnValue({ value: REMOVE_CALLDATA, error: undefined }),
...overrides,
}) as unknown as RaindexOrder;

const makeVaultsList = (overrides: Partial<RaindexVaultsList> = {}) =>
({
getWithdrawableVaults: vi
.fn()
.mockReturnValue({ value: [{ id: "0xvault" }], error: undefined }),
getWithdrawCalldata: vi
.fn()
.mockResolvedValue({ value: WITHDRAW_CALLDATA, error: undefined }),
...overrides,
}) as unknown as RaindexVaultsList;

const makeDeps = (
order: RaindexOrder,
vaultsList: RaindexVaultsList,
): HandleRemoveOrderAndWithdrawAllDependencies => ({
raindexClient: mockRaindexClient,
order,
vaultsList,
handleTransactionConfirmationModal: mockHandleTransactionConfirmationModal,
errToast: mockErrToast,
manager: mockManager,
});

describe("handleRemoveOrderAndWithdrawAll", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHandleTransactionConfirmationModal.mockResolvedValue({ success: true });
});

it("batches removeOrder + withdraw into a single multicall in that exact order", async () => {
const order = makeOrder();
const vaultsList = makeVaultsList();

await handleRemoveOrderAndWithdrawAll(makeDeps(order, vaultsList));

expect(mockHandleTransactionConfirmationModal).toHaveBeenCalledOnce();
const args = mockHandleTransactionConfirmationModal.mock.calls[0][0].args;

// The calldata is exactly the multicall of removeOrder then withdraw,
// in that order, under the multicall selector.
expect(args.calldata).toBe(EXPECTED_BOTH);

// Decode to prove it is a flat multicall containing the two calls in order.
const decoded = decodeFunctionData({
abi: MULTICALL_ABI,
data: args.calldata,
});
expect(decoded.functionName).toBe("multicall");
expect(decoded.args[0]).toEqual([REMOVE_CALLDATA, WITHDRAW_CALLDATA]);

expect(args.toAddress).toBe(raindex);
expect(args.chainId).toBe(chainId);
expect(args.entity).toBe(order);
expect(mockErrToast).not.toHaveBeenCalled();
});

it("removes the order alone (no withdraw call) when there are no withdrawable vaults", async () => {
const order = makeOrder();
const vaultsList = makeVaultsList({
getWithdrawableVaults: vi
.fn()
.mockReturnValue({ value: [], error: undefined }),
});

await handleRemoveOrderAndWithdrawAll(makeDeps(order, vaultsList));

const args = mockHandleTransactionConfirmationModal.mock.calls[0][0].args;
expect(args.calldata).toBe(EXPECTED_REMOVE_ONLY);

const decoded = decodeFunctionData({
abi: MULTICALL_ABI,
data: args.calldata,
});
expect(decoded.args[0]).toEqual([REMOVE_CALLDATA]);

// Must not even ask for withdraw calldata when nothing is withdrawable.
expect(vaultsList.getWithdrawCalldata).not.toHaveBeenCalled();
expect(mockErrToast).not.toHaveBeenCalled();
});

it("uses the combined-action modal title", async () => {
await handleRemoveOrderAndWithdrawAll(
makeDeps(makeOrder(), makeVaultsList()),
);

expect(mockHandleTransactionConfirmationModal).toHaveBeenCalledWith(
expect.objectContaining({
modalTitle: "Removing order and withdrawing all vaults",
}),
);
});

it("creates a remove-order transaction on confirmation", async () => {
const order = makeOrder();
const txHash = "0xtxhash" as Hex;

await handleRemoveOrderAndWithdrawAll(makeDeps(order, makeVaultsList()));

const onConfirm =
mockHandleTransactionConfirmationModal.mock.calls[0][0].args.onConfirm;
onConfirm(txHash);

expect(mockCreateRemoveOrderTransaction).toHaveBeenCalledWith({
raindexClient: mockRaindexClient,
txHash,
queryKey: orderHash,
chainId,
entity: order,
});
});

it("toasts the remove-calldata error and never opens the modal", async () => {
const order = makeOrder({
getRemoveCalldata: vi.fn().mockReturnValue({
value: undefined,
error: { msg: "remove failed", readableMsg: "Remove calldata failed" },
}),
});

await handleRemoveOrderAndWithdrawAll(makeDeps(order, makeVaultsList()));

expect(mockErrToast).toHaveBeenCalledWith("Remove calldata failed");
expect(mockHandleTransactionConfirmationModal).not.toHaveBeenCalled();
});

it("toasts the withdraw-calldata error and never opens the modal", async () => {
const vaultsList = makeVaultsList({
getWithdrawCalldata: vi.fn().mockResolvedValue({
value: undefined,
error: {
msg: "withdraw failed",
readableMsg: "Withdraw calldata failed",
},
}),
});

await handleRemoveOrderAndWithdrawAll(makeDeps(makeOrder(), vaultsList));

expect(mockErrToast).toHaveBeenCalledWith(
"Failed to generate withdraw calldata: Withdraw calldata failed",
);
expect(mockHandleTransactionConfirmationModal).not.toHaveBeenCalled();
});

it("toasts the withdrawable-vaults lookup error and never opens the modal", async () => {
const vaultsList = makeVaultsList({
getWithdrawableVaults: vi.fn().mockReturnValue({
value: undefined,
error: { msg: "lookup failed", readableMsg: "Lookup failed" },
}),
});

await handleRemoveOrderAndWithdrawAll(makeDeps(makeOrder(), vaultsList));

expect(mockErrToast).toHaveBeenCalledWith(
"Failed to get withdrawable vaults: Lookup failed",
);
expect(mockHandleTransactionConfirmationModal).not.toHaveBeenCalled();
});
});
Loading
Loading