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 64% rename from app/src/components/vault/SubscriptionModal.test.tsx rename to app/src/components/vault/TransactionModal.test.tsx index 91f00df..38dda5c 100644 --- a/app/src/components/vault/SubscriptionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,13 +1,17 @@ +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" 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), })) -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, @@ -17,7 +21,8 @@ const renderModal = (status: RequestDepositStatus) => { const onClose = vi.fn() const onRetry = vi.fn() const view = render( - { return { ...view, onClose, onRetry } } -describe("SubscriptionModal", () => { +const renderCancelModal = (status: CancelDepositStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +describe("TransactionModal", () => { it("renders nothing while idle", () => { renderModal({ status: "idle" }) @@ -308,3 +329,144 @@ describe("SubscriptionModal", () => { expect(onRetry).not.toHaveBeenCalled() }) }) + +describe("TransactionModal, 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("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", + 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, without sending the investor to claim yet", () => { + renderCancelModal({ + status: "failed", + failure: { kind: "contract-error", code: 6041 }, + }) + + 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", () => { + 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/TransactionModal.tsx similarity index 67% rename from app/src/components/vault/SubscriptionModal.tsx rename to app/src/components/vault/TransactionModal.tsx index 47d1bcf..51937af 100644 --- a/app/src/components/vault/SubscriptionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1,23 +1,37 @@ -import { explorerTransaction, shortAddress } from "@stellar-scaffold/app-lib" -import React from "react" import { - type RequestDepositFailure, - type RequestDepositStatus, -} from "../../hooks/useRequestDeposit" + 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" +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 = { - status: RequestDepositStatus - amount: string - ticker: string - onClose: () => void - onRetry: () => void -} +export type TransactionModalProps = + | { + 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 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}).` + } +} + const describeFailure = ( - failure: RequestDepositFailure, + failure: TransactionFailure, + 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: `${formatScaled(status.refundedAmount, AMOUNT_DECIMALS)} ${ticker} has been returned to your wallet.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure(status.failure, cancelContractErrorReason), + hash: status.hash, + } } } +const describeStatus = ( + props: TransactionModalProps, +): { 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 TransactionModal: 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) @@ -169,7 +242,7 @@ const SubscriptionModal: React.FC = ({ 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") { @@ -229,7 +302,7 @@ const SubscriptionModal: React.FC = ({ {steps !== undefined && ( -
    +
      {STEP_ORDER.map((id) => { const state = steps[id] const announcement = stepAnnouncement[state] @@ -272,4 +345,4 @@ const SubscriptionModal: React.FC = ({ ) } -export default SubscriptionModal +export default TransactionModal diff --git a/app/src/hooks/useCancelDeposit.test.ts b/app/src/hooks/useCancelDeposit.test.ts new file mode 100644 index 0000000..1463289 --- /dev/null +++ b/app/src/hooks/useCancelDeposit.test.ts @@ -0,0 +1,390 @@ +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 otherEpochId = 4n + +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("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 } }) => { + 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(result.current.status).toEqual({ status: "submitted" }), + ) + expect(vaultMock.cancel_deposit).toHaveBeenCalledTimes(1) + expect(vaultMock.cancel_deposit).toHaveBeenCalledWith({ + from: investorAddress, + epoch_id: epochId, + }) + + 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..d7ce9ae --- /dev/null +++ b/app/src/hooks/useCancelDeposit.ts @@ -0,0 +1,30 @@ +import { type Amount, type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" + +export type CancelDepositStatus = TransactionStatus<{ refundedAmount: Amount }> + +export interface UseCancelDeposit { + status: CancelDepositStatus + submit: (epochId: bigint) => Promise + reset: () => void +} + +export function useCancelDeposit(): UseCancelDeposit { + const call = useCallback( + (signer: Signer, epochId: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.cancel_deposit({ from: signer.publicKey, epoch_id: epochId }), + ), + [], + ) + 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..c882791 --- /dev/null +++ b/app/src/hooks/useContractTransaction.ts @@ -0,0 +1,141 @@ +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 runSubmission = useCallback( + async (arg: TArg, owner: string) => { + let hash: string | undefined + let signatureRequested = false + let reachedNetwork = false + const applyStatus = (next: TransactionStatus) => { + lastStatus.current = next + if (!dismissed.current) setStatus(next) + } + const invalidateRequestData = () => { + void queryClient.invalidateQueries({ + queryKey: investorRequestsKey(owner), + }) + void queryClient.invalidateQueries({ + queryKey: depositBalanceKey(owner), + }) + } + applyStatus({ status: "preparing" }) + try { + const tx = await call({ publicKey: owner, 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() + return + } + applyStatus({ + status: "confirmed", + ...toConfirmed(sent.result), + hash, + } as TransactionStatus) + invalidateRequestData() + } catch (error) { + applyStatus({ + status: "failed", + failure: isUserRejection(error) + ? { kind: "declined" } + : signatureRequested + ? { kind: "unknown" } + : { kind: "interrupted" }, + hash, + }) + if (reachedNetwork) invalidateRequestData() + } finally { + submitting.current = false + } + }, + [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(() => { + 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..8bdb750 100644 --- a/app/src/hooks/useRequestDeposit.ts +++ b/app/src/hooks/useRequestDeposit.ts @@ -1,124 +1,27 @@ -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 - submit: (amount: Amount) => Promise + submit: (amount: Amount) => Promise reset: () => void } 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.test.tsx b/app/src/pages/VaultPreview.test.tsx index eaa8b7d..984ed2b 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,115 @@ 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() + 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 () => { + 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..9cb587e 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, @@ -19,10 +20,14 @@ 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" -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,22 @@ const VaultPreview: React.FC = () => { pause, hasOpenSubscription: isSubscriptionOpen(requests), }) ?? toPriceBlock(nav, isPendingNav) + const cancelDeposit = (request: InvestorRequest) => { + void submitCancelDeposit(request.epochId).then((started) => { + if (!started) return + setPendingCancelEpochId(request.epochId) + setPendingCancelAmountLabel(formatScaled(request.amount, AMOUNT_DECIMALS)) + }) + } + 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[]] = [ { @@ -179,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 = () => { @@ -294,14 +323,26 @@ const VaultPreview: React.FC = () => { - {requestDepositStatus.status !== "idle" && ( - + ) : ( + 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) {