From 625b9692ea4862e585f6b1048bf0bf4adf65feb0 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 11:26:03 -0300 Subject: [PATCH 1/2] feat(app): run the wind-down from the admin panel --- .../components/admin/CycleSection.test.tsx | 101 ++++++++++ app/src/components/admin/CycleSection.tsx | 26 +++ .../vault/TransactionModal.test.tsx | 187 ++++++++++++++++++ app/src/components/vault/TransactionModal.tsx | 130 +++++++++++- app/src/hooks/useActivateWindDown.test.ts | 160 +++++++++++++++ app/src/hooks/useActivateWindDown.ts | 30 +++ .../hooks/useFinalizeWindDownRound.test.ts | 166 ++++++++++++++++ app/src/hooks/useFinalizeWindDownRound.ts | 37 ++++ app/src/pages/cycle.test.ts | 176 ++++++++++++++++- app/src/pages/cycle.ts | 94 ++++++++- 10 files changed, 1102 insertions(+), 5 deletions(-) create mode 100644 app/src/hooks/useActivateWindDown.test.ts create mode 100644 app/src/hooks/useActivateWindDown.ts create mode 100644 app/src/hooks/useFinalizeWindDownRound.test.ts create mode 100644 app/src/hooks/useFinalizeWindDownRound.ts diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 8ca3e05..29dffe1 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -1,6 +1,7 @@ import { type Amount, type Price } from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" import { type Grant } from "../../hooks/useAdminAuthority" import { type AttestStatus } from "../../hooks/useAttest" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" @@ -8,6 +9,7 @@ import { type CycleState } from "../../hooks/useCycleState" import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" import { type DepositBalance } from "../../hooks/useDepositBalance" import { type EpochRecord } from "../../hooks/useEpochHistory" +import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" import { type FundStatus } from "../../hooks/useFund" import CycleSection from "./CycleSection" @@ -22,6 +24,8 @@ const { attestMock, deployMock, fundMock, + activateMock, + finalizeMock, } = vi.hoisted(() => ({ useCycleStateMock: vi.fn(), useCycleEventsMock: vi.fn(), @@ -52,6 +56,16 @@ const { submit: vi.fn(() => true), reset: vi.fn(), }, + activateMock: { + status: { status: "idle" } as ActivateWindDownStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, + finalizeMock: { + status: { status: "idle" } as FinalizeWindDownRoundStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, })) vi.mock("../../hooks/useCycleState", () => ({ @@ -76,6 +90,12 @@ vi.mock("../../hooks/useDeployToCustodian", () => ({ vi.mock("../../hooks/useFund", () => ({ useFund: () => fundMock, })) +vi.mock("../../hooks/useActivateWindDown", () => ({ + useActivateWindDown: () => activateMock, +})) +vi.mock("../../hooks/useFinalizeWindDownRound", () => ({ + useFinalizeWindDownRound: () => finalizeMock, +})) vi.mock("../../hooks/useDepositBalance", () => ({ useDepositBalance: useDepositBalanceMock, })) @@ -155,6 +175,8 @@ const ready = (overrides: Partial = {}): CycleState => ({ const WALLET = "GAUTHORITY1234567890" +const windDown = (ready() as ReadyState).windDown + const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ role, authority: WALLET, @@ -176,11 +198,15 @@ describe("CycleSection", () => { attestMock.status = { status: "idle" } deployMock.status = { status: "idle" } fundMock.status = { status: "idle" } + activateMock.status = { status: "idle" } + finalizeMock.status = { status: "idle" } closeEpochMock.submit.mockReturnValue(true) fulfillEpochMock.submit.mockReturnValue(true) attestMock.submit.mockReturnValue(true) deployMock.submit.mockReturnValue(true) fundMock.submit.mockReturnValue(true) + activateMock.submit.mockReturnValue(true) + finalizeMock.submit.mockReturnValue(true) useCycleStateMock.mockReturnValue({ cycle: ready() }) useDepositBalanceMock.mockReturnValue({ balance: { status: "held", amount: amount(1_000n) } as DepositBalance, @@ -650,4 +676,79 @@ describe("CycleSection", () => { expect(fulfillEpochMock.reset).toHaveBeenCalledTimes(1) }) + + it("offers no wind-down action while none is proposed", () => { + render() + + expect( + screen.queryByRole("button", { name: "Activate wind-down" }), + ).toBeNull() + expect(screen.queryByRole("button", { name: "Finalize round" })).toBeNull() + }) + + it("offers every cycle role the activation under the Wind-down group once proposed, and follows it in the modal", () => { + const proposed = ready({ + windDown: { ...windDown, phase: "proposed", activeAt: 1_700_000_000n }, + }) + useCycleStateMock.mockReturnValue({ cycle: proposed }) + const { rerender } = render( + , + ) + const windDownGroup = screen.getByRole("heading", { + name: "Wind-down", + }).parentElement! + const activate = within(windDownGroup).getByRole("button", { + name: "Activate wind-down", + }) as HTMLButtonElement + + expect(activate.disabled).toBe(false) + expect( + screen.getByText("Stops new requests and opens distribution rounds."), + ).toBeTruthy() + fireEvent.click(activate) + expect(activateMock.submit).toHaveBeenCalledTimes(1) + + activateMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { + name: "Preparing to activate the wind-down", + }), + ).toBeTruthy() + }) + + it("submits the round and keeps its number in the modal after the cycle moves on", () => { + const active = (round: number) => + ready({ + windDown: { + ...windDown, + phase: "active", + activeAt: 1_700_000_000n, + round, + }, + }) + useCycleStateMock.mockReturnValue({ cycle: active(2) }) + const { rerender } = render( + , + ) + + expect( + screen.getByText("Distributes 900.00 to holders as round 3."), + ).toBeTruthy() + fireEvent.click(button("Finalize round")) + expect(finalizeMock.submit).toHaveBeenCalledTimes(1) + + finalizeMock.status = { + status: "confirmed", + credited: amount(900n), + } + useCycleStateMock.mockReturnValue({ cycle: active(3) }) + rerender() + + expect( + screen.getByRole("heading", { name: "Round 3 finalized" }), + ).toBeTruthy() + expect(screen.getByText("900.00 is claimable by holders.")).toBeTruthy() + }) }) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index b98ddba..3bd0f5a 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react" +import { useActivateWindDown } from "../../hooks/useActivateWindDown" import { type Grant } from "../../hooks/useAdminAuthority" import { useAttest } from "../../hooks/useAttest" import { useCloseEpoch } from "../../hooks/useCloseEpoch" @@ -6,6 +7,7 @@ import { useCycleEvents, useCycleState } from "../../hooks/useCycleState" import { useDeployToCustodian } from "../../hooks/useDeployToCustodian" import { useDepositBalance } from "../../hooks/useDepositBalance" import { useEpochHistory } from "../../hooks/useEpochHistory" +import { useFinalizeWindDownRound } from "../../hooks/useFinalizeWindDownRound" import { useFulfillEpoch } from "../../hooks/useFulfillEpoch" import { useFund } from "../../hooks/useFund" import { usePendingTransaction } from "../../hooks/usePendingTransaction" @@ -45,6 +47,8 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ const attest = usePendingTransaction(useAttest()) const deploy = usePendingTransaction(useDeployToCustodian()) const fund = usePendingTransaction(useFund()) + const activateWindDown = usePendingTransaction(useActivateWindDown()) + const finalizeRound = usePendingTransaction(useFinalizeWindDownRound()) const [priceInput, setPriceInput] = useAmountInput( attest.status.status === "confirmed", ) @@ -79,6 +83,13 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ case "fund": if (action.amount !== null) fund.submit(action.amount, exactAmount(action.amount)) + return + case "activate-wind-down": + activateWindDown.submit(undefined, "") + return + case "finalize-round": + if (action.round !== null) + finalizeRound.submit(undefined, String(action.round)) } } @@ -95,6 +106,8 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ return case "close-epoch": case "fulfill-epoch": + case "activate-wind-down": + case "finalize-round": return } } @@ -155,6 +168,19 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ onClose: fund.reset, onRetry: fund.retry, }, + { + action: "activate-wind-down", + status: activateWindDown.status, + onClose: activateWindDown.reset, + onRetry: activateWindDown.retry, + }, + { + action: "finalize-round", + status: finalizeRound.status, + round: finalizeRound.amountLabel, + onClose: finalizeRound.reset, + onRetry: finalizeRound.retry, + }, ] const activeModal = modalFlows.find((flow) => flow.status.status !== "idle") diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 6e0c156..439d17a 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -3,6 +3,7 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" import { describe, expect, it, vi } from "vitest" +import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" import { type AttestStatus } from "../../hooks/useAttest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" @@ -10,6 +11,7 @@ import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" @@ -204,6 +206,35 @@ const renderFundModal = (status: FundStatus) => { return { ...view, onClose, onRetry } } +const renderActivateModal = (status: ActivateWindDownStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFinalizeModal = (status: FinalizeWindDownRoundStatus) => { + 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" }) @@ -1760,3 +1791,159 @@ describe("TransactionModal, funding the reserve", () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + +describe("TransactionModal, activating the wind-down", () => { + it("opens with a preparing state", () => { + renderActivateModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { + name: "Preparing to activate the wind-down", + }), + ).toBeTruthy() + }) + + it("tells the signer what the activation stops and opens", () => { + renderActivateModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing stops new requests and opens distribution rounds.", + ), + ).toBeTruthy() + }) + + it("tells the signer the activation is on its way", () => { + renderActivateModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the activation" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says requests are closed and rounds can distribute once confirmed", () => { + renderActivateModal({ status: "confirmed", hash: "b".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Wind-down active" }), + ).toBeTruthy() + expect( + screen.getByText( + "New requests are closed. Rounds can now distribute the free reserve.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6047, /No wind-down has been proposed/], + [6046, /already active/], + [6049, /delay has not elapsed/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a code it does not recognize, without the raw code", () => { + renderActivateModal({ + status: "failed", + failure: { kind: "contract-error", code: 6050 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6050/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderActivateModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) + +describe("TransactionModal, finalizing a wind-down round", () => { + it("opens with a preparing state naming the round", () => { + renderFinalizeModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing round 3" }), + ).toBeTruthy() + }) + + it("tells the signer what the round distributes", () => { + renderFinalizeModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing distributes the free reserve to holders as round 3.", + ), + ).toBeTruthy() + }) + + it("tells the signer the round is on its way", () => { + renderFinalizeModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the round" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("says what holders can claim once confirmed, to the last digit", () => { + renderFinalizeModal({ + status: "confirmed", + hash: "b".repeat(64), + credited: 9_000_000_001n as Amount, + }) + + expect( + screen.getByRole("heading", { name: "Round 3 finalized" }), + ).toBeTruthy() + expect( + screen.getByText("900.0000001 is claimable by holders."), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6050, /not active/], + [6052, /nothing to distribute/], + [6014, /too large/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFinalizeModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { name: "The vault refused this request" }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFinalizeModal({ status: "failed", failure: { kind: "unknown" } }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 15c78c8..f8867ad 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -7,6 +7,7 @@ import { shortAddress, } from "@stellar-scaffold/app-lib" import React from "react" +import { type ActivateWindDownStatus } from "../../hooks/useActivateWindDown" import { type AttestStatus } from "../../hooks/useAttest" import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" @@ -15,6 +16,7 @@ import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type TransactionFailure } from "../../hooks/useContractTransaction" import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" +import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" @@ -112,6 +114,19 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: "activate-wind-down" + status: ActivateWindDownStatus + onClose: () => void + onRetry: () => void + } + | { + action: "finalize-round" + status: FinalizeWindDownRoundStatus + round: string + onClose: () => void + onRetry: () => void + } const GENERIC_REFUSAL = "The vault refused this request. Try again, and contact support if it keeps happening." @@ -304,6 +319,32 @@ const fundContractErrorReason = (code: number): string => { } } +const activateWindDownContractErrorReason = (code: number): string => { + switch (code) { + case 6047: + return "No wind-down has been proposed, so there is nothing to activate." + case 6046: + return "The wind-down is already active." + case 6049: + return "The wind-down delay has not elapsed yet." + default: + return GENERIC_REFUSAL + } +} + +const finalizeRoundContractErrorReason = (code: number): string => { + switch (code) { + case 6050: + return "The wind-down is not active, so no round can be finalized." + case 6052: + return "There is nothing to distribute: the free reserve is empty or too small to split across holders." + case 6014: + return "The round is too large for the vault to record." + default: + return GENERIC_REFUSAL + } +} + const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" @@ -792,6 +833,87 @@ const describeFundStatus = ( } } +const describeActivateWindDownStatus = ( + status: ActivateWindDownStatus, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: "Preparing to activate the wind-down", + body: "We are getting the activation ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: "Signing stops new requests and opens distribution rounds.", + } + case "submitted": + return { + heading: "Sending the activation", + body: "The activation 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: "Wind-down active", + body: "New requests are closed. Rounds can now distribute the free reserve.", + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + activateWindDownContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFinalizeRoundStatus = ( + status: FinalizeWindDownRoundStatus, + round: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing round ${round}`, + body: "We are getting the round ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing distributes the free reserve to holders as round ${round}.`, + } + case "submitted": + return { + heading: "Sending the round", + body: "The round 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: `Round ${round} finalized`, + body: `${formatExact(status.credited, AMOUNT_DECIMALS)} is claimable by holders.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + finalizeRoundContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + const describeStatus = ( props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => { @@ -833,6 +955,10 @@ const describeStatus = ( return describeDeployStatus(props.status, props.amount) case "fund": return describeFundStatus(props.status, props.amount) + case "activate-wind-down": + return describeActivateWindDownStatus(props.status) + case "finalize-round": + return describeFinalizeRoundStatus(props.status, props.round) } } @@ -859,7 +985,9 @@ const computeSteps = ( | FulfillEpochStatus | AttestStatus | DeployToCustodianStatus - | FundStatus, + | FundStatus + | ActivateWindDownStatus + | FinalizeWindDownRoundStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/hooks/useActivateWindDown.test.ts b/app/src/hooks/useActivateWindDown.test.ts new file mode 100644 index 0000000..66a68e1 --- /dev/null +++ b/app/src/hooks/useActivateWindDown.test.ts @@ -0,0 +1,160 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, renderWithWallet } from "./testSupport" +import { useActivateWindDown } from "./useActivateWindDown" +import { cycleWriteKeys } from "./useCycleState" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { activate_wind_down: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const renderActivateWindDown = () => renderWithWallet(useActivateWindDown) + +const successful = () => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), +}) + +describe("useActivateWindDown", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed, 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: undefined, + } + }, + ) + vaultMock.activate_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderActivateWindDown() + + expect(result.current.submit()).toBe(true) + + 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" }), + ) + + expect(vaultMock.activate_wind_down).toHaveBeenCalledWith() + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.activate_wind_down.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6049)" }, + signAndSend, + }) + const { result } = renderActivateWindDown() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6049 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.activate_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderActivateWindDown() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + 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: undefined, + } + }) + vaultMock.activate_wind_down.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderActivateWindDown() + + void result.current.submit() + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit() + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(vaultMock.activate_wind_down).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle's history, reads, events and figures once the activation confirms", async () => { + vaultMock.activate_wind_down.mockResolvedValue(successful()) + const { result, queryClient } = renderActivateWindDown() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.activate_wind_down.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6047)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderActivateWindDown() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useActivateWindDown.ts b/app/src/hooks/useActivateWindDown.ts new file mode 100644 index 0000000..bf6e712 --- /dev/null +++ b/app/src/hooks/useActivateWindDown.ts @@ -0,0 +1,30 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { useCallback } from "react" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type TransactionStatus, +} from "./useContractTransaction" +import { cycleWriteKeys } from "./useCycleState" + +export type ActivateWindDownStatus = TransactionStatus + +export interface UseActivateWindDown { + status: ActivateWindDownStatus + submit: () => boolean + reset: () => void +} + +export function useActivateWindDown(): UseActivateWindDown { + const call = useCallback( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => vault.activate_wind_down()), + [], + ) + const toConfirmed = useCallback(() => ({}), []) + return useContractTransaction>( + call, + toConfirmed, + cycleWriteKeys, + ) +} diff --git a/app/src/hooks/useFinalizeWindDownRound.test.ts b/app/src/hooks/useFinalizeWindDownRound.test.ts new file mode 100644 index 0000000..c03052f --- /dev/null +++ b/app/src/hooks/useFinalizeWindDownRound.test.ts @@ -0,0 +1,166 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, renderWithWallet } from "./testSupport" +import { cycleWriteKeys } from "./useCycleState" +import { useFinalizeWindDownRound } from "./useFinalizeWindDownRound" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { finalize_wind_down_round: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const credited = 9_000_000_000n + +const renderFinalizeWindDownRound = () => + renderWithWallet(useFinalizeWindDownRound) + +const successful = (result: bigint) => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result, + }), +}) + +describe("useFinalizeWindDownRound", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed with the amount credited to holders, 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: credited, + } + }, + ) + vaultMock.finalize_wind_down_round.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderFinalizeWindDownRound() + + expect(result.current.submit()).toBe(true) + + 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", + credited, + }), + ) + + expect(vaultMock.finalize_wind_down_round).toHaveBeenCalledWith() + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.finalize_wind_down_round.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6052)" }, + signAndSend, + }) + const { result } = renderFinalizeWindDownRound() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6052 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.finalize_wind_down_round.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderFinalizeWindDownRound() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + 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: credited, + } + }) + vaultMock.finalize_wind_down_round.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderFinalizeWindDownRound() + + void result.current.submit() + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + void result.current.submit() + + gate.resolve() + await waitFor(() => expect(result.current.status.status).toBe("confirmed")) + + expect(vaultMock.finalize_wind_down_round).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle's history, reads, events and figures once the round confirms", async () => { + vaultMock.finalize_wind_down_round.mockResolvedValue(successful(credited)) + const { result, queryClient } = renderFinalizeWindDownRound() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.finalize_wind_down_round.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6050)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderFinalizeWindDownRound() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useFinalizeWindDownRound.ts b/app/src/hooks/useFinalizeWindDownRound.ts new file mode 100644 index 0000000..34223c7 --- /dev/null +++ b/app/src/hooks/useFinalizeWindDownRound.ts @@ -0,0 +1,37 @@ +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" +import { cycleWriteKeys } from "./useCycleState" + +export type FinalizeWindDownRoundStatus = TransactionStatus<{ + credited: Amount +}> + +export interface UseFinalizeWindDownRound { + status: FinalizeWindDownRoundStatus + submit: () => boolean + reset: () => void +} + +export function useFinalizeWindDownRound(): UseFinalizeWindDownRound { + const call = useCallback( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.finalize_wind_down_round(), + ), + [], + ) + const toConfirmed = useCallback( + (credited: bigint) => ({ credited: credited as Amount }), + [], + ) + return useContractTransaction( + call, + toConfirmed, + cycleWriteKeys, + ) +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index c1539c8..34eeea1 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -25,7 +25,9 @@ import { type CycleAction, type EpochAction, type TreasuryAction, + type WindDownAction, formatDuration, + formatTimestamp, toActivityList, toCycleActions, toCycleRows, @@ -617,7 +619,9 @@ type ActionOf = Id extends AttestAction["id"] ? AttestAction : Id extends TreasuryAction["id"] ? TreasuryAction - : EpochAction + : Id extends WindDownAction["id"] + ? WindDownAction + : EpochAction const actionOf = ( actions: CycleAction[], @@ -1499,3 +1503,173 @@ describe("toCycleActions, funding the reserve", () => { expect(fund?.enabled).toBe(true) }) }) + +const ACTIVE_AT = NOW - 60n +const proposed: CycleWindDown = { + ...windDown, + phase: "proposed", + activeAt: ACTIVE_AT, + round: 0, +} +const active: CycleWindDown = { ...proposed, phase: "active", round: 2 } + +const windDownActionsOf = ( + overrides: Partial, + grants: Grant[] = [manager], +) => + actionsFor(ready(overrides), grants).filter( + (action) => action.group === "Wind-down", + ) + +describe("toCycleActions, winding down", () => { + it("offers no wind-down action while none is proposed", () => { + expect(windDownActionsOf({})).toEqual([]) + }) + + it("offers no wind-down action while the wind-down could not be read", () => { + expect( + windDownActionsOf({ windDown: { ...proposed, phase: null } }), + ).toEqual([]) + }) + + it("offers the activation to every cycle role once a wind-down is proposed, never asking for signatures", () => { + for (const holder of [ + manager, + attester, + treasury, + grant("vault manager", { needs: 2 }), + ]) { + const actions = windDownActionsOf({ windDown: proposed }, [holder]) + expect(actions.map((action) => action.id)).toEqual(["activate-wind-down"]) + expect(actions[0]?.enabled).toBe(true) + expect(actions[0]?.unavailable).toBeUndefined() + } + }) + + it("enables the activation once the delay has elapsed, saying what it stops and opens", () => { + const activate = actionOf( + windDownActionsOf({ windDown: proposed }), + "activate-wind-down", + ) + + expect(activate?.label).toBe("Activate wind-down") + expect(conditionsOf(activate)).toEqual({ + "Wind-down proposed": true, + "Delay elapsed": true, + }) + expect(activate?.outcome).toBe( + "Stops new requests and opens distribution rounds.", + ) + expect(activate?.round).toBeNull() + expect(activate?.enabled).toBe(true) + }) + + it("lets the activation through at the very second the delay elapses", () => { + const activate = actionOf( + windDownActionsOf({ windDown: { ...proposed, activeAt: NOW } }), + "activate-wind-down", + ) + + expect(conditionsOf(activate)["Delay elapsed"]).toBe(true) + expect(activate?.enabled).toBe(true) + }) + + it("names when the wind-down activates while the delay runs", () => { + const activate = actionOf( + windDownActionsOf({ windDown: { ...proposed, activeAt: NOW + 1n } }), + "activate-wind-down", + ) + + expect(conditionOf(activate, "Delay elapsed")).toEqual({ + label: "Delay elapsed", + met: false, + detail: `Activates ${formatTimestamp(NOW + 1n)}`, + }) + expect(activate?.enabled).toBe(false) + }) + + it("cannot vouch for the delay when the ledger time could not be read", () => { + const activate = actionOf( + windDownActionsOf({ windDown: proposed, ledgerTime: null }), + "activate-wind-down", + ) + + expect(conditionsOf(activate)["Delay elapsed"]).toBe(false) + expect(activate?.enabled).toBe(false) + }) + + it("offers both actions once active, the activation already done", () => { + const actions = windDownActionsOf({ windDown: active }) + const activate = actionOf(actions, "activate-wind-down") + + expect(actions.map((action) => action.id)).toEqual([ + "activate-wind-down", + "finalize-round", + ]) + expect(conditionsOf(activate)["Wind-down proposed"]).toBe(false) + expect(activate?.enabled).toBe(false) + }) + + it("enables the round when the free reserve holds something, naming what it distributes and which round", () => { + const finalize = actionOf( + windDownActionsOf({ windDown: active }, [attester]), + "finalize-round", + ) + + expect(finalize?.label).toBe("Finalize round") + expect(conditionsOf(finalize)).toEqual({ + "Wind-down active": true, + "Free reserve to distribute": true, + }) + expect(finalize?.outcome).toBe("Distributes 900.00 to holders as round 3.") + expect(finalize?.round).toBe(3) + expect(finalize?.enabled).toBe(true) + expect(finalize?.unavailable).toBeUndefined() + }) + + it("shows the empty free reserve and disables the round when nothing is free", () => { + const finalize = actionOf( + windDownActionsOf({ + windDown: active, + reserve: { ...reserve, free: amount(0n) }, + }), + "finalize-round", + ) + + expect(conditionOf(finalize, "Free reserve to distribute")).toEqual({ + label: "Free reserve to distribute", + met: false, + detail: "Free 0.00", + }) + expect(finalize?.outcome).toBeNull() + expect(finalize?.enabled).toBe(false) + }) + + it("cannot vouch for the free reserve when it could not be read", () => { + const finalize = actionOf( + windDownActionsOf({ + windDown: active, + reserve: { ...reserve, free: null }, + }), + "finalize-round", + ) + + expect(conditionOf(finalize, "Free reserve to distribute")).toEqual({ + label: "Free reserve to distribute", + met: false, + }) + expect(finalize?.outcome).toBeNull() + expect(finalize?.enabled).toBe(false) + }) + + it("holds the round when the current round could not be read", () => { + const finalize = actionOf( + windDownActionsOf({ windDown: { ...active, round: null } }), + "finalize-round", + ) + + expect(finalize?.round).toBeNull() + expect(finalize?.outcome).toBeNull() + expect(finalize?.enabled).toBe(false) + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 495412b..454ba9e 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -332,9 +332,15 @@ function windDownGroup(windDown: CycleWindDown): CycleGroup { } export type CycleActionId = - "close-epoch" | "fulfill-epoch" | "attest" | "deploy" | "fund" + | "close-epoch" + | "fulfill-epoch" + | "attest" + | "deploy" + | "fund" + | "activate-wind-down" + | "finalize-round" -export type ActionGroup = "Epoch" | "Price" | "Reserve" +export type ActionGroup = "Epoch" | "Price" | "Reserve" | "Wind-down" export type Condition = { label: string; met: boolean | null; detail?: string } @@ -373,7 +379,13 @@ export type TreasuryAction = ActionBase & { amount: Amount | null } -export type CycleAction = EpochAction | AttestAction | TreasuryAction +export type WindDownAction = ActionBase & { + id: "activate-wind-down" | "finalize-round" + round: number | null +} + +export type CycleAction = + EpochAction | AttestAction | TreasuryAction | WindDownAction export type ActionInputs = { price?: string @@ -729,6 +741,81 @@ function toFundAction( } } +const toWindDownAction = ( + id: WindDownAction["id"], + label: string, + conditions: Condition[], + outcome: string | null, + round: number | null = null, +): WindDownAction => ({ + id, + group: "Wind-down", + label, + conditions, + outcome, + round, + enabled: allMet(conditions), +}) + +const delayElapsed = ( + activeAt: bigint | null, + ledgerTime: bigint | null, +): Condition => { + const label = "Delay elapsed" + if (activeAt === null || ledgerTime === null) return { label, met: false } + return ledgerTime >= activeAt + ? { label, met: true } + : { label, met: false, detail: `Activates ${formatTimestamp(activeAt)}` } +} + +function toActivateAction(state: ReadyState): WindDownAction { + const { phase, activeAt } = state.windDown + return toWindDownAction( + "activate-wind-down", + "Activate wind-down", + [ + { label: "Wind-down proposed", met: phase === "proposed" }, + delayElapsed(activeAt, state.ledgerTime), + ], + "Stops new requests and opens distribution rounds.", + ) +} + +const freeToDistribute = (free: Amount | null): Condition => { + const label = "Free reserve to distribute" + if (free === null) return { label, met: false } + return free > 0n + ? { label, met: true } + : { label, met: false, detail: `Free ${exactAmount(free)}` } +} + +function toFinalizeAction(state: ReadyState): WindDownAction { + const { phase, round } = state.windDown + const { free } = state.reserve + const next = round === null ? null : round + 1 + const action = toWindDownAction( + "finalize-round", + "Finalize round", + [ + { label: "Wind-down active", met: phase === "active" }, + freeToDistribute(free), + ], + free === null || free <= 0n || next === null + ? null + : `Distributes ${exactAmount(free)} to holders as round ${next}.`, + next, + ) + return next === null ? { ...action, enabled: false } : action +} + +function toWindDownActions(state: ReadyState): WindDownAction[] { + const { phase } = state.windDown + if (phase === null || phase === "none") return [] + const actions = [toActivateAction(state)] + if (phase === "active") actions.push(toFinalizeAction(state)) + return actions +} + export function toCycleActions( state: CycleState, grants: Grant[], @@ -748,6 +835,7 @@ export function toCycleActions( actions.push(toDeployAction(state, treasury, wallet, inputs.deploy ?? "")) actions.push( toFundAction(state, inputs.fund ?? "", inputs.walletBalance ?? null), + ...toWindDownActions(state), ) return actions } From 58ecec5addceac7cd523d0f0153dcdd3dd679098 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 11:31:48 -0300 Subject: [PATCH 2/2] fix(app): say why the round cannot be finalized --- app/src/pages/cycle.test.ts | 5 +++++ app/src/pages/cycle.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 34eeea1..a0f171f 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -1668,6 +1668,11 @@ describe("toCycleActions, winding down", () => { "finalize-round", ) + expect(conditionOf(finalize, "Wind-down active")).toEqual({ + label: "Wind-down active", + met: false, + detail: "Round unavailable", + }) expect(finalize?.round).toBeNull() expect(finalize?.outcome).toBeNull() expect(finalize?.enabled).toBe(false) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 454ba9e..641fecf 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -793,19 +793,19 @@ function toFinalizeAction(state: ReadyState): WindDownAction { const { phase, round } = state.windDown const { free } = state.reserve const next = round === null ? null : round + 1 - const action = toWindDownAction( + const windDownActive: Condition = + next === null + ? { label: "Wind-down active", met: false, detail: "Round unavailable" } + : { label: "Wind-down active", met: phase === "active" } + return toWindDownAction( "finalize-round", "Finalize round", - [ - { label: "Wind-down active", met: phase === "active" }, - freeToDistribute(free), - ], + [windDownActive, freeToDistribute(free)], free === null || free <= 0n || next === null ? null : `Distributes ${exactAmount(free)} to holders as round ${next}.`, next, ) - return next === null ? { ...action, enabled: false } : action } function toWindDownActions(state: ReadyState): WindDownAction[] {