From 2011eb46fdd9b72891967ab88f6408d6cd202cfc Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 24 Sep 2026 17:02:50 -0300 Subject: [PATCH 1/2] feat(app): tell the investor why they cannot subscribe --- app-lib/contracts.ts | 1 + app/src/components/vault/ActionPanel.test.tsx | 46 +++- app/src/components/vault/ActionPanel.tsx | 142 ++++++++----- app/src/hooks/useVaultPaused.test.ts | 28 +++ app/src/hooks/useVaultPaused.ts | 25 +++ app/src/pages/VaultPreview.test.tsx | 63 +++++- app/src/pages/VaultPreview.tsx | 12 +- app/src/pages/vaultAccess.test.ts | 198 +++++++++++++++++- app/src/pages/vaultAccess.ts | 64 +++++- 9 files changed, 507 insertions(+), 72 deletions(-) create mode 100644 app/src/hooks/useVaultPaused.test.ts create mode 100644 app/src/hooks/useVaultPaused.ts diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index 07037fc..2b8e38c 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -32,6 +32,7 @@ export interface AsyncVaultApi { treasury: Call> guardian: Call> custodian: Call> + paused: Call current_epoch: Call get_epoch: CallOf<{ epoch_id: u64 }, Option> get_deposit_request: CallOf< diff --git a/app/src/components/vault/ActionPanel.test.tsx b/app/src/components/vault/ActionPanel.test.tsx index d787c56..492d394 100644 --- a/app/src/components/vault/ActionPanel.test.tsx +++ b/app/src/components/vault/ActionPanel.test.tsx @@ -119,7 +119,7 @@ describe("ActionPanel", () => { expect(onSubmit).not.toHaveBeenCalled() }) - it("hides the form and shows only the reason for a message block", () => { + it("hides the whole panel, tabs included, for a message block that covers both sides", () => { render( { block={{ kind: "message", reason: "This address is not on the vault's allowlist.", + sides: ["subscribe", "redeem"], }} />, ) @@ -139,6 +140,49 @@ describe("ActionPanel", () => { ).toBeTruthy() }) + it("keeps the tabs reachable for a message block scoped to the current side only", () => { + render( + , + ) + + expect(screen.getByRole("tablist")).toBeTruthy() + expect(screen.queryByRole("textbox")).toBeNull() + expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull() + expect( + screen.getByText("The vault is not accepting new requests right now."), + ).toBeTruthy() + }) + + it("leaves the other side's form untouched by a block scoped to the current side", () => { + render( + , + ) + + expect(screen.getByRole("tablist")).toBeTruthy() + expect(screen.getByRole("textbox")).toBeTruthy() + expect(screen.getByRole("button", { name: "Redeem" })).toBeTruthy() + expect( + screen.queryByText("The vault is not accepting new requests right now."), + ).toBeNull() + }) + it("hides MAX and cannot reach the over-balance state when balance is null", () => { render() diff --git a/app/src/components/vault/ActionPanel.tsx b/app/src/components/vault/ActionPanel.tsx index febb102..c160825 100644 --- a/app/src/components/vault/ActionPanel.tsx +++ b/app/src/components/vault/ActionPanel.tsx @@ -21,6 +21,7 @@ export type ActionBlock = { export type MessageBlock = { kind: "message" reason: string + sides: ActionPanelSide[] } export type ActionPanelBlock = ActionBlock | MessageBlock @@ -121,6 +122,13 @@ const ActionPanel: React.FC = ({ if (balance !== null) onAmountChange(formatAmount(balance)) } const actionBlock = block?.kind === "action" ? block : undefined + const messageBlock = block?.kind === "message" ? block : undefined + const blocksEverySide = + messageBlock !== undefined && + messageBlock.sides.includes("subscribe") && + messageBlock.sides.includes("redeem") + const blocksThisSide = + messageBlock !== undefined && messageBlock.sides.includes(side) return (
@@ -128,10 +136,17 @@ const ActionPanel: React.FC = ({ {heading} {note &&

{note}

} - {block?.kind === "message" ? ( -

{block.reason}

+ {blocksEverySide ? ( +

+ {messageBlock.reason} +

) : ( <> + {blocksThisSide && ( +

+ {messageBlock.reason} +

+ )} {actionBlock && (

{actionBlock.reason} @@ -163,66 +178,77 @@ const ActionPanel: React.FC = ({

-
-
- - onAmountChange(event.target.value)} - placeholder="0.00" - inputMode="decimal" - /> - {ticker} - {balance !== null && ( - + )} +
+
+ + {balanceLabel} + +
+
+ +
+ + {estimate.label} + + - MAX - - )} -
-
- - {balanceLabel} - -
- + {estimate.status === "ready" + ? estimate.value + : estimate.reason} + + -
- - {estimate.label} - - - {estimate.status === "ready" ? estimate.value : estimate.reason} - -
+ {overBalance !== undefined && ( +

+ {`You have ${overBalance} ${ticker}. Enter ${overBalance} or less.`} +

+ )} - {overBalance !== undefined && ( -

- {`You have ${overBalance} ${ticker}. Enter ${overBalance} or less.`} -

+ + )} - - )} diff --git a/app/src/hooks/useVaultPaused.test.ts b/app/src/hooks/useVaultPaused.test.ts new file mode 100644 index 0000000..f45ab00 --- /dev/null +++ b/app/src/hooks/useVaultPaused.test.ts @@ -0,0 +1,28 @@ +import { type ContractRead } from "@stellar-scaffold/app-lib" +import { describe, expect, it, vi } from "vitest" +import { classifyPause } from "./useVaultPaused" + +vi.mock("../config/clients", () => ({ + asyncVault: async () => ({ + paused: async () => ({}), + }), +})) + +const paused: ContractRead = { kind: "value", value: true } +const open: ContractRead = { kind: "value", value: false } +const contractError: ContractRead = { + kind: "contract-error", + code: 1, +} +const unreadable: ContractRead = { kind: "unreadable" } + +describe("classifyPause", () => { + it.each<[string, ContractRead, string]>([ + ["a value of true", paused, "paused"], + ["a value of false", open, "open"], + ["a contract error, never as open", contractError, "unreadable"], + ["an unreadable read", unreadable, "unreadable"], + ])("classifies %s as %s", (_name, read, expected) => { + expect(classifyPause(read)).toBe(expected) + }) +}) diff --git a/app/src/hooks/useVaultPaused.ts b/app/src/hooks/useVaultPaused.ts new file mode 100644 index 0000000..0a116f6 --- /dev/null +++ b/app/src/hooks/useVaultPaused.ts @@ -0,0 +1,25 @@ +import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { asyncVault } from "../config/clients" + +export type PauseState = "checking" | "open" | "paused" | "unreadable" + +export function classifyPause(read: ContractRead): PauseState { + if (read.kind !== "value") return "unreadable" + return read.value ? "paused" : "open" +} + +async function fetchVaultPause(): Promise { + const read = await readContract(async () => (await asyncVault()).paused()) + return classifyPause(read) +} + +export function useVaultPaused(): { pause: PauseState } { + const { data } = useQuery({ + queryKey: ["vault", "paused"], + queryFn: fetchVaultPause, + staleTime: 30_000, + }) + + return { pause: data ?? "checking" } +} diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 66a5477..3a8e710 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -25,12 +25,14 @@ const { mockDeposit, mockSymbols, mockRequests, + mockVault, requestDepositMock, } = vi.hoisted(() => ({ mockVaultId: "CMOCKVAULTADDRESS1234567890", mockGovernanceAddress: "GGOVERNANCEADDRESS1234567890", mockShares: { balance: 500_0000000n }, mockDeposit: { balance: 3200_0000000n }, + mockVault: { paused: false }, mockSymbols: { token: "USDC", shareToken: "vUSDC", @@ -91,12 +93,14 @@ const resetMockRequests = () => { beforeEach(() => { mockShares.balance = 500_0000000n mockDeposit.balance = 3200_0000000n + mockVault.paused = false resetMockRequests() }) afterEach(() => { mockShares.balance = 500_0000000n mockDeposit.balance = 3200_0000000n + mockVault.paused = false resetMockRequests() }) @@ -135,6 +139,7 @@ vi.mock("../config/clients", () => { get_redeem_request: async ({ epoch_id }: { epoch_id: bigint }) => ({ result: mockRequests.redeems.get(epoch_id), }), + paused: async () => ({ result: mockVault.paused }), request_deposit: requestDepositMock, } const oracle = { @@ -341,6 +346,48 @@ describe("VaultPreview", () => { ).toBeTruthy() }) + it("blocks subscribing before signing while the vault is paused", async () => { + mockVault.paused = true + renderVaultPreview(connectedWallet) + + expect( + await screen.findByText( + "The vault is not accepting new requests right now.", + ), + ).toBeTruthy() + expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull() + }) + + it("reaches the Redeem tab while the vault is paused", async () => { + mockVault.paused = true + renderVaultPreview(connectedWallet) + + await screen.findByText( + "The vault is not accepting new requests right now.", + ) + fireEvent.click(await screen.findByRole("tab", { name: "Redeem" })) + + expect(await screen.findByRole("button", { name: "Redeem" })).toBeTruthy() + expect( + screen.queryByText("The vault is not accepting new requests right now."), + ).toBeNull() + }) + + it("blocks subscribing before signing with a subscription already open in the current batch", async () => { + mockRequests.deposits.set(mockRequests.currentEpoch, { + amount: 100_0000000n, + claimed: false, + }) + renderVaultPreview(connectedWallet) + + expect( + await screen.findByText( + "You already have a subscription request open in this batch.", + ), + ).toBeTruthy() + expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull() + }) + it("reads and renders the connected address's share balance, using the share token's own reported symbol", async () => { renderVaultPreview(connectedWallet) @@ -610,11 +657,11 @@ describe("VaultPreview", () => { }) }) - it("clears the amount field once the subscription confirms, so a second press cannot duplicate it", async () => { + it("blocks a second subscription once the first confirms, so a second press cannot duplicate it", async () => { renderVaultPreview(connectedWallet) - const input = (await screen.findByRole("textbox", { + const input = await screen.findByRole("textbox", { name: "Amount to subscribe", - })) as HTMLInputElement + }) fireEvent.change(input, { target: { value: "150" } }) fireEvent.click(await screen.findByRole("button", { name: "Subscribe" })) @@ -622,7 +669,15 @@ describe("VaultPreview", () => { expect( await screen.findByRole("heading", { name: "Request locked in" }), ).toBeTruthy() - expect(input.value).toBe("") + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + + expect( + await screen.findByText( + "You already have a subscription request open in this batch.", + ), + ).toBeTruthy() + expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull() }) it("reads a declined signature as a choice and offers to try again", async () => { diff --git a/app/src/pages/VaultPreview.tsx b/app/src/pages/VaultPreview.tsx index 1bc9cd0..2cd8aac 100644 --- a/app/src/pages/VaultPreview.tsx +++ b/app/src/pages/VaultPreview.tsx @@ -30,11 +30,13 @@ import { useSharePosition } from "../hooks/useSharePosition" import { useTokenSymbols } from "../hooks/useTokenSymbols" import { useVaultAuthorities } from "../hooks/useVaultAuthorities" import { useVaultFigures } from "../hooks/useVaultFigures" +import { useVaultPaused } from "../hooks/useVaultPaused" import { useWallet } from "../hooks/useWallet" import typeStyles from "../styles/type.module.css" import { deriveAccess, emptyMessages, + isSubscriptionOpen, toActionBalance, toPanelBlock, toPosition, @@ -80,6 +82,7 @@ const VaultPreview: React.FC = () => { const { balance: deposit } = useDepositBalance() const { symbols } = useTokenSymbols() const { requests } = useInvestorRequests() + const { pause } = useVaultPaused() const { status: requestDepositStatus, submit: submitRequestDeposit, @@ -96,9 +99,13 @@ const VaultPreview: React.FC = () => { network: { state, appNetwork, walletNetwork }, allowance, }) + const isSubscribe = actionSide === "subscribe" const block = - toPanelBlock(access, connectWallet, profileModal) ?? - toPriceBlock(nav, isPendingNav) + toPanelBlock(access, connectWallet, profileModal, { + isSubscribe, + pause, + hasOpenSubscription: isSubscriptionOpen(requests), + }) ?? toPriceBlock(nav, isPendingNav) const messages = emptyMessages(requests.status) const entriesByStage = requests.status === "loaded" @@ -149,7 +156,6 @@ const VaultPreview: React.FC = () => { setOpenTooltipId(null) } - const isSubscribe = actionSide === "subscribe" const inTicker = isSubscribe ? symbols.token : symbols.shareToken const outTicker = isSubscribe ? symbols.shareToken : symbols.token const balance = toActionBalance( diff --git a/app/src/pages/vaultAccess.test.ts b/app/src/pages/vaultAccess.test.ts index 73a071d..df7f857 100644 --- a/app/src/pages/vaultAccess.test.ts +++ b/app/src/pages/vaultAccess.test.ts @@ -2,20 +2,24 @@ import { AMOUNT_DECIMALS, type Amount, type NetworkState, + type Price, } from "@stellar-scaffold/app-lib" import { describe, expect, it, vi } from "vitest" import { type DepositBalance } from "../hooks/useDepositBalance" +import { type InvestorRequestsRead } from "../hooks/useInvestorRequests" import { type Allowance } from "../hooks/useIsAllowed" import { type NavClassification } from "../hooks/useNavPrice" import { type SharePosition } from "../hooks/useSharePosition" import { deriveAccess, emptyMessages, + isSubscriptionOpen, toActionBalance, toPanelBlock, toPosition, toPriceBlock, type InvestorAccess, + type SubscribeGate, } from "./vaultAccess" const address = "GINVESTORADDRESS1234567890" @@ -89,12 +93,18 @@ describe("deriveAccess", () => { describe("toPanelBlock", () => { const onConnect = vi.fn() const onOpenWallet = vi.fn() + const openGate: SubscribeGate = { + isSubscribe: true, + pause: "open", + hasOpenSubscription: false, + } it("offers a connect action while disconnected", () => { const block = toPanelBlock( { status: "disconnected" }, onConnect, onOpenWallet, + openGate, ) expect(block?.kind).toBe("action") @@ -113,6 +123,7 @@ describe("toPanelBlock", () => { }, onConnect, onOpenWallet, + openGate, ) expect(block?.kind).toBe("action") @@ -124,10 +135,16 @@ describe("toPanelBlock", () => { }) it("shows a checking message distinguishable from a failure or a denial", () => { - const block = toPanelBlock({ status: "checking" }, onConnect, onOpenWallet) + const block = toPanelBlock( + { status: "checking" }, + onConnect, + onOpenWallet, + openGate, + ) expect(block).toEqual({ kind: "message", reason: "Checking whether this address may subscribe.", + sides: ["subscribe", "redeem"], }) }) @@ -136,6 +153,7 @@ describe("toPanelBlock", () => { { status: "unreadable" }, onConnect, onOpenWallet, + openGate, ) expect(block?.kind).toBe("message") expect(block?.reason).not.toMatch(/not allowed/i) @@ -146,16 +164,184 @@ describe("toPanelBlock", () => { { status: "not-allowed" }, onConnect, onOpenWallet, + openGate, ) expect(block?.kind).toBe("message") expect(block?.reason).toMatch(/allowlist/i) }) - it("returns no block once allowed", () => { + it("returns no block once allowed, unpaused, and with no open subscription", () => { expect( - toPanelBlock({ status: "allowed" }, onConnect, onOpenWallet), + toPanelBlock({ status: "allowed" }, onConnect, onOpenWallet, openGate), ).toBeUndefined() }) + + it("states a genuine not-allowed before either subscribe gate reason", () => { + const block = toPanelBlock( + { status: "not-allowed" }, + onConnect, + onOpenWallet, + { isSubscribe: true, pause: "paused", hasOpenSubscription: true }, + ) + expect(block?.reason).toMatch(/allowlist/i) + }) + + describe("subscribe gate", () => { + it("blocks with a checking message while the pause read is in flight", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { ...openGate, pause: "checking" }, + ) + expect(block).toEqual({ + kind: "message", + reason: "Checking whether the vault is accepting requests.", + sides: ["subscribe"], + }) + }) + + it("blocks with a distinct message when the pause read fails", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { ...openGate, pause: "unreadable" }, + ) + expect(block).toEqual({ + kind: "message", + reason: + "Could not check whether the vault is accepting requests. Try again shortly.", + sides: ["subscribe"], + }) + }) + + it("blocks while the vault is paused", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { ...openGate, pause: "paused" }, + ) + expect(block).toEqual({ + kind: "message", + reason: "The vault is not accepting new requests right now.", + sides: ["subscribe"], + }) + }) + + it("blocks while a subscription is already open in the current batch", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { ...openGate, hasOpenSubscription: true }, + ) + expect(block).toEqual({ + kind: "message", + reason: "You already have a subscription request open in this batch.", + sides: ["subscribe"], + }) + }) + + it("scopes the pause block to the subscribe side only, so the redeem side stays reachable", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { ...openGate, pause: "paused" }, + ) + expect(block?.kind).toBe("message") + if (block?.kind === "message") { + expect(block.sides).toEqual(["subscribe"]) + } + }) + + it("states the pause reason before the open-subscription reason when both apply", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { isSubscribe: true, pause: "paused", hasOpenSubscription: true }, + ) + expect(block?.reason).toMatch(/accepting new requests/i) + }) + + it("never gates the redeem side on pause or an open subscription", () => { + const block = toPanelBlock( + { status: "allowed" }, + onConnect, + onOpenWallet, + { isSubscribe: false, pause: "paused", hasOpenSubscription: true }, + ) + expect(block).toBeUndefined() + }) + }) +}) + +describe("isSubscriptionOpen", () => { + const checking: InvestorRequestsRead = { status: "checking" } + + it("is false before the investor's requests resolve", () => { + expect(isSubscriptionOpen(checking)).toBe(false) + }) + + it("is true when a deposit request is open in the current batch", () => { + const requests: InvestorRequestsRead = { + status: "loaded", + requests: [ + { + epochId: 2n, + side: "deposit", + epochStatus: { tag: "Open", values: undefined }, + sharePrice: 0n as Price, + amount: 100_0000000n as Amount, + claimed: false, + }, + ], + archived: [], + unreadable: [], + } + expect(isSubscriptionOpen(requests)).toBe(true) + }) + + it("is false when only a redemption is open in the current batch", () => { + const requests: InvestorRequestsRead = { + status: "loaded", + requests: [ + { + epochId: 2n, + side: "redeem", + epochStatus: { tag: "Open", values: undefined }, + sharePrice: 0n as Price, + amount: 500_0000000n as Amount, + claimed: false, + }, + ], + archived: [], + unreadable: [], + } + expect(isSubscriptionOpen(requests)).toBe(false) + }) + + it("is false for a deposit request left over from a closed batch", () => { + const requests: InvestorRequestsRead = { + status: "loaded", + requests: [ + { + epochId: 1n, + side: "deposit", + epochStatus: { tag: "Pending", values: undefined }, + sharePrice: 0n as Price, + amount: 100_0000000n as Amount, + claimed: false, + }, + ], + archived: [], + unreadable: [], + } + expect(isSubscriptionOpen(requests)).toBe(false) + }) }) describe("toPriceBlock", () => { @@ -163,13 +349,17 @@ describe("toPriceBlock", () => { expect(toPriceBlock(undefined, true)).toEqual({ kind: "message", reason: "Reading the vault's price.", + sides: ["subscribe", "redeem"], }) }) - it("blocks on any non-valid price once resolved", () => { + it("blocks on any non-valid price once resolved, on both sides", () => { const notValid: NavClassification = { status: "paused" } const block = toPriceBlock(notValid, false) expect(block?.kind).toBe("message") + if (block?.kind === "message") { + expect(block.sides).toEqual(["subscribe", "redeem"]) + } }) it("returns no block once the price is valid", () => { diff --git a/app/src/pages/vaultAccess.ts b/app/src/pages/vaultAccess.ts index d9ed10e..7c03520 100644 --- a/app/src/pages/vaultAccess.ts +++ b/app/src/pages/vaultAccess.ts @@ -10,6 +10,7 @@ import { type InvestorRequestsRead } from "../hooks/useInvestorRequests" import { type Allowance } from "../hooks/useIsAllowed" import { type NavClassification } from "../hooks/useNavPrice" import { type SharePosition } from "../hooks/useSharePosition" +import { type PauseState } from "../hooks/useVaultPaused" export type InvestorAccess = | { status: "disconnected" } @@ -40,10 +41,53 @@ export function deriveAccess(input: { return { status: "allowed" } } +export type SubscribeGate = { + isSubscribe: boolean + pause: PauseState + hasOpenSubscription: boolean +} + +function toSubscribeGateBlock( + gate: SubscribeGate, +): ActionPanelBlock | undefined { + if (!gate.isSubscribe) return undefined + if (gate.pause === "checking") { + return { + kind: "message", + reason: "Checking whether the vault is accepting requests.", + sides: ["subscribe"], + } + } + if (gate.pause === "unreadable") { + return { + kind: "message", + reason: + "Could not check whether the vault is accepting requests. Try again shortly.", + sides: ["subscribe"], + } + } + if (gate.pause === "paused") { + return { + kind: "message", + reason: "The vault is not accepting new requests right now.", + sides: ["subscribe"], + } + } + if (gate.hasOpenSubscription) { + return { + kind: "message", + reason: "You already have a subscription request open in this batch.", + sides: ["subscribe"], + } + } + return undefined +} + export function toPanelBlock( access: InvestorAccess, onConnect: () => void, onOpenWallet: () => void, + subscribeGate: SubscribeGate, ): ActionPanelBlock | undefined { switch (access.status) { case "disconnected": @@ -64,36 +108,52 @@ export function toPanelBlock( return { kind: "message", reason: "Checking whether this address may subscribe.", + sides: ["subscribe", "redeem"], } case "not-allowed": return { kind: "message", reason: "This address is not on the vault's allowlist. The vault's operator grants access.", + sides: ["subscribe", "redeem"], } case "unreadable": return { kind: "message", reason: "Could not check whether this address may subscribe. Try again shortly.", + sides: ["subscribe", "redeem"], } case "allowed": - return undefined + return toSubscribeGateBlock(subscribeGate) } } +export function isSubscriptionOpen(requests: InvestorRequestsRead): boolean { + if (requests.status !== "loaded") return false + return requests.requests.some( + (request) => + request.side === "deposit" && request.epochStatus.tag === "Open", + ) +} + export function toPriceBlock( nav: NavClassification | undefined, isPending: boolean, ): ActionPanelBlock | undefined { if (isPending) { - return { kind: "message", reason: "Reading the vault's price." } + return { + kind: "message", + reason: "Reading the vault's price.", + sides: ["subscribe", "redeem"], + } } if (nav?.status === "valid") return undefined return { kind: "message", reason: "The vault's price is not valid right now, so subscribing and redeeming are closed.", + sides: ["subscribe", "redeem"], } } From 72f9968789ae7aea48da65da685cfbbbb42c2239 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 24 Sep 2026 22:30:24 -0300 Subject: [PATCH 2/2] test(app): prove the amount field clears when the request confirms --- app/src/pages/VaultPreview.test.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/pages/VaultPreview.test.tsx b/app/src/pages/VaultPreview.test.tsx index 3a8e710..eaa8b7d 100644 --- a/app/src/pages/VaultPreview.test.tsx +++ b/app/src/pages/VaultPreview.test.tsx @@ -680,6 +680,28 @@ describe("VaultPreview", () => { expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull() }) + it("clears the amount field once the subscription confirms, so a second press cannot duplicate it", async () => { + requestDepositMock.mockImplementationOnce(async () => ({ + simulation: undefined, + signAndSend: async () => ({ + getTransactionResponse: { status: "SUCCESS" }, + result: mockRequests.currentEpoch, + }), + })) + renderVaultPreview(connectedWallet) + const input = (await screen.findByRole("textbox", { + name: "Amount to subscribe", + })) as HTMLInputElement + fireEvent.change(input, { target: { value: "150" } }) + + fireEvent.click(await screen.findByRole("button", { name: "Subscribe" })) + + expect( + await screen.findByRole("heading", { name: "Request locked in" }), + ).toBeTruthy() + await waitFor(() => expect(input.value).toBe("")) + }) + it("reads a declined signature as a choice and offers to try again", async () => { requestDepositMock.mockImplementationOnce(async () => ({ simulation: undefined,