From c36ce9f8c18ca0df88f1376f2f9645e937054891 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 25 Sep 2026 12:01:33 -0300 Subject: [PATCH 1/4] feat(app): let the investor cancel a subscription --- .../vault/SubscriptionModal.test.tsx | 149 ++++++++ .../components/vault/SubscriptionModal.tsx | 113 +++++- app/src/hooks/useCancelDeposit.test.ts | 350 ++++++++++++++++++ app/src/hooks/useCancelDeposit.ts | 131 +++++++ app/src/pages/VaultPreview.test.tsx | 115 ++++++ app/src/pages/VaultPreview.tsx | 40 +- app/src/pages/vaultRequests.test.ts | 66 +++- app/src/pages/vaultRequests.ts | 20 +- 8 files changed, 959 insertions(+), 25 deletions(-) create mode 100644 app/src/hooks/useCancelDeposit.test.ts create mode 100644 app/src/hooks/useCancelDeposit.ts diff --git a/app/src/components/vault/SubscriptionModal.test.tsx b/app/src/components/vault/SubscriptionModal.test.tsx index 91f00df..f58601c 100644 --- a/app/src/components/vault/SubscriptionModal.test.tsx +++ b/app/src/components/vault/SubscriptionModal.test.tsx @@ -1,5 +1,7 @@ +import { type Amount } from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import SubscriptionModal from "./SubscriptionModal" @@ -18,6 +20,23 @@ const renderModal = (status: RequestDepositStatus) => { const onRetry = vi.fn() const view = render( , + ) + return { ...view, onClose, onRetry } +} + +const renderCancelModal = (status: CancelDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + { expect(onRetry).not.toHaveBeenCalled() }) }) + +describe("SubscriptionModal, cancelling", () => { + it("opens with a preparing state naming the cancellation, not the request", () => { + renderCancelModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing your cancellation" }), + ).toBeTruthy() + }) + + it("tells the investor their deposit returns to their wallet, honestly, without escrow or pricing language", () => { + renderCancelModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.getByText(/from escrow/)).toBeTruthy() + expect(screen.queryByText(/into escrow/)).toBeNull() + }) + + it("tells the investor the cancellation is on its way, distinct from the subscribe copy", () => { + renderCancelModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending your cancellation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the returned amount once confirmed, with no batch or pricing claim", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 150_0000000n as Amount, + hash: "b".repeat(64), + }) + + expect( + screen.getByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + expect(screen.queryByText(/Batch/)).toBeNull() + expect(screen.queryByText(/attestation/)).toBeNull() + expect(screen.queryByText(/Epoch/)).toBeNull() + }) + + it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect( + screen.getByText(/already been priced. Claim your shares instead/), + ).toBeTruthy() + }) + + it("names PriceAvailable distinctly from AlreadyPriced", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6041 }, + }) + + expect( + screen.getByText(/price is now available for this batch/), + ).toBeTruthy() + }) + + it("names RequestNotFound for a cancellation of a request that no longer exists", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect(screen.getByText(/no longer exists to cancel/)).toBeTruthy() + }) + + it("names EpochNotFound as a batch that could not be found", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6029 }, + }) + + expect(screen.getByText(/batch could not be found/)).toBeTruthy() + }) + + it("falls back to the raw code for a cancel refusal it does not recognize", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect(screen.getByText(/reason 9999/)).toBeTruthy() + }) + + it("reads a declined cancellation as a choice, not a failure, and offers to try again", () => { + const { onRetry } = renderCancelModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + expect( + screen.getByRole("heading", { name: "You declined the request" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it("shows the same step progress machinery for a cancellation in flight", () => { + renderCancelModal({ status: "awaiting-signature" }) + + const steps = screen.getAllByRole("listitem") + expect(steps.map((step) => step.textContent)).toEqual([ + "Approved in your wallet, in progress", + "Sent to the network", + "Recorded", + ]) + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderCancelModal({ status: "awaiting-signature" }) + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/SubscriptionModal.tsx b/app/src/components/vault/SubscriptionModal.tsx index 47d1bcf..816c903 100644 --- a/app/src/components/vault/SubscriptionModal.tsx +++ b/app/src/components/vault/SubscriptionModal.tsx @@ -1,5 +1,9 @@ import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" import React from "react" +import { + type CancelDepositFailure, + type CancelDepositStatus, +} from "../../hooks/useCancelDeposit" import { type RequestDepositFailure, type RequestDepositStatus, @@ -9,15 +13,25 @@ import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" import styles from "./SubscriptionModal.module.css" -export type SubscriptionModalProps = { - status: RequestDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void -} +export type SubscriptionModalProps = + | { + action: "subscribe" + status: RequestDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } + | { + action: "cancel" + status: CancelDepositStatus + amount: string + ticker: string + onClose: () => void + onRetry: () => void + } -const contractErrorReason = (code: number): string => { +const subscribeContractErrorReason = (code: number): string => { switch (code) { case 6007: return "Enter an amount greater than zero." @@ -32,8 +46,24 @@ const contractErrorReason = (code: number): string => { } } +const cancelContractErrorReason = (code: number): string => { + switch (code) { + case 6001: + return "This request no longer exists to cancel." + case 6029: + return "This batch could not be found." + case 6039: + return "This batch has already been priced. Claim your shares instead of cancelling." + case 6041: + return "A price is now available for this batch. Claim your shares instead of cancelling." + default: + return `The vault declined this request (reason ${code}).` + } +} + const describeFailure = ( - failure: RequestDepositFailure, + failure: RequestDepositFailure | CancelDepositFailure, + contractErrorReason: (code: number) => string, ): { heading: string; body: string } => { switch (failure.kind) { case "declined": @@ -59,7 +89,7 @@ const describeFailure = ( } } -const describeStatus = ( +const describeSubscribeStatus = ( status: RequestDepositStatus, amount: string, ticker: string, @@ -90,10 +120,58 @@ const describeStatus = ( hash: status.hash, } case "failed": - return { ...describeFailure(status.failure), hash: status.hash } + return { + ...describeFailure(status.failure, subscribeContractErrorReason), + hash: status.hash, + } } } +const describeCancelStatus = ( + status: CancelDepositStatus, + amount: string, + ticker: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing your cancellation", + body: "We are getting your cancellation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `This returns ${amount} ${ticker} from escrow to your wallet. This request is withdrawn, not priced.`, + } + case "submitted": + return { + heading: "Sending your cancellation", + body: "Your cancellation is on its way to the network. This should only take a moment. Closing this window will not stop it.", + hash: status.hash, + } + case "confirmed": + return { + heading: "Request cancelled", + body: `${amount} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, cancelContractErrorReason), + hash: status.hash, + } + } +} + +const describeStatus = ( + props: SubscriptionModalProps, +): { heading: string; body: string; hash?: string } | undefined => + props.action === "subscribe" + ? describeSubscribeStatus(props.status, props.amount, props.ticker) + : describeCancelStatus(props.status, props.amount, props.ticker) + type StepId = "signature" | "network" | "recorded" type StepState = "done" | "current" | "upcoming" | "failed" @@ -106,7 +184,7 @@ const stepLabel: Record = { } const computeSteps = ( - status: RequestDepositStatus, + status: RequestDepositStatus | CancelDepositStatus, ): Record | undefined => { switch (status.status) { case "idle": @@ -147,20 +225,15 @@ const stepAnnouncement: Partial> = { failed: "failed", } -const SubscriptionModal: React.FC = ({ - status, - amount, - ticker, - onClose, - onRetry, -}) => { +const SubscriptionModal: React.FC = (props) => { + const { status, onClose, onRetry } = props const dialogRef = React.useRef(null) React.useEffect(() => { dialogRef.current?.focus() }, []) - const content = describeStatus(status, amount, ticker) + const content = describeStatus(props) if (content === undefined) return null const steps = computeSteps(status) diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts new file mode 100644 index 0000000..36b9833 --- /dev/null +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -0,0 +1,350 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { networkPassphrase } from "@stellar-scaffold/app-lib" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, renderHook, waitFor } from "@testing-library/react" +import { createElement, type ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + WalletContext, + type WalletContextType, +} from "../providers/WalletProvider" +import { useCancelDeposit } from "./useCancelDeposit" +import { depositBalanceKey } from "./useDepositBalance" +import { investorRequestsKey } from "./useInvestorRequests" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { cancel_deposit: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) +asyncVaultWriterMock.mockResolvedValue(vaultMock) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const investorAddress = "GINVESTORADDRESS1234567890" +const epochId = 3n + +const wallet: WalletContextType = { + address: investorAddress, + networkPassphrase, + balances: {}, + isPending: false, + updateBalances: async () => {}, + signTransaction: vi.fn() as WalletContextType["signTransaction"], +} + +const renderCancelDeposit = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(WalletContext, { value: wallet }, children), + ) + return { ...renderHook(() => useCancelDeposit(), { wrapper }), queryClient } +} + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +describe("useCancelDeposit", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed with the refunded amount, in order", async () => { + const signingGate = deferred() + const confirmGate = deferred() + const signAndSend = vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await signingGate.promise + watcher.onSubmitted() + await confirmGate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }, + ) + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + signingGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + confirmGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + refundedAmount: 100_0000000n, + }), + ) + + expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ + from: investorAddress, + epoch_id: epochId, + }) + }) + + it("shows preparing the instant the first press happens, before any network call resolves", async () => { + const clientGate = deferred() + asyncVaultWriterMock.mockReturnValueOnce(clientGate.promise) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + + await waitFor(() => + expect(result.current.status).toEqual({ status: "preparing" }), + ) + expect(vaultMock.cancel_deposit).not.toHaveBeenCalled() + }) + + it("invalidates the investor's cached requests and deposit balance once the cancellation confirms", async () => { + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + }), + }) + const { result, queryClient } = renderCancelDeposit() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: investorRequestsKey(investorAddress), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: depositBalanceKey(investorAddress), + }) + }) + + it("does not touch cached requests or balance when the vault refuses at simulation", async () => { + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6039)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderCancelDeposit() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6039)" }, + signAndSend, + }) + const { result } = renderCancelDeposit() + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6039 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderCancelDeposit() + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("invalidates cached requests and balance for an unknown outcome that reached the network but never confirmed", async () => { + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "FAILED" }, + result: 100_0000000n, + }), + }) + const { result, queryClient } = renderCancelDeposit() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "unknown" }, + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: investorRequestsKey(investorAddress), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: depositBalanceKey(investorAddress), + }) + }) + + it("does not invalidate anything when the failure happened before the transaction ever reached the network", async () => { + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockRejectedValue(new Error("could not broadcast")), + }) + const { result, queryClient } = renderCancelDeposit() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "unknown" }, + }) + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("marks a wallet connection failure that happens before any signature is requested as interrupted, not unknown", async () => { + asyncVaultWriterMock.mockRejectedValueOnce(new Error("could not connect")) + const { result } = renderCancelDeposit() + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "interrupted" }, + }) + }) + + it("ignores a second submission while one is already in flight", async () => { + const gate = deferred() + const signAndSend = vi.fn(async () => { + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }) + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit(epochId) + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(vaultMock.cancel_deposit).toHaveBeenCalledTimes(1) + }) + + it("reconnects a dismissed caller to a still-settling submission instead of ignoring it", async () => { + const gate = deferred() + const signAndSend = vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + watcher.onSubmitted() + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }, + ) + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + act(() => result.current.reset()) + expect(result.current.status).toEqual({ status: "idle" }) + + void result.current.submit(epochId) + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + expect(vaultMock.cancel_deposit).toHaveBeenCalledTimes(1) + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + }) + + it("carries the transaction hash from submission through to confirmation", async () => { + const confirmGate = deferred() + const signAndSend = vi.fn( + async ({ + watcher, + }: { + watcher: { onSubmitted: (response: { hash: string }) => void } + }) => { + watcher.onSubmitted({ hash: "a".repeat(64) }) + await confirmGate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }, + ) + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + + await waitFor(() => + expect(result.current.status).toEqual({ + status: "submitted", + hash: "a".repeat(64), + }), + ) + + confirmGate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ + status: "confirmed", + refundedAmount: 100_0000000n, + hash: "a".repeat(64), + }), + ) + }) +}) diff --git a/app/src/hooks/useCancelDeposit.ts b/app/src/hooks/useCancelDeposit.ts new file mode 100644 index 0000000..ed31edd --- /dev/null +++ b/app/src/hooks/useCancelDeposit.ts @@ -0,0 +1,131 @@ +import { + type Amount, + isUserRejection, + parseErrorCode, +} from "@stellar-scaffold/app-lib" +import { useQueryClient } from "@tanstack/react-query" +import { useCallback, useRef, useState } from "react" +import { asyncVaultWriter } from "../config/clients" +import { depositBalanceKey } from "./useDepositBalance" +import { investorRequestsKey } from "./useInvestorRequests" +import { useWallet } from "./useWallet" + +export type CancelDepositFailure = + | { kind: "declined" } + | { kind: "contract-error"; code: number } + | { kind: "interrupted" } + | { kind: "unknown" } + +export type CancelDepositStatus = + | { status: "idle" } + | { status: "preparing" } + | { status: "awaiting-signature" } + | { status: "submitted"; hash?: string } + | { status: "confirmed"; refundedAmount: Amount; hash?: string } + | { status: "failed"; failure: CancelDepositFailure; hash?: string } + +export interface UseCancelDeposit { + status: CancelDepositStatus + submit: (epochId: bigint) => Promise + reset: () => void +} + +export function useCancelDeposit(): UseCancelDeposit { + const { address, signTransaction } = useWallet() + const queryClient = useQueryClient() + const [status, setStatus] = useState({ status: "idle" }) + const submitting = useRef(false) + const dismissed = useRef(false) + const lastStatus = useRef({ status: "idle" }) + + const submit = useCallback( + async (epochId: bigint) => { + if (address === undefined) return + if (submitting.current) { + dismissed.current = false + setStatus(lastStatus.current) + return + } + submitting.current = true + dismissed.current = false + let hash: string | undefined + let signatureRequested = false + let reachedNetwork = false + const applyStatus = (next: CancelDepositStatus) => { + lastStatus.current = next + if (!dismissed.current) setStatus(next) + } + const invalidateRequestData = (owner: string) => { + void queryClient.invalidateQueries({ + queryKey: investorRequestsKey(owner), + }) + void queryClient.invalidateQueries({ + queryKey: depositBalanceKey(owner), + }) + } + applyStatus({ status: "preparing" }) + try { + const vault = await asyncVaultWriter({ + publicKey: address, + signTransaction, + }) + const tx = await vault.cancel_deposit({ + from: address, + epoch_id: epochId, + }) + const code = parseErrorCode(tx.simulation) + if (code !== null) { + applyStatus({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + return + } + signatureRequested = true + applyStatus({ status: "awaiting-signature" }) + const sent = await tx.signAndSend({ + watcher: { + onSubmitted: (response) => { + hash = response?.hash + reachedNetwork = true + applyStatus({ status: "submitted", hash }) + }, + onProgress: () => {}, + }, + }) + if (sent.getTransactionResponse?.status !== "SUCCESS") { + applyStatus({ status: "failed", failure: { kind: "unknown" }, hash }) + invalidateRequestData(address) + return + } + applyStatus({ + status: "confirmed", + refundedAmount: sent.result as Amount, + hash, + }) + invalidateRequestData(address) + } catch (error) { + applyStatus({ + status: "failed", + failure: isUserRejection(error) + ? { kind: "declined" } + : signatureRequested + ? { kind: "unknown" } + : { kind: "interrupted" }, + hash, + }) + if (reachedNetwork) invalidateRequestData(address) + } finally { + submitting.current = false + } + }, + [address, signTransaction, queryClient], + ) + + const reset = useCallback(() => { + dismissed.current = true + setStatus({ status: "idle" }) + }, []) + + return { status, submit, reset } +} diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index eaa8b7d..24d5051 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -27,6 +27,7 @@ const { mockRequests, mockVault, requestDepositMock, + cancelDepositMock, } = vi.hoisted(() => ({ mockVaultId: "CMOCKVAULTADDRESS1234567890", mockGovernanceAddress: "GGOVERNANCEADDRESS1234567890", @@ -58,8 +59,34 @@ const { >(), }, requestDepositMock: vi.fn(), + cancelDepositMock: vi.fn(), })) +const defaultCancelDepositImpl = async ({ + epoch_id, +}: { + from: string + epoch_id: bigint +}) => { + const request = mockRequests.deposits.get(epoch_id) + const refunded = request?.amount ?? 0n + return { + simulation: undefined, + signAndSend: async ({ + watcher, + }: { + watcher: { onSubmitted: () => void } + }) => { + watcher.onSubmitted() + mockRequests.deposits.delete(epoch_id) + return { + getTransactionResponse: { status: "SUCCESS" }, + result: refunded, + } + }, + } +} + const defaultRequestDepositImpl = async ({ amount, }: { @@ -88,6 +115,8 @@ const resetMockRequests = () => { mockRequests.redeems.clear() requestDepositMock.mockReset() requestDepositMock.mockImplementation(defaultRequestDepositImpl) + cancelDepositMock.mockReset() + cancelDepositMock.mockImplementation(defaultCancelDepositImpl) } beforeEach(() => { @@ -141,6 +170,7 @@ vi.mock("../config/clients", () => { }), paused: async () => ({ result: mockVault.paused }), request_deposit: requestDepositMock, + cancel_deposit: cancelDepositMock, } const oracle = { state: async () => ({ result: { tag: "Valid", values: undefined } }), @@ -726,4 +756,89 @@ describe("VaultPreview", () => { ).toBeTruthy() expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy() }) + + it("offers a Cancel action on a waiting subscription", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 150_0000000n, + claimed: false, + }) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: /^Waiting/ })) + + expect(await screen.findByRole("button", { name: "Cancel" })).toBeTruthy() + }) + + it("reaches cancel_deposit with the request's own batch when Cancel is pressed", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 150_0000000n, + claimed: false, + }) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: /^Waiting/ })) + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })) + + await waitFor(() => expect(cancelDepositMock).toHaveBeenCalledTimes(1)) + expect(cancelDepositMock).toHaveBeenCalledWith({ + from: investorAddress, + epoch_id: mockRequests.currentEpoch, + }) + }) + + it("shows the cancellation modal with the request's own amount while it is in flight", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 150_0000000n, + claimed: false, + }) + let resolveSend!: () => void + const gate = new Promise((resolve) => { + resolveSend = resolve + }) + cancelDepositMock.mockImplementationOnce(async () => ({ + simulation: undefined, + signAndSend: async ({ + watcher, + }: { + watcher: { onSubmitted: () => void } + }) => { + watcher.onSubmitted() + await gate + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 150_0000000n, + } + }, + })) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: /^Waiting/ })) + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })) + + expect( + await screen.findByRole("heading", { name: "Sending your cancellation" }), + ).toBeTruthy() + expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() + + resolveSend() + }) + + it("removes a cancelled request from the list without a manual refresh", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 150_0000000n, + claimed: false, + }) + renderVaultPreview(connectedWallet) + + fireEvent.click(await screen.findByRole("tab", { name: /^Waiting/ })) + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })) + + expect( + await screen.findByRole("heading", { name: "Request cancelled" }), + ).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + + expect(await screen.findByRole("tab", { name: "Waiting 0" })).toBeTruthy() + }) }) diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index 2cd8aac..9f3afb0 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -3,6 +3,7 @@ import { type Amount, connectWallet, formatAmount, + formatScaled, networkStatus, parseAmount, parseUnits, @@ -21,8 +22,12 @@ import { type RequestStage } from "../components/vault/RequestCard" import RequestList, { type RequestGroup } from "../components/vault/RequestList" import SubscriptionModal from "../components/vault/SubscriptionModal" import { contractRows, vaultContractId } from "../config/contracts" +import { useCancelDeposit } from "../hooks/useCancelDeposit" import { useDepositBalance } from "../hooks/useDepositBalance" -import { useInvestorRequests } from "../hooks/useInvestorRequests" +import { + type InvestorRequest, + useInvestorRequests, +} from "../hooks/useInvestorRequests" import { useIsAllowed } from "../hooks/useIsAllowed" import { useNavPrice } from "../hooks/useNavPrice" import { useRequestDeposit } from "../hooks/useRequestDeposit" @@ -90,6 +95,16 @@ const VaultPreview: React.FC = () => { } = useRequestDeposit() const [pendingAmountLabel, setPendingAmountLabel] = React.useState("") const [pendingAmount, setPendingAmount] = React.useState(null) + const { + status: cancelDepositStatus, + submit: submitCancelDeposit, + reset: resetCancelDeposit, + } = useCancelDeposit() + const [pendingCancelAmountLabel, setPendingCancelAmountLabel] = + React.useState("") + const [pendingCancelEpochId, setPendingCancelEpochId] = React.useState< + bigint | null + >(null) const { state, appNetwork, walletNetwork } = networkStatus( address, networkPassphrase, @@ -106,10 +121,20 @@ const VaultPreview: React.FC = () => { pause, hasOpenSubscription: isSubscriptionOpen(requests), }) ?? toPriceBlock(nav, isPendingNav) + const cancelDeposit = (request: InvestorRequest) => { + setPendingCancelEpochId(request.epochId) + setPendingCancelAmountLabel(formatScaled(request.amount, AMOUNT_DECIMALS)) + void submitCancelDeposit(request.epochId) + } + const retryCancelDeposit = () => { + if (pendingCancelEpochId === null) return + void submitCancelDeposit(pendingCancelEpochId) + } + const messages = emptyMessages(requests.status) const entriesByStage = requests.status === "loaded" - ? toRequestEntriesByStage(requests, symbols) + ? toRequestEntriesByStage(requests, symbols, undefined, cancelDeposit) : undefined const requestGroups: [RequestGroup, ...RequestGroup[]] = [ { @@ -296,6 +321,7 @@ const VaultPreview: React.FC = () => { {requestDepositStatus.status !== "idle" && ( { onRetry={retryRequestDeposit} /> )} + {cancelDepositStatus.status !== "idle" && ( + + )} ) } diff --git a/app/src/pages/vaultRequests.test.ts b/app/src/pages/vaultRequests.test.ts index 6790d48..8d84d51 100644 --- a/app/src/pages/vaultRequests.test.ts +++ b/app/src/pages/vaultRequests.test.ts @@ -3,7 +3,7 @@ import { type EpochStatus, type Price, } from "@stellar-scaffold/app-lib" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { type ArchivedRequest, type InvestorRequest, @@ -271,6 +271,70 @@ describe("toRequestEntriesByStage", () => { expect(result.blocked).toHaveLength(1) }) + it("offers no cancel action on a waiting deposit when no handler is given", () => { + const waiting: InvestorRequest = { ...deposit, epochStatus: open } + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + ) + + expect(result.waiting[0]?.actions).toEqual([]) + }) + + it("offers a cancel action on a waiting deposit when a handler is given", () => { + const waiting: InvestorRequest = { ...deposit, epochStatus: open } + const onCancel = () => {} + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + undefined, + onCancel, + ) + + expect(result.waiting[0]?.actions).toEqual([ + { label: "Cancel", kind: "ordinary", onPress: expect.any(Function) }, + ]) + }) + + it("presses the cancel action with the exact request it belongs to", () => { + const waiting: InvestorRequest = { ...deposit, epochStatus: open } + const onCancel = vi.fn() + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + undefined, + onCancel, + ) + + result.waiting[0]?.actions[0]?.onPress() + + expect(onCancel).toHaveBeenCalledWith(waiting) + }) + + it("offers no cancel action on a waiting redemption, since cancel_deposit is deposit-only", () => { + const waiting: InvestorRequest = { ...redeem, epochStatus: open } + const onCancel = () => {} + const result = toRequestEntriesByStage( + { requests: [waiting], archived: [], unreadable: [] }, + tokens, + undefined, + onCancel, + ) + + expect(result.waiting[0]?.actions).toEqual([]) + }) + + it("offers no cancel action once a deposit is priced and no longer waiting", () => { + const result = toRequestEntriesByStage( + { requests: [deposit], archived: [], unreadable: [] }, + tokens, + undefined, + () => {}, + ) + + expect(result.ready[0]?.actions).toEqual([]) + }) + it("places an unreadable request in blocked, distinguishable from an archived one", () => { const unreadable: UnreadableRequest = { epochId: 4n, side: "redeem" } const result = toRequestEntriesByStage( diff --git a/app/src/pages/vaultRequests.ts b/app/src/pages/vaultRequests.ts index fdcce2d..a1cf22e 100644 --- a/app/src/pages/vaultRequests.ts +++ b/app/src/pages/vaultRequests.ts @@ -5,6 +5,7 @@ import { type Price, } from "@stellar-scaffold/app-lib" import { + type RequestAction, type RequestEntry, type RequestStage, type RequestTone, @@ -83,12 +84,25 @@ const baseEntry = ( actions: [], }) +function cancelAction( + request: InvestorRequest, + onCancel: (request: InvestorRequest) => void, +): RequestAction { + return { label: "Cancel", kind: "ordinary", onPress: () => onCancel(request) } +} + function waitingEntry( request: InvestorRequest, tokens: RequestTokens, + onCancel: ((request: InvestorRequest) => void) | undefined, ): RequestEntry { + const actions = + onCancel !== undefined && request.side === "deposit" + ? [cancelAction(request, onCancel)] + : [] return { ...baseEntry(request, tokens), + actions, outAmount: "Not yet priced", outTone: "word", state: request.epochStatus.tag, @@ -148,9 +162,10 @@ export function toPresentEntry( request: InvestorRequest, tokens: RequestTokens, refusal: ClaimRefusal | undefined, + onCancel?: (request: InvestorRequest) => void, ): RequestEntry { const stage = assignStage(request, refusal) - if (stage === "waiting") return waitingEntry(request, tokens) + if (stage === "waiting") return waitingEntry(request, tokens, onCancel) if (!hasValidPrice(request.sharePrice)) return invalidPriceEntry(request, tokens) if (stage === "blocked" && refusal !== undefined) @@ -201,6 +216,7 @@ export function toRequestEntriesByStage( tokens: RequestTokens, refusalFor: (request: InvestorRequest) => ClaimRefusal | undefined = () => undefined, + onCancel?: (request: InvestorRequest) => void, ): EntriesByStage { const entries: EntriesByStage = { ready: [], blocked: [], waiting: [] } @@ -208,7 +224,7 @@ export function toRequestEntriesByStage( if (request.claimed) continue const refusal = refusalFor(request) entries[assignStage(request, refusal)].push( - toPresentEntry(request, tokens, refusal), + toPresentEntry(request, tokens, refusal, onCancel), ) } for (const request of loaded.archived) { From 87e8c7b9c9eaa3ec4d4941dc33623835bde0108d Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 25 Sep 2026 12:18:24 -0300 Subject: [PATCH 2/4] refactor(app): extract the shared transaction flow --- ...module.css => TransactionModal.module.css} | 0 ...dal.test.tsx => TransactionModal.test.tsx} | 10 +- ...criptionModal.tsx => TransactionModal.tsx} | 27 ++-- app/src/hooks/useCancelDeposit.ts | 137 +++--------------- app/src/hooks/useContractTransaction.ts | 133 +++++++++++++++++ app/src/hooks/useRequestDeposit.ts | 127 ++-------------- app/src/pages/VaultPreview.tsx | 6 +- 7 files changed, 185 insertions(+), 255 deletions(-) rename app/src/components/vault/{SubscriptionModal.module.css => TransactionModal.module.css} (100%) rename app/src/components/vault/{SubscriptionModal.test.tsx => TransactionModal.test.tsx} (98%) rename app/src/components/vault/{SubscriptionModal.tsx => TransactionModal.tsx} (93%) create mode 100644 app/src/hooks/useContractTransaction.ts diff --git a/app/src/components/vault/SubscriptionModal.module.css b/app/src/components/vault/TransactionModal.module.css similarity index 100% rename from app/src/components/vault/SubscriptionModal.module.css rename to app/src/components/vault/TransactionModal.module.css diff --git a/app/src/components/vault/SubscriptionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx similarity index 98% rename from app/src/components/vault/SubscriptionModal.test.tsx rename to app/src/components/vault/TransactionModal.test.tsx index f58601c..11f5b78 100644 --- a/app/src/components/vault/SubscriptionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" -import SubscriptionModal from "./SubscriptionModal" +import TransactionModal from "./TransactionModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -19,7 +19,7 @@ const renderModal = (status: RequestDepositStatus) => { const onClose = vi.fn() const onRetry = vi.fn() const view = render( - { const onClose = vi.fn() const onRetry = vi.fn() const view = render( - { return { ...view, onClose, onRetry } } -describe("SubscriptionModal", () => { +describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -328,7 +328,7 @@ describe("SubscriptionModal", () => { }) }) -describe("SubscriptionModal, cancelling", () => { +describe("TransactionModal, cancelling", () => { it("opens with a preparing state naming the cancellation, not the request", () => { renderCancelModal({ status: "preparing" }) diff --git a/app/src/components/vault/SubscriptionModal.tsx b/app/src/components/vault/TransactionModal.tsx similarity index 93% rename from app/src/components/vault/SubscriptionModal.tsx rename to app/src/components/vault/TransactionModal.tsx index 816c903..56c1b75 100644 --- a/app/src/components/vault/SubscriptionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,19 +1,14 @@ import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" import React from "react" -import { - type CancelDepositFailure, - type CancelDepositStatus, -} from "../../hooks/useCancelDeposit" -import { - type RequestDepositFailure, - type RequestDepositStatus, -} from "../../hooks/useRequestDeposit" +import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" +import { type TransactionFailure } from "../../hooks/useContractTransaction" +import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import typeStyles from "../../styles/type.module.css" import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" -import styles from "./SubscriptionModal.module.css" +import styles from "./TransactionModal.module.css" -export type SubscriptionModalProps = +export type TransactionModalProps = | { action: "subscribe" status: RequestDepositStatus @@ -62,7 +57,7 @@ const cancelContractErrorReason = (code: number): string => { } const describeFailure = ( - failure: RequestDepositFailure | CancelDepositFailure, + failure: TransactionFailure, contractErrorReason: (code: number) => string, ): { heading: string; body: string } => { switch (failure.kind) { @@ -166,7 +161,7 @@ const describeCancelStatus = ( } const describeStatus = ( - props: SubscriptionModalProps, + props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => props.action === "subscribe" ? describeSubscribeStatus(props.status, props.amount, props.ticker) @@ -225,7 +220,7 @@ const stepAnnouncement: Partial> = { failed: "failed", } -const SubscriptionModal: React.FC = (props) => { +const TransactionModal: React.FC = (props) => { const { status, onClose, onRetry } = props const dialogRef = React.useRef(null) @@ -242,7 +237,7 @@ const SubscriptionModal: React.FC = (props) => { content.hash === undefined ? null : explorerTransaction(content.hash) const offersRetry = status.status === "failed" && status.failure.kind === "declined" - const headingId = "subscription-modal-heading" + const headingId = "transaction-modal-heading" const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Escape") { @@ -302,7 +297,7 @@ const SubscriptionModal: React.FC = (props) => { {steps !== undefined && ( -
    +
      {STEP_ORDER.map((id) => { const state = steps[id] const announcement = stepAnnouncement[state] @@ -345,4 +340,4 @@ const SubscriptionModal: React.FC = (props) => { ) } -export default SubscriptionModal +export default TransactionModal diff --git a/app/src/hooks/useCancelDeposit.ts b/app/src/hooks/useCancelDeposit.ts index ed31edd..cddd1e6 100644 --- a/app/src/hooks/useCancelDeposit.ts +++ b/app/src/hooks/useCancelDeposit.ts @@ -1,28 +1,12 @@ -import { - type Amount, - isUserRejection, - parseErrorCode, -} from "@stellar-scaffold/app-lib" -import { useQueryClient } from "@tanstack/react-query" -import { useCallback, useRef, useState } from "react" +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" -import { depositBalanceKey } from "./useDepositBalance" -import { investorRequestsKey } from "./useInvestorRequests" -import { useWallet } from "./useWallet" - -export type CancelDepositFailure = - | { kind: "declined" } - | { kind: "contract-error"; code: number } - | { kind: "interrupted" } - | { kind: "unknown" } +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" -export type CancelDepositStatus = - | { status: "idle" } - | { status: "preparing" } - | { status: "awaiting-signature" } - | { status: "submitted"; hash?: string } - | { status: "confirmed"; refundedAmount: Amount; hash?: string } - | { status: "failed"; failure: CancelDepositFailure; hash?: string } +export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> export interface UseCancelDeposit { status: CancelDepositStatus @@ -31,101 +15,16 @@ export interface UseCancelDeposit { } export function useCancelDeposit(): UseCancelDeposit { - const { address, signTransaction } = useWallet() - const queryClient = useQueryClient() - const [status, setStatus] = useState({ status: "idle" }) - const submitting = useRef(false) - const dismissed = useRef(false) - const lastStatus = useRef({ status: "idle" }) - - const submit = useCallback( - async (epochId: bigint) => { - if (address === undefined) return - if (submitting.current) { - dismissed.current = false - setStatus(lastStatus.current) - return - } - submitting.current = true - dismissed.current = false - let hash: string | undefined - let signatureRequested = false - let reachedNetwork = false - const applyStatus = (next: CancelDepositStatus) => { - lastStatus.current = next - if (!dismissed.current) setStatus(next) - } - const invalidateRequestData = (owner: string) => { - void queryClient.invalidateQueries({ - queryKey: investorRequestsKey(owner), - }) - void queryClient.invalidateQueries({ - queryKey: depositBalanceKey(owner), - }) - } - applyStatus({ status: "preparing" }) - try { - const vault = await asyncVaultWriter({ - publicKey: address, - signTransaction, - }) - const tx = await vault.cancel_deposit({ - from: address, - epoch_id: epochId, - }) - const code = parseErrorCode(tx.simulation) - if (code !== null) { - applyStatus({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - return - } - signatureRequested = true - applyStatus({ status: "awaiting-signature" }) - const sent = await tx.signAndSend({ - watcher: { - onSubmitted: (response) => { - hash = response?.hash - reachedNetwork = true - applyStatus({ status: "submitted", hash }) - }, - onProgress: () => {}, - }, - }) - if (sent.getTransactionResponse?.status !== "SUCCESS") { - applyStatus({ status: "failed", failure: { kind: "unknown" }, hash }) - invalidateRequestData(address) - return - } - applyStatus({ - status: "confirmed", - refundedAmount: sent.result as Amount, - hash, - }) - invalidateRequestData(address) - } catch (error) { - applyStatus({ - status: "failed", - failure: isUserRejection(error) - ? { kind: "declined" } - : signatureRequested - ? { kind: "unknown" } - : { kind: "interrupted" }, - hash, - }) - if (reachedNetwork) invalidateRequestData(address) - } finally { - submitting.current = false - } - }, - [address, signTransaction, queryClient], + const call = useCallback( + (signer: Signer, epochId: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.cancel_deposit({ from: signer.publicKey, epoch_id: epochId }), + ), + [], ) - - const reset = useCallback(() => { - dismissed.current = true - setStatus({ status: "idle" }) - }, []) - - return { status, submit, reset } + const toConfirmed = useCallback( + (refundedAmount: bigint) => ({ refundedAmount: refundedAmount as Amount }), + [], + ) + return useContractTransaction(call, toConfirmed) } diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts new file mode 100644 index 0000000..3ac545c --- /dev/null +++ b/app/src/hooks/useContractTransaction.ts @@ -0,0 +1,133 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { + type Signer, + isUserRejection, + parseErrorCode, +} from "@stellar-scaffold/app-lib" +import { useQueryClient } from "@tanstack/react-query" +import { useCallback, useRef, useState } from "react" +import { depositBalanceKey } from "./useDepositBalance" +import { investorRequestsKey } from "./useInvestorRequests" +import { useWallet } from "./useWallet" + +export type TransactionFailure = + | { kind: "declined" } + | { kind: "contract-error"; code: number } + | { kind: "interrupted" } + | { kind: "unknown" } + +export type TransactionStatus = + | { status: "idle" } + | { status: "preparing" } + | { status: "awaiting-signature" } + | { status: "submitted"; hash?: string } + | ({ status: "confirmed"; hash?: string } & TConfirmed) + | { status: "failed"; failure: TransactionFailure; hash?: string } + +export interface UseContractTransaction { + status: TransactionStatus + submit: (arg: TArg) => Promise + reset: () => void +} + +export function useContractTransaction< + TArg, + TResult, + TConfirmed extends object, +>( + call: (signer: Signer, arg: TArg) => Promise>, + toConfirmed: (result: TResult) => TConfirmed, +): UseContractTransaction { + const { address, signTransaction } = useWallet() + const queryClient = useQueryClient() + const [status, setStatus] = useState>({ + status: "idle", + }) + const submitting = useRef(false) + const dismissed = useRef(false) + const lastStatus = useRef>({ status: "idle" }) + + const submit = useCallback( + async (arg: TArg) => { + if (address === undefined) return + if (submitting.current) { + dismissed.current = false + setStatus(lastStatus.current) + return + } + submitting.current = true + dismissed.current = false + let hash: string | undefined + let signatureRequested = false + let reachedNetwork = false + const applyStatus = (next: TransactionStatus) => { + lastStatus.current = next + if (!dismissed.current) setStatus(next) + } + const invalidateRequestData = (owner: string) => { + void queryClient.invalidateQueries({ + queryKey: investorRequestsKey(owner), + }) + void queryClient.invalidateQueries({ + queryKey: depositBalanceKey(owner), + }) + } + applyStatus({ status: "preparing" }) + try { + const tx = await call({ publicKey: address, signTransaction }, arg) + const code = parseErrorCode(tx.simulation) + if (code !== null) { + applyStatus({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + return + } + signatureRequested = true + applyStatus({ status: "awaiting-signature" }) + const sent = await tx.signAndSend({ + watcher: { + onSubmitted: (response) => { + hash = response?.hash + reachedNetwork = true + applyStatus({ status: "submitted", hash }) + }, + onProgress: () => {}, + }, + }) + if (sent.getTransactionResponse?.status !== "SUCCESS") { + applyStatus({ status: "failed", failure: { kind: "unknown" }, hash }) + invalidateRequestData(address) + return + } + applyStatus({ + status: "confirmed", + ...toConfirmed(sent.result), + hash, + } as TransactionStatus) + invalidateRequestData(address) + } catch (error) { + applyStatus({ + status: "failed", + failure: isUserRejection(error) + ? { kind: "declined" } + : signatureRequested + ? { kind: "unknown" } + : { kind: "interrupted" }, + hash, + }) + if (reachedNetwork) invalidateRequestData(address) + } finally { + submitting.current = false + } + }, + [address, signTransaction, call, toConfirmed, queryClient], + ) + + const reset = useCallback(() => { + dismissed.current = true + setStatus({ status: "idle" }) + }, []) + + return { status, submit, reset } +} diff --git a/app/src/hooks/useRequestDeposit.ts b/app/src/hooks/useRequestDeposit.ts index 8b81cfb..cc74139 100644 --- a/app/src/hooks/useRequestDeposit.ts +++ b/app/src/hooks/useRequestDeposit.ts @@ -1,28 +1,12 @@ -import { - type Amount, - isUserRejection, - parseErrorCode, -} from "@stellar-scaffold/app-lib" -import { useQueryClient } from "@tanstack/react-query" -import { useCallback, useRef, useState } from "react" +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" import { asyncVaultWriter } from "../config/clients" -import { depositBalanceKey } from "./useDepositBalance" -import { investorRequestsKey } from "./useInvestorRequests" -import { useWallet } from "./useWallet" - -export type RequestDepositFailure = - | { kind: "declined" } - | { kind: "contract-error"; code: number } - | { kind: "interrupted" } - | { kind: "unknown" } +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" -export type RequestDepositStatus = - | { status: "idle" } - | { status: "preparing" } - | { status: "awaiting-signature" } - | { status: "submitted"; hash?: string } - | { status: "confirmed"; epochId: bigint; hash?: string } - | { status: "failed"; failure: RequestDepositFailure; hash?: string } +export type RequestDepositStatus = TransactionStatus<{ epochId: bigint }> export interface UseRequestDeposit { status: RequestDepositStatus @@ -31,94 +15,13 @@ export interface UseRequestDeposit { } export function useRequestDeposit(): UseRequestDeposit { - const { address, signTransaction } = useWallet() - const queryClient = useQueryClient() - const [status, setStatus] = useState({ status: "idle" }) - const submitting = useRef(false) - const dismissed = useRef(false) - const lastStatus = useRef({ status: "idle" }) - - const submit = useCallback( - async (amount: Amount) => { - if (address === undefined) return - if (submitting.current) { - dismissed.current = false - setStatus(lastStatus.current) - return - } - submitting.current = true - dismissed.current = false - let hash: string | undefined - let signatureRequested = false - let reachedNetwork = false - const applyStatus = (next: RequestDepositStatus) => { - lastStatus.current = next - if (!dismissed.current) setStatus(next) - } - const invalidateRequestData = (owner: string) => { - void queryClient.invalidateQueries({ - queryKey: investorRequestsKey(owner), - }) - void queryClient.invalidateQueries({ - queryKey: depositBalanceKey(owner), - }) - } - applyStatus({ status: "preparing" }) - try { - const vault = await asyncVaultWriter({ - publicKey: address, - signTransaction, - }) - const tx = await vault.request_deposit({ from: address, amount }) - const code = parseErrorCode(tx.simulation) - if (code !== null) { - applyStatus({ - status: "failed", - failure: { kind: "contract-error", code }, - }) - return - } - signatureRequested = true - applyStatus({ status: "awaiting-signature" }) - const sent = await tx.signAndSend({ - watcher: { - onSubmitted: (response) => { - hash = response?.hash - reachedNetwork = true - applyStatus({ status: "submitted", hash }) - }, - onProgress: () => {}, - }, - }) - if (sent.getTransactionResponse?.status !== "SUCCESS") { - applyStatus({ status: "failed", failure: { kind: "unknown" }, hash }) - invalidateRequestData(address) - return - } - applyStatus({ status: "confirmed", epochId: sent.result, hash }) - invalidateRequestData(address) - } catch (error) { - applyStatus({ - status: "failed", - failure: isUserRejection(error) - ? { kind: "declined" } - : signatureRequested - ? { kind: "unknown" } - : { kind: "interrupted" }, - hash, - }) - if (reachedNetwork) invalidateRequestData(address) - } finally { - submitting.current = false - } - }, - [address, signTransaction, queryClient], + const call = useCallback( + (signer: Signer, amount: Amount) => + asyncVaultWriter(signer).then((vault) => + vault.request_deposit({ from: signer.publicKey, amount }), + ), + [], ) - - const reset = useCallback(() => { - dismissed.current = true - setStatus({ status: "idle" }) - }, []) - - return { status, submit, reset } + const toConfirmed = useCallback((epochId: bigint) => ({ epochId }), []) + return useContractTransaction(call, toConfirmed) } diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index 9f3afb0..860516a 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -20,7 +20,7 @@ import MetricsStrip, { type Metric } from "../components/vault/MetricsStrip" import PositionCard from "../components/vault/PositionCard" import { type RequestStage } from "../components/vault/RequestCard" import RequestList, { type RequestGroup } from "../components/vault/RequestList" -import SubscriptionModal from "../components/vault/SubscriptionModal" +import TransactionModal from "../components/vault/TransactionModal" import { contractRows, vaultContractId } from "../config/contracts" import { useCancelDeposit } from "../hooks/useCancelDeposit" import { useDepositBalance } from "../hooks/useDepositBalance" @@ -320,7 +320,7 @@ const VaultPreview: React.FC = () => { {requestDepositStatus.status !== "idle" && ( - { /> )} {cancelDepositStatus.status !== "idle" && ( - Date: Fri, 25 Sep 2026 13:16:03 -0300 Subject: [PATCH 3/4] fix(app): keep one cancellation from narrating another --- .../vault/TransactionModal.test.tsx | 23 ++++-- app/src/components/vault/TransactionModal.tsx | 11 ++- app/src/hooks/useCancelDeposit.test.ts | 80 +++++++++++++++++++ app/src/hooks/useContractTransaction.ts | 7 +- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 11f5b78..38dda5c 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,4 +1,5 @@ import { type Amount } from "@stellar-scaffold/app-lib" +import type * as AppLib from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" @@ -9,7 +10,8 @@ const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), })) -vi.mock("@stellar-scaffold/app-lib", () => ({ +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), shortAddress: (address: string) => `${address.slice(0, 4)}...${address.slice(-4)}`, explorerTransaction: explorerTransactionMock, @@ -373,6 +375,17 @@ describe("TransactionModal, cancelling", () => { expect(screen.queryByText(/Epoch/)).toBeNull() }) + it("reports the amount the vault returned, not the one captured when the investor pressed cancel", () => { + renderCancelModal({ + status: "confirmed", + refundedAmount: 275_5000000n as Amount, + hash: "b".repeat(64), + }) + + expect(screen.getByText(/275\.50 USDC/)).toBeTruthy() + expect(screen.queryByText(/150\.00/)).toBeNull() + }) + it("names the vault's own reason for a cancel-specific contract refusal, distinct from subscribe's codes", () => { renderCancelModal({ status: "failed", @@ -387,15 +400,15 @@ describe("TransactionModal, cancelling", () => { ).toBeTruthy() }) - it("names PriceAvailable distinctly from AlreadyPriced", () => { + it("names PriceAvailable distinctly from AlreadyPriced, without sending the investor to claim yet", () => { renderCancelModal({ status: "failed", failure: { kind: "contract-error", code: 6041 }, }) - expect( - screen.getByText(/price is now available for this batch/), - ).toBeTruthy() + expect(screen.getByText(/A price is available for this batch/)).toBeTruthy() + expect(screen.getByText(/claimable once it is/)).toBeTruthy() + expect(screen.queryByText(/Claim your shares instead/)).toBeNull() }) it("names RequestNotFound for a cancellation of a request that no longer exists", () => { diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 56c1b75..51937af 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,4 +1,9 @@ -import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" +import { + AMOUNT_DECIMALS, + explorerTransaction, + formatScaled, + shortAddress, +} from "@stellar-scaffold/app-lib" import React from "react" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type TransactionFailure } from "../../hooks/useContractTransaction" @@ -50,7 +55,7 @@ const cancelContractErrorReason = (code: number): string => { case 6039: return "This batch has already been priced. Claim your shares instead of cancelling." case 6041: - return "A price is now available for this batch. Claim your shares instead of cancelling." + return "A price is available for this batch, so this request can no longer be cancelled. The batch will be priced shortly, and your shares are claimable once it is." default: return `The vault declined this request (reason ${code}).` } @@ -149,7 +154,7 @@ const describeCancelStatus = ( case "confirmed": return { heading: "Request cancelled", - body: `${amount} ${ticker} has been returned to your wallet.`, + body: `${formatScaled(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, hash: status.hash, } case "failed": diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index 36b9833..73840b6 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -24,6 +24,7 @@ vi.mock("../config/clients", () => ({ const investorAddress = "GINVESTORADDRESS1234567890" const epochId = 3n +const otherEpochId = 4n const wallet: WalletContextType = { address: investorAddress, @@ -307,6 +308,85 @@ describe("useCancelDeposit", () => { await waitFor(() => expect(result.current.status.status).toBe("confirmed")) }) + it("submits a different request instead of replaying the one already in flight", async () => { + const gate = deferred() + const signAndSend = vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + watcher.onSubmitted() + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }, + ) + vaultMock.cancel_deposit.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCancelDeposit() + + void result.current.submit(epochId) + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + act(() => result.current.reset()) + void result.current.submit(otherEpochId) + + await waitFor(() => + expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ + from: investorAddress, + epoch_id: otherEpochId, + }), + ) + + gate.resolve() + }) + + it("keeps a superseded submission from narrating over the request pressed after it", async () => { + const gates = new Map>>() + vaultMock.cancel_deposit.mockImplementation( + ({ epoch_id }: { epoch_id: bigint }) => { + const gate = deferred() + gates.set(epoch_id, gate) + return Promise.resolve({ + simulation: undefined, + signAndSend: async ({ + watcher, + }: { + watcher: { onSubmitted: () => void } + }) => { + watcher.onSubmitted() + await gate.promise + return { + getTransactionResponse: { status: "SUCCESS" }, + result: 100_0000000n, + } + }, + }) + }, + ) + const { result } = renderCancelDeposit() + + const first = result.current.submit(epochId) + await waitFor(() => + expect(result.current.status).toEqual({ status: "submitted" }), + ) + + act(() => result.current.reset()) + void result.current.submit(otherEpochId) + await waitFor(() => expect(gates.has(otherEpochId)).toBe(true)) + + gates.get(epochId)?.resolve() + await act(() => first) + + expect(result.current.status).toEqual({ status: "submitted" }) + + gates.get(otherEpochId)?.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + }) + it("carries the transaction hash from submission through to confirmation", async () => { const confirmGate = deferred() const signAndSend = vi.fn( diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 3ac545c..02940c6 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -44,23 +44,26 @@ export function useContractTransaction< status: "idle", }) const submitting = useRef(false) + const inFlightArg = useRef(undefined) const dismissed = useRef(false) const lastStatus = useRef>({ status: "idle" }) const submit = useCallback( async (arg: TArg) => { if (address === undefined) return - if (submitting.current) { + if (submitting.current && inFlightArg.current === arg) { dismissed.current = false setStatus(lastStatus.current) return } submitting.current = true + inFlightArg.current = arg dismissed.current = false let hash: string | undefined let signatureRequested = false let reachedNetwork = false const applyStatus = (next: TransactionStatus) => { + if (inFlightArg.current !== arg) return lastStatus.current = next if (!dismissed.current) setStatus(next) } @@ -118,7 +121,7 @@ export function useContractTransaction< }) if (reachedNetwork) invalidateRequestData(address) } finally { - submitting.current = false + if (inFlightArg.current === arg) submitting.current = false } }, [address, signTransaction, call, toConfirmed, queryClient], From c4b61493e02d58ca89f494c754df80d795eed9bf Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 25 Sep 2026 13:36:05 -0300 Subject: [PATCH 4/4] fix(app): report whether a submission was accepted --- app/src/hooks/useCancelDeposit.test.ts | 54 ++++--------------------- app/src/hooks/useCancelDeposit.ts | 2 +- app/src/hooks/useContractTransaction.ts | 47 +++++++++++---------- app/src/hooks/useRequestDeposit.ts | 2 +- app/src/pages/VaultPreview.test.tsx | 26 ++++++++++++ app/src/pages/VaultPreview.tsx | 39 ++++++++++-------- 6 files changed, 83 insertions(+), 87 deletions(-) diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts index 73840b6..1463289 100644 --- a/app/src/hooks/useCancelDeposit.test.ts +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -308,7 +308,7 @@ describe("useCancelDeposit", () => { await waitFor(() => expect(result.current.status.status).toBe("confirmed")) }) - it("submits a different request instead of replaying the one already in flight", async () => { + it("reconnects to the request already in flight instead of starting a different one, so the modal never shows one request's amount against another's transaction", async () => { const gate = deferred() const signAndSend = vi.fn( async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { @@ -334,56 +334,16 @@ describe("useCancelDeposit", () => { act(() => result.current.reset()) void result.current.submit(otherEpochId) - await waitFor(() => - expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ - from: investorAddress, - epoch_id: otherEpochId, - }), - ) - - gate.resolve() - }) - - it("keeps a superseded submission from narrating over the request pressed after it", async () => { - const gates = new Map>>() - vaultMock.cancel_deposit.mockImplementation( - ({ epoch_id }: { epoch_id: bigint }) => { - const gate = deferred() - gates.set(epoch_id, gate) - return Promise.resolve({ - simulation: undefined, - signAndSend: async ({ - watcher, - }: { - watcher: { onSubmitted: () => void } - }) => { - watcher.onSubmitted() - await gate.promise - return { - getTransactionResponse: { status: "SUCCESS" }, - result: 100_0000000n, - } - }, - }) - }, - ) - const { result } = renderCancelDeposit() - - const first = result.current.submit(epochId) await waitFor(() => expect(result.current.status).toEqual({ status: "submitted" }), ) + expect(vaultMock.cancel_deposit).toHaveBeenCalledTimes(1) + expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ + from: investorAddress, + epoch_id: epochId, + }) - act(() => result.current.reset()) - void result.current.submit(otherEpochId) - await waitFor(() => expect(gates.has(otherEpochId)).toBe(true)) - - gates.get(epochId)?.resolve() - await act(() => first) - - expect(result.current.status).toEqual({ status: "submitted" }) - - gates.get(otherEpochId)?.resolve() + gate.resolve() await waitFor(() => expect(result.current.status.status).toBe("confirmed")) }) diff --git a/app/src/hooks/useCancelDeposit.ts b/app/src/hooks/useCancelDeposit.ts index cddd1e6..d7ce9ae 100644 --- a/app/src/hooks/useCancelDeposit.ts +++ b/app/src/hooks/useCancelDeposit.ts @@ -10,7 +10,7 @@ export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> export interface UseCancelDeposit { status: CancelDepositStatus - submit: (epochId: bigint) => Promise + submit: (epochId: bigint) => Promise reset: () => void } diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 02940c6..c882791 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -26,7 +26,7 @@ export type TransactionStatus = export interface UseContractTransaction { status: TransactionStatus - submit: (arg: TArg) => Promise + submit: (arg: TArg) => Promise reset: () => void } @@ -44,30 +44,19 @@ export function useContractTransaction< status: "idle", }) const submitting = useRef(false) - const inFlightArg = useRef(undefined) const dismissed = useRef(false) const lastStatus = useRef>({ status: "idle" }) - const submit = useCallback( - async (arg: TArg) => { - if (address === undefined) return - if (submitting.current && inFlightArg.current === arg) { - dismissed.current = false - setStatus(lastStatus.current) - return - } - submitting.current = true - inFlightArg.current = arg - dismissed.current = false + const runSubmission = useCallback( + async (arg: TArg, owner: string) => { let hash: string | undefined let signatureRequested = false let reachedNetwork = false const applyStatus = (next: TransactionStatus) => { - if (inFlightArg.current !== arg) return lastStatus.current = next if (!dismissed.current) setStatus(next) } - const invalidateRequestData = (owner: string) => { + const invalidateRequestData = () => { void queryClient.invalidateQueries({ queryKey: investorRequestsKey(owner), }) @@ -77,7 +66,7 @@ export function useContractTransaction< } applyStatus({ status: "preparing" }) try { - const tx = await call({ publicKey: address, signTransaction }, arg) + const tx = await call({ publicKey: owner, signTransaction }, arg) const code = parseErrorCode(tx.simulation) if (code !== null) { applyStatus({ @@ -100,7 +89,7 @@ export function useContractTransaction< }) if (sent.getTransactionResponse?.status !== "SUCCESS") { applyStatus({ status: "failed", failure: { kind: "unknown" }, hash }) - invalidateRequestData(address) + invalidateRequestData() return } applyStatus({ @@ -108,7 +97,7 @@ export function useContractTransaction< ...toConfirmed(sent.result), hash, } as TransactionStatus) - invalidateRequestData(address) + invalidateRequestData() } catch (error) { applyStatus({ status: "failed", @@ -119,12 +108,28 @@ export function useContractTransaction< : { kind: "interrupted" }, hash, }) - if (reachedNetwork) invalidateRequestData(address) + if (reachedNetwork) invalidateRequestData() } finally { - if (inFlightArg.current === arg) submitting.current = false + submitting.current = false } }, - [address, signTransaction, call, toConfirmed, queryClient], + [signTransaction, call, toConfirmed, queryClient], + ) + + const submit = useCallback( + (arg: TArg): Promise => { + if (address === undefined) return Promise.resolve(false) + if (submitting.current) { + dismissed.current = false + setStatus(lastStatus.current) + return Promise.resolve(false) + } + submitting.current = true + dismissed.current = false + void runSubmission(arg, address) + return Promise.resolve(true) + }, + [address, runSubmission], ) const reset = useCallback(() => { diff --git a/app/src/hooks/useRequestDeposit.ts b/app/src/hooks/useRequestDeposit.ts index cc74139..8bdb750 100644 --- a/app/src/hooks/useRequestDeposit.ts +++ b/app/src/hooks/useRequestDeposit.ts @@ -10,7 +10,7 @@ export type RequestDepositStatus = TransactionStatus<{ epochId: bigint }> export interface UseRequestDeposit { status: RequestDepositStatus - submit: (amount: Amount) => Promise + submit: (amount: Amount) => Promise reset: () => void } diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 24d5051..984ed2b 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -821,6 +821,32 @@ describe("VaultPreview", () => { expect(screen.getByText(/150\.00 USDC/)).toBeTruthy() resolveSend() + await screen.findByRole("heading", { name: "Request cancelled" }) + }) + + it("never mounts more than one transaction dialog at once", async () => { + mockRequests.epochs.set(1n, { status: { tag: "Pending" }, share_price: 0n }) + mockRequests.deposits.set(1n, { amount: 150_0000000n, claimed: false }) + requestDepositMock.mockImplementationOnce(async () => ({ + simulation: undefined, + signAndSend: () => new Promise(() => {}), + })) + renderVaultPreview(connectedWallet) + + const input = await screen.findByRole("textbox", { + name: "Amount to subscribe", + }) + fireEvent.change(input, { target: { value: "150" } }) + fireEvent.click(await screen.findByRole("button", { name: "Subscribe" })) + + expect( + await screen.findByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + + fireEvent.click(await screen.findByRole("tab", { name: /^Waiting/ })) + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })) + + expect(screen.getAllByRole("dialog")).toHaveLength(1) }) it("removes a cancelled request from the list without a manual refresh", async () => { diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index 860516a..9cb587e 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -122,9 +122,11 @@ const VaultPreview: React.FC = () => { hasOpenSubscription: isSubscriptionOpen(requests), }) ?? toPriceBlock(nav, isPendingNav) const cancelDeposit = (request: InvestorRequest) => { - setPendingCancelEpochId(request.epochId) - setPendingCancelAmountLabel(formatScaled(request.amount, AMOUNT_DECIMALS)) - void submitCancelDeposit(request.epochId) + void submitCancelDeposit(request.epochId).then((started) => { + if (!started) return + setPendingCancelEpochId(request.epochId) + setPendingCancelAmountLabel(formatScaled(request.amount, AMOUNT_DECIMALS)) + }) } const retryCancelDeposit = () => { if (pendingCancelEpochId === null) return @@ -204,9 +206,11 @@ const VaultPreview: React.FC = () => { if (parsedAmount === null) return const amount = parseUnits(actionAmount, AMOUNT_DECIMALS) if (amount === null) return - setPendingAmountLabel(formatAmount(parsedAmount)) - setPendingAmount(amount) - void submitRequestDeposit(amount) + void submitRequestDeposit(amount).then((started) => { + if (!started) return + setPendingAmountLabel(formatAmount(parsedAmount)) + setPendingAmount(amount) + }) } const retryRequestDeposit = () => { @@ -319,7 +323,7 @@ const VaultPreview: React.FC = () => { - {requestDepositStatus.status !== "idle" && ( + {requestDepositStatus.status !== "idle" ? ( { onClose={resetRequestDeposit} onRetry={retryRequestDeposit} /> - )} - {cancelDepositStatus.status !== "idle" && ( - + ) : ( + cancelDepositStatus.status !== "idle" && ( + + ) )} )