From c5c238313a7d42efcb33bae24bcf710a979a84f0 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 8 Oct 2026 21:24:29 -0300 Subject: [PATCH 1/3] feat(app): close and fulfill epochs from the admin panel --- .../components/admin/CycleSection.test.tsx | 239 ++++++++++++++++ app/src/components/admin/CycleSection.tsx | 69 ++++- .../components/admin/CycleSurface.module.css | 76 ++++++ .../components/admin/CycleSurface.test.tsx | 120 ++++++++- app/src/components/admin/CycleSurface.tsx | 56 ++++ .../vault/TransactionModal.test.tsx | 239 +++++++++++++++- app/src/components/vault/TransactionModal.tsx | 156 ++++++++++- app/src/hooks/useCloseEpoch.test.ts | 163 +++++++++++ app/src/hooks/useCloseEpoch.ts | 35 +++ app/src/hooks/useContractTransaction.test.ts | 72 +++++ app/src/hooks/useContractTransaction.ts | 8 +- app/src/hooks/useCycleState.ts | 14 +- app/src/hooks/useFulfillEpoch.test.ts | 168 ++++++++++++ app/src/hooks/useFulfillEpoch.ts | 31 +++ app/src/pages/AdminPanel.test.tsx | 28 +- app/src/pages/AdminPanel.tsx | 4 +- app/src/pages/cycle.test.ts | 255 +++++++++++++++++- app/src/pages/cycle.ts | 111 ++++++++ 18 files changed, 1822 insertions(+), 22 deletions(-) create mode 100644 app/src/components/admin/CycleSection.test.tsx create mode 100644 app/src/hooks/useCloseEpoch.test.ts create mode 100644 app/src/hooks/useCloseEpoch.ts create mode 100644 app/src/hooks/useContractTransaction.test.ts create mode 100644 app/src/hooks/useFulfillEpoch.test.ts create mode 100644 app/src/hooks/useFulfillEpoch.ts diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx new file mode 100644 index 00000000..efa824ac --- /dev/null +++ b/app/src/components/admin/CycleSection.test.tsx @@ -0,0 +1,239 @@ +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 Grant } from "../../hooks/useAdminAuthority" +import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" +import { type CycleState } from "../../hooks/useCycleState" +import { type EpochRecord } from "../../hooks/useEpochHistory" +import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" +import CycleSection from "./CycleSection" + +const { + useCycleStateMock, + useCycleEventsMock, + useEpochHistoryMock, + closeEpochMock, + fulfillEpochMock, +} = vi.hoisted(() => ({ + useCycleStateMock: vi.fn(), + useCycleEventsMock: vi.fn(), + useEpochHistoryMock: vi.fn(), + closeEpochMock: { + status: { status: "idle" } as CloseEpochStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, + fulfillEpochMock: { + status: { status: "idle" } as FulfillEpochStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }, +})) + +vi.mock("../../hooks/useCycleState", () => ({ + useCycleState: useCycleStateMock, + useCycleEvents: useCycleEventsMock, +})) +vi.mock("../../hooks/useEpochHistory", () => ({ + useEpochHistory: useEpochHistoryMock, +})) +vi.mock("../../hooks/useCloseEpoch", () => ({ + useCloseEpoch: () => closeEpochMock, +})) +vi.mock("../../hooks/useFulfillEpoch", () => ({ + useFulfillEpoch: () => fulfillEpochMock, +})) + +const amount = (whole: bigint) => (whole * 10_000_000n) as Amount +const price = (whole: bigint) => (whole * 10n ** 18n) as Price + +const sealed: EpochRecord = { + id: 4n, + status: "Pending", + totalDeposited: amount(300n), + totalSharesRedeeming: amount(20n), + sharePrice: null, + closedAt: 1_700_000_000n, + priceableAt: 1_700_003_600n, +} + +const open: EpochRecord = { + id: 5n, + status: "Open", + totalDeposited: amount(40n), + totalSharesRedeeming: amount(0n), + sharePrice: null, + closedAt: 0n, + priceableAt: 0n, +} + +type ReadyState = Extract + +const ready = (overrides: Partial = {}): CycleState => ({ + status: "ready", + ledgerTime: 1_700_100_000n, + epoch: { + id: 5n, + open, + awaiting: sealed, + awaitingKnown: true, + noticeSeconds: 3_600n, + }, + oracle: { + state: "valid", + price: price(1n), + attestedAt: 1_700_050_000n, + expiresAt: 1_700_136_000n, + ripcord: false, + limits: null, + }, + reserve: { + free: null, + committed: null, + uncovered: null, + liquid: null, + netDeployed: null, + depositCap: null, + custodian: null, + custodianBalance: null, + }, + windDown: { + phase: "none", + activeAt: null, + round: null, + delay: null, + owed: null, + supply: null, + }, + paused: "open", + ...overrides, +}) + +const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ + role, + authority: "GAUTHORITY1234567890", + standing, +}) + +const manager = grant("vault manager") +const treasury = grant("vault treasury") + +const button = (name: string) => + screen.getByRole("button", { name }) as HTMLButtonElement + +describe("CycleSection", () => { + beforeEach(() => { + vi.clearAllMocks() + closeEpochMock.status = { status: "idle" } + fulfillEpochMock.status = { status: "idle" } + closeEpochMock.submit.mockReturnValue(true) + fulfillEpochMock.submit.mockReturnValue(true) + useCycleStateMock.mockReturnValue({ cycle: ready() }) + useCycleEventsMock.mockReturnValue({ + cycleEvents: { status: "loaded", events: [] }, + }) + useEpochHistoryMock.mockReturnValue({ + history: { + status: "loaded", + currentEpoch: 5n, + epochs: [open, sealed], + absent: [], + unreadable: [], + }, + }) + }) + + it("offers the manager the close and the settlement, with their conditions and resulting state", () => { + render() + + expect(button("Close epoch").disabled).toBe(false) + expect(button("Fulfill epoch").disabled).toBe(false) + const closeConditions = screen.getByRole("list", { + name: "Close epoch conditions", + }) + expect(within(closeConditions).getByText("Epoch open")).toBeTruthy() + expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() + expect( + screen.getByText("Prices epoch 4 at 1.0000 and settles its requests."), + ).toBeTruthy() + }) + + it("offers the treasury only the settlement", () => { + render() + + expect(screen.queryByRole("button", { name: "Close epoch" })).toBeNull() + expect(button("Fulfill epoch").disabled).toBe(false) + }) + + it("disables the settlement and marks the failing condition while the notice runs", () => { + useCycleStateMock.mockReturnValue({ + cycle: ready({ ledgerTime: sealed.priceableAt - 1n }), + }) + render() + + expect(button("Fulfill epoch").disabled).toBe(true) + const notice = screen.getByText("Notice elapsed").closest("li")! + expect(within(notice).getByText("Not met")).toBeTruthy() + }) + + it("tells a manager signing through a multisig that collecting signatures is not supported yet", () => { + render() + + expect(button("Close epoch").disabled).toBe(true) + expect( + screen.getByText( + "Needs 2 signatures; collecting them is not supported yet.", + ), + ).toBeTruthy() + expect(button("Fulfill epoch").disabled).toBe(false) + }) + + it("shows no actions while the cycle is still being read", () => { + useCycleStateMock.mockReturnValue({ cycle: { status: "checking" } }) + render() + + expect(screen.queryByRole("button")).toBeNull() + }) + + it("submits the close and follows it in the modal, labelled with the epoch it seals", () => { + const { rerender } = render() + + fireEvent.click(button("Close epoch")) + expect(closeEpochMock.submit).toHaveBeenCalledTimes(1) + + closeEpochMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to close epoch 5" }), + ).toBeTruthy() + }) + + it("submits the settlement for the oldest sealed epoch and follows it in the modal", () => { + const { rerender } = render() + + fireEvent.click(button("Fulfill epoch")) + expect(fulfillEpochMock.submit).toHaveBeenCalledWith(4n) + + fulfillEpochMock.status = { status: "awaiting-signature" } + rerender() + + expect( + screen.getByText( + "Signing prices epoch 4 at the oracle's current price and settles its requests.", + ), + ).toBeTruthy() + }) + + it("closes the modal through the hook's reset", () => { + fulfillEpochMock.status = { + status: "failed", + failure: { kind: "declined" }, + } + render() + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + + expect(fulfillEpochMock.reset).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 0f6a2bc7..8c309070 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -1,21 +1,72 @@ import React from "react" +import { type Grant } from "../../hooks/useAdminAuthority" +import { useCloseEpoch } from "../../hooks/useCloseEpoch" import { useCycleEvents, useCycleState } from "../../hooks/useCycleState" import { useEpochHistory } from "../../hooks/useEpochHistory" -import { toActivityList, toCycleRows, toEpochList } from "../../pages/cycle" -import CycleSurface from "./CycleSurface" +import { useFulfillEpoch } from "../../hooks/useFulfillEpoch" +import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { + type CycleAction, + toActivityList, + toCycleActions, + toCycleRows, + toEpochList, +} from "../../pages/cycle" +import TransactionModal, { + type TransactionModalProps, +} from "../vault/TransactionModal" +import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" -const CycleSection: React.FC = () => { +const EPOCH_GROUP = "Epoch" + +const CycleSection: React.FC<{ grants: Grant[] }> = ({ grants }) => { const { cycle } = useCycleState() const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() + const closeEpoch = usePendingTransaction(useCloseEpoch()) + const fulfillEpoch = usePendingTransaction(useFulfillEpoch()) + + const run = (action: CycleAction) => { + if (action.epochId === null) return + const label = String(action.epochId) + if (action.id === "close-epoch") closeEpoch.submit(undefined, label) + else fulfillEpoch.submit(action.epochId, label) + } + + const actions = toCycleActions(cycle, grants) + const withActions = (group: CycleGroup): CycleGroup => + group.title === EPOCH_GROUP && actions.length > 0 + ? { ...group, actions: } + : group + + const modalFlows: TransactionModalProps[] = [ + { + action: "close-epoch", + status: closeEpoch.status, + epoch: closeEpoch.amountLabel, + onClose: closeEpoch.reset, + onRetry: closeEpoch.retry, + }, + { + action: "fulfill-epoch", + status: fulfillEpoch.status, + epoch: fulfillEpoch.amountLabel, + onClose: fulfillEpoch.reset, + onRetry: fulfillEpoch.retry, + }, + ] + const activeModal = modalFlows.find((flow) => flow.status.status !== "idle") return ( - + <> + + {activeModal !== undefined && } + ) } diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 9f709f02..c4c1d324 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -56,6 +56,82 @@ margin: 0; } +.unmet { + color: var(--vault-text-muted); + text-align: right; + white-space: nowrap; +} + +.actionArea { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 20px 24px; + margin-top: 9px; +} + +.action { + display: flex; + flex-direction: column; + gap: 11px; + padding: 16px; + border: 1px solid var(--vault-border-hairline); + background: var(--vault-surface-inset); +} + +.conditions { + display: flex; + flex-direction: column; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.conditions .row { + padding-bottom: 0; +} + +.outcome { + margin: 0; + color: var(--vault-text-body); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.actionPrimary, +.actionUnavailable { + flex: 1; + min-height: 48px; + padding: 13px 18px; + font-family: var(--vault-font-sans); + font-size: 15.5px; + font-weight: 500; + border-radius: 0; +} + +.actionPrimary { + border: 1px solid var(--vault-action); + background: var(--vault-action); + color: var(--vault-surface-card); +} + +.actionPrimary:hover:not(:disabled) { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + +.actionUnavailable { + border: 1px solid var(--vault-border-hairline); + background: var(--vault-surface-card); + color: var(--vault-text-muted); + cursor: not-allowed; +} + .rowSkeleton { height: 1em; width: 11ch; diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index f547a7bf..632646f2 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -1,7 +1,9 @@ -import { render, screen, within } from "@testing-library/react" +import { fireEvent, render, screen, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { type CycleAction } from "../../pages/cycle" import CycleSurface, { type ActivityRow, + CycleActions, type CycleGroup, type EpochRow, type ListState, @@ -248,6 +250,26 @@ describe("CycleSurface", () => { expect(screen.queryByRole("table", { name: "Recent activity" })).toBeNull() }) + it("renders a group's action area after its rows", () => { + renderSurface({ + groups: [ + { ...groups[0]!, actions: }, + ], + }) + + const group = screen.getByText("Notice period").closest("li")! + .parentElement!.parentElement! + const button = within(group).getByRole("button", { name: "Close epoch" }) + expect( + group.compareDocumentPosition(button) & + Node.DOCUMENT_POSITION_CONTAINED_BY, + ).toBeTruthy() + expect( + screen.getByText("Notice period").compareDocumentPosition(button) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + it("heads each group and section with a sentence-case title below the card title", () => { renderSurface() @@ -258,3 +280,99 @@ describe("CycleSurface", () => { ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"]) }) }) + +const closeAction: CycleAction = { + id: "close-epoch", + label: "Close epoch", + conditions: [ + { label: "Wind-down not active", met: true }, + { label: "Epoch open", met: true }, + ], + outcome: "Seals epoch 5 and opens epoch 6.", + epochId: 5n, + enabled: true, +} + +const fulfillAction: CycleAction = { + id: "fulfill-epoch", + label: "Fulfill epoch", + conditions: [ + { label: "Sealed epoch awaiting a price", met: true }, + { label: "Notice elapsed", met: false }, + ], + outcome: "Prices epoch 4 at 1.0000 and settles its requests.", + epochId: 4n, + enabled: false, +} + +describe("CycleActions", () => { + it("lists each condition as met or not met, in the card's label and figure typography", () => { + render() + + const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) + const [awaiting, notice] = within(list).getAllByRole("listitem") + expect( + within(awaiting!).getByText("Sealed epoch awaiting a price"), + ).toBeTruthy() + expect(within(awaiting!).getByText("Met")).toBeTruthy() + expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() + expect(within(notice!).getByText("Not met")).toBeTruthy() + }) + + it("states the resulting state before the button and enables the button only when the action is", () => { + const onRun = vi.fn() + render( + , + ) + + expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() + const close = screen.getByRole("button", { name: "Close epoch" }) + const fulfill = screen.getByRole("button", { name: "Fulfill epoch" }) + expect((close as HTMLButtonElement).disabled).toBe(false) + expect((fulfill as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(close) + fireEvent.click(fulfill) + + expect(onRun).toHaveBeenCalledTimes(1) + expect(onRun).toHaveBeenCalledWith(closeAction) + }) + + it("says why an action is unavailable and keeps its button disabled", () => { + render( + , + ) + + expect( + screen.getByText( + "Needs 2 signatures; collecting them is not supported yet.", + ), + ).toBeTruthy() + expect( + (screen.getByRole("button", { name: "Close epoch" }) as HTMLButtonElement) + .disabled, + ).toBe(true) + }) + + it("omits the resulting state when there is none to show", () => { + render( + , + ) + + expect(screen.queryByText(/Prices epoch/)).toBeNull() + expect(screen.getByRole("button", { name: "Fulfill epoch" })).toBeTruthy() + }) +}) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 56ab22ac..03ef061e 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -1,4 +1,5 @@ import React from "react" +import { type CycleAction } from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" import { type AddressRow, type FigureGroup } from "../vault/AboutVault" @@ -9,6 +10,7 @@ import styles from "./CycleSurface.module.css" export type CycleGroup = FigureGroup & { tiles: Metric[] addresses?: AddressRow[] + actions?: React.ReactNode } export type EpochRow = { @@ -79,6 +81,60 @@ const Group: React.FC<{ group: CycleGroup }> = ({ group }) => ( ))} {group.addresses !== undefined && } + {group.actions} + +) + +export const CycleActions: React.FC<{ + actions: CycleAction[] + onRun: (action: CycleAction) => void +}> = ({ actions, onRun }) => ( +
+ {actions.map((action) => ( +
+
    + {action.conditions.map((condition) => ( +
  • + + {condition.label} + + + {condition.met ? "Met" : "Not met"} + +
  • + ))} +
+ {action.outcome !== null && ( +

+ {action.outcome} +

+ )} +
+ +
+ {action.unavailable !== undefined && ( +

+ {action.unavailable} +

+ )} +
+ ))}
) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index e25dc993..9ccfff46 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -1,4 +1,4 @@ -import { type Amount } from "@stellar-scaffold/app-lib" +import { type Amount, type Price } from "@stellar-scaffold/app-lib" import type * as AppLib from "@stellar-scaffold/app-lib" import { fireEvent, render, screen, within } from "@testing-library/react" import { StrictMode } from "react" @@ -7,6 +7,8 @@ import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" +import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import TransactionModal from "./TransactionModal" @@ -120,6 +122,36 @@ const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { return { ...view, onClose, onRetry } } +const renderCloseEpochModal = (status: CloseEpochStatus) => { + const onClose = vi.fn() + const onRetry = vi.fn() + const view = render( + , + ) + return { ...view, onClose, onRetry } +} + +const renderFulfillEpochModal = (status: FulfillEpochStatus) => { + 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" }) @@ -1188,3 +1220,208 @@ describe("TransactionModal, claiming a redemption", () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + +describe("TransactionModal, closing an epoch", () => { + it("opens with a preparing state naming the epoch being closed", () => { + renderCloseEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to close epoch 5" }), + ).toBeTruthy() + }) + + it("tells the manager what signing seals and what it opens", () => { + renderCloseEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing seals epoch 5 and opens epoch 6. Requests in epoch 5 wait for a price.", + ), + ).toBeTruthy() + }) + + it("tells the manager the close is on its way", () => { + renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the close" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("names the epoch the vault sealed once confirmed, taking the id from the result rather than the label", () => { + renderCloseEpochModal({ + status: "confirmed", + sealedEpoch: 7n, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 7 sealed" })).toBeTruthy() + expect( + screen.getByText( + "Epoch 8 is open and accepting requests. Epoch 7 prices once the notice elapses and a valid price is attested.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /current epoch could not be found/], + [6032, /not open/], + [2000, /not authorized to close/], + [2007, /does not hold the manager role/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderCloseEpochModal({ + 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 close error it does not recognize, without the raw code", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6001 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6001/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderCloseEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeTruthy() + expect( + screen.getByText( + "We could not confirm whether this reached the network. We don't yet know if it went through, so check the Cycle card before trying again.", + ), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + }) + + it("reads a declined signature as a choice and offers to try again", () => { + const { onRetry } = renderCloseEpochModal({ + status: "failed", + failure: { kind: "declined" }, + }) + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) + +describe("TransactionModal, pricing an epoch", () => { + it("opens with a preparing state naming the epoch being priced", () => { + renderFulfillEpochModal({ status: "preparing" }) + + expect( + screen.getByRole("heading", { name: "Preparing to price epoch 4" }), + ).toBeTruthy() + }) + + it("tells the operator what signing prices and settles", () => { + renderFulfillEpochModal({ status: "awaiting-signature" }) + + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + expect( + screen.getByText( + "Signing prices epoch 4 at the oracle's current price and settles its requests.", + ), + ).toBeTruthy() + }) + + it("tells the operator the settlement is on its way", () => { + renderFulfillEpochModal({ status: "submitted", hash: "a".repeat(64) }) + + expect( + screen.getByRole("heading", { name: "Sending the settlement" }), + ).toBeTruthy() + expect(screen.getByText(/on its way to the network/)).toBeTruthy() + }) + + it("shows the share price the vault settled at once confirmed, to four places", () => { + renderFulfillEpochModal({ + status: "confirmed", + sharePrice: 1_250_000_000_000_000_000n as Price, + hash: "b".repeat(64), + }) + + expect(screen.getByRole("heading", { name: "Epoch 4 priced" })).toBeTruthy() + expect( + screen.getByText( + "Settled at 1.2500 per share. Share claims are ready; redemption claims wait until the reserve covers them.", + ), + ).toBeTruthy() + }) + + it.each<[number, RegExp]>([ + [6046, /winding down/], + [6029, /epoch could not be found/], + [6038, /not sealed/], + [6042, /notice period has not elapsed/], + [6044, /price is not valid/], + [6043, /attested before this epoch was sealed/], + [6031, /could not resolve a valid share price/], + [6014, /too large/], + [1000, /paused/], + ])("names the vault's reason for code %i", (code, sentence) => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect(screen.getByText(sentence)).toBeTruthy() + }) + + it("falls back to a generic refusal for a settlement error it does not recognize, without the raw code", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "contract-error", code: 6035 }, + }) + + expect( + screen.getByText(/The vault refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/6035/)).toBeNull() + }) + + it("sends an unknown outcome to the Cycle card, not to the investor's requests", () => { + renderFulfillEpochModal({ + status: "failed", + failure: { kind: "unknown" }, + hash: "c".repeat(64), + }) + + expect( + screen.getByText(/check the Cycle card before trying again/), + ).toBeTruthy() + expect(screen.queryByText(/your requests/)).toBeNull() + expect(screen.getByText(/cccc\.\.\.cccc/)).toBeTruthy() + }) + + it("is reachable as a dialog and dismissible by its close control", () => { + const { onClose } = renderFulfillEpochModal({ status: "preparing" }) + + expect(screen.getByRole("dialog")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 8e430d19..810478db 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -2,6 +2,8 @@ import { AMOUNT_DECIMALS, explorerTransaction, formatExact, + formatScaled, + PRICE_DECIMALS, shortAddress, } from "@stellar-scaffold/app-lib" import React from "react" @@ -9,7 +11,9 @@ import { type CancelDepositStatus } from "../../hooks/useCancelDeposit" import { type CancelRedeemStatus } from "../../hooks/useCancelRedeem" import { type ClaimDepositStatus } from "../../hooks/useClaimDeposit" import { type ClaimRedeemStatus } from "../../hooks/useClaimRedeem" +import { type CloseEpochStatus } from "../../hooks/useCloseEpoch" import { type TransactionFailure } from "../../hooks/useContractTransaction" +import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import typeStyles from "../../styles/type.module.css" @@ -69,6 +73,20 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: "close-epoch" + status: CloseEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } + | { + action: "fulfill-epoch" + status: FulfillEpochStatus + epoch: string + onClose: () => void + onRetry: () => void + } const GENERIC_REFUSAL = "The vault refused this request. Try again, and contact support if it keeps happening." @@ -170,9 +188,55 @@ const claimRedeemContractErrorReason = ( } } +const closeEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so no more epochs can be sealed." + case 6029: + return "The current epoch could not be found." + case 6032: + return "The current epoch is not open, so there is nothing to seal." + case 2000: + return "This wallet is not authorized to close epochs." + case 2007: + return "This wallet does not hold the manager role." + default: + return GENERIC_REFUSAL + } +} + +const fulfillEpochContractErrorReason = (code: number): string => { + switch (code) { + case 6046: + return "The vault is winding down, so epochs can no longer be priced." + case 6029: + return "This epoch could not be found." + case 6038: + return "This epoch is not sealed, so it cannot be priced." + case 6042: + return "The notice period has not elapsed yet." + case 6044: + return "The oracle price is not valid right now." + case 6043: + return "The latest price was attested before this epoch was sealed. A fresh attestation is needed." + case 6031: + return "The vault could not resolve a valid share price." + case 6014: + return "The settlement total is too large for the vault to record." + case 1000: + return "The vault is paused, so epochs cannot be priced." + 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" + const describeFailure = ( failure: TransactionFailure, contractErrorReason: (code: number) => string, + followUp = INVESTOR_FOLLOW_UP, ): { heading: string; body: string } => { switch (failure.kind) { case "declined": @@ -193,7 +257,7 @@ const describeFailure = ( case "unknown": return { heading: "Something went wrong", - body: "We could not confirm whether this reached the network. We don't yet know if it went through, so check your requests before doing anything else.", + body: `We could not confirm whether this reached the network. We don't yet know if it went through, so ${followUp}.`, } } } @@ -445,6 +509,88 @@ const describeClaimRedeemStatus = ( } } +const describeCloseEpochStatus = ( + status: CloseEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to close epoch ${epoch}`, + body: "We are getting the close ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing seals epoch ${epoch} and opens epoch ${BigInt(epoch) + 1n}. Requests in epoch ${epoch} wait for a price.`, + } + case "submitted": + return { + heading: "Sending the close", + body: "The close 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: `Epoch ${status.sealedEpoch} sealed`, + body: `Epoch ${status.sealedEpoch + 1n} is open and accepting requests. Epoch ${status.sealedEpoch} prices once the notice elapses and a valid price is attested.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + closeEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + +const describeFulfillEpochStatus = ( + status: FulfillEpochStatus, + epoch: string, +): { heading: string; body: string; hash?: string } | undefined => { + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to price epoch ${epoch}`, + body: "We are getting the settlement ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { + heading: "Confirm in your wallet", + body: `Signing prices epoch ${epoch} at the oracle's current price and settles its requests.`, + } + case "submitted": + return { + heading: "Sending the settlement", + body: "The settlement 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: `Epoch ${epoch} priced`, + body: `Settled at ${formatScaled(status.sharePrice, PRICE_DECIMALS, 4)} per share. Share claims are ready; redemption claims wait until the reserve covers them.`, + hash: status.hash, + } + case "failed": + return { + ...describeFailure( + status.failure, + fulfillEpochContractErrorReason, + OPERATOR_FOLLOW_UP, + ), + hash: status.hash, + } + } +} + const describeStatus = ( props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => { @@ -476,6 +622,10 @@ const describeStatus = ( props.ticker, props.uncovered, ) + case "close-epoch": + return describeCloseEpochStatus(props.status, props.epoch) + case "fulfill-epoch": + return describeFulfillEpochStatus(props.status, props.epoch) } } @@ -497,7 +647,9 @@ const computeSteps = ( | CancelRedeemStatus | ClaimDepositStatus | RequestRedeemStatus - | ClaimRedeemStatus, + | ClaimRedeemStatus + | CloseEpochStatus + | FulfillEpochStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/hooks/useCloseEpoch.test.ts b/app/src/hooks/useCloseEpoch.test.ts new file mode 100644 index 00000000..19591c2e --- /dev/null +++ b/app/src/hooks/useCloseEpoch.test.ts @@ -0,0 +1,163 @@ +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { act, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deferred, investorAddress, renderWithWallet } from "./testSupport" +import { useCloseEpoch } from "./useCloseEpoch" +import { cycleWriteKeys } from "./useCycleState" +import { investorRequestsKey } from "./useInvestorRequests" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { close_epoch: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const renderCloseEpoch = () => renderWithWallet(useCloseEpoch) + +const successful = (result: bigint) => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result, + }), +}) + +describe("useCloseEpoch", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed with the sealed epoch, 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: 5n } + }, + ) + vaultMock.close_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCloseEpoch() + + 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", + sealedEpoch: 5n, + }), + ) + + expect(vaultMock.close_epoch).toHaveBeenCalledWith({ + caller: investorAddress, + }) + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.close_epoch.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6032)" }, + signAndSend, + }) + const { result } = renderCloseEpoch() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6032 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.close_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderCloseEpoch() + + 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: 5n } + }) + vaultMock.close_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderCloseEpoch() + + 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.close_epoch).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle's history, reads, events and figures once the close confirms", async () => { + vaultMock.close_epoch.mockResolvedValue(successful(5n)) + const { result, queryClient } = renderCloseEpoch() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: investorRequestsKey(investorAddress), + }) + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.close_epoch.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6046)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderCloseEpoch() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useCloseEpoch.ts b/app/src/hooks/useCloseEpoch.ts new file mode 100644 index 00000000..64359c6f --- /dev/null +++ b/app/src/hooks/useCloseEpoch.ts @@ -0,0 +1,35 @@ +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 CloseEpochStatus = TransactionStatus<{ sealedEpoch: bigint }> + +export interface UseCloseEpoch { + status: CloseEpochStatus + submit: () => boolean + reset: () => void +} + +export function useCloseEpoch(): UseCloseEpoch { + const call = useCallback( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.close_epoch({ caller: signer.publicKey }), + ), + [], + ) + const toConfirmed = useCallback( + (sealedEpoch: bigint) => ({ sealedEpoch }), + [], + ) + return useContractTransaction( + call, + toConfirmed, + cycleWriteKeys, + ) +} diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts new file mode 100644 index 00000000..b52e59a5 --- /dev/null +++ b/app/src/hooks/useContractTransaction.test.ts @@ -0,0 +1,72 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { act } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { investorAddress, renderWithWallet } from "./testSupport" +import { useContractTransaction } from "./useContractTransaction" +import { investorRequestsKey } from "./useInvestorRequests" + +const extraKeys = [ + ["cycle", "history"], + ["vault", "figures"], +] as const + +const transaction = (simulationError?: string) => + ({ + simulation: + simulationError === undefined ? undefined : { error: simulationError }, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: 1n, + }), + }) as unknown as AssembledTransaction + +const renderTransaction = (simulationError?: string) => + renderWithWallet(() => + useContractTransaction( + async () => transaction(simulationError), + (result: bigint) => ({ result }), + extraKeys, + ), + ) + +describe("useContractTransaction", () => { + it("refreshes the extra query keys alongside the investor's once the transaction confirms", async () => { + const { result, queryClient } = renderTransaction() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(undefined)) + + expect(result.current.status).toEqual({ status: "confirmed", result: 1n }) + for (const queryKey of extraKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: investorRequestsKey(investorAddress), + }) + }) + + it("leaves every cache alone when the vault refuses at simulation", async () => { + const { result, queryClient } = renderTransaction( + "HostError: Error(Contract, #6046)", + ) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(undefined)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("refreshes only the investor's caches when no extra keys are given", async () => { + const { result, queryClient } = renderWithWallet(() => + useContractTransaction( + async () => transaction(), + (result: bigint) => ({ result }), + ), + ) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(undefined)) + + expect(invalidateQueries).toHaveBeenCalledTimes(3) + }) +}) diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index e1cd74ba..84fbc6c5 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -4,7 +4,7 @@ import { isUserRejection, parseErrorCode, } from "@stellar-scaffold/app-lib" -import { useQueryClient } from "@tanstack/react-query" +import { type QueryKey, useQueryClient } from "@tanstack/react-query" import { useCallback, useRef, useState } from "react" import { depositBalanceKey } from "./useDepositBalance" import { investorRequestsKey } from "./useInvestorRequests" @@ -40,6 +40,7 @@ export function useContractTransaction< >( call: (signer: Signer, arg: TArg) => Promise>, toConfirmed: (result: TResult) => TConfirmed, + extraKeys: readonly QueryKey[] = [], ): UseContractTransaction { const { address, signTransaction } = useWallet() const queryClient = useQueryClient() @@ -69,6 +70,9 @@ export function useContractTransaction< void queryClient.invalidateQueries({ queryKey: sharePositionKey(owner), }) + for (const queryKey of extraKeys) { + void queryClient.invalidateQueries({ queryKey }) + } } applyStatus({ status: "preparing" }) try { @@ -119,7 +123,7 @@ export function useContractTransaction< submitting.current = false } }, - [signTransaction, call, toConfirmed, queryClient], + [signTransaction, call, toConfirmed, queryClient, extraKeys], ) const submit = useCallback( diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index edf1a34c..aa20c196 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -15,6 +15,7 @@ import { asset, asyncVault, navOracle } from "../config/clients" import { type EpochHistoryRead, type EpochRecord, + epochHistoryKey, useEpochHistory, } from "./useEpochHistory" import { classifyNav, type NavClassification } from "./useNavPrice" @@ -33,6 +34,15 @@ const VAULT_EVENTS = [ ] const ORACLE_EVENTS = ["nav_attested"] +export const cycleReadsKey = ["cycle", "reads"] as const +export const cycleEventsKey = ["cycle", "events"] as const +export const cycleWriteKeys = [ + epochHistoryKey, + cycleReadsKey, + cycleEventsKey, + ["vault", "figures"], +] as const + export type OracleLimits = { freshness: bigint cooldown: bigint @@ -246,7 +256,7 @@ export function useCycleState(): { cycle: CycleState } { const { pause } = useVaultPaused() const { authorities, isPending: authoritiesPending } = useVaultAuthorities() const { data: reads } = useQuery({ - queryKey: ["cycle", "reads"], + queryKey: cycleReadsKey, queryFn: fetchCycleReads, staleTime: STALE_MS, refetchInterval: REFRESH_MS, @@ -312,7 +322,7 @@ async function fetchCycleEvents(): Promise { export function useCycleEvents(): { cycleEvents: CycleEventsRead } { const { data } = useQuery({ - queryKey: ["cycle", "events"], + queryKey: cycleEventsKey, queryFn: fetchCycleEvents, staleTime: STALE_MS, }) diff --git a/app/src/hooks/useFulfillEpoch.test.ts b/app/src/hooks/useFulfillEpoch.test.ts new file mode 100644 index 00000000..ed404087 --- /dev/null +++ b/app/src/hooks/useFulfillEpoch.test.ts @@ -0,0 +1,168 @@ +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 { useFulfillEpoch } from "./useFulfillEpoch" + +const { vaultMock, asyncVaultWriterMock } = vi.hoisted(() => ({ + vaultMock: { fulfill_epoch: vi.fn() }, + asyncVaultWriterMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: asyncVaultWriterMock, +})) + +const epochId = 4n +const sharePrice = 1_250_000_000_000_000_000n + +const renderFulfillEpoch = () => renderWithWallet(useFulfillEpoch) + +const successful = (result: bigint) => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result, + }), +}) + +describe("useFulfillEpoch", () => { + beforeEach(() => { + vi.clearAllMocks() + asyncVaultWriterMock.mockResolvedValue(vaultMock) + }) + + it("moves through awaiting signature, submitted, then confirmed with the share price, 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: sharePrice, + } + }, + ) + vaultMock.fulfill_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderFulfillEpoch() + + expect(result.current.submit(epochId)).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", + sharePrice, + }), + ) + + expect(vaultMock.fulfill_epoch).toHaveBeenCalledWith({ + epoch_id: epochId, + }) + }) + + it("refuses before ever asking for a signature when the simulation carries the vault's reason", async () => { + const signAndSend = vi.fn() + vaultMock.fulfill_epoch.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6042)" }, + signAndSend, + }) + const { result } = renderFulfillEpoch() + + await act(() => result.current.submit(epochId)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 6042 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + vaultMock.fulfill_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderFulfillEpoch() + + await act(() => result.current.submit(epochId)) + + 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: sharePrice, + } + }) + vaultMock.fulfill_epoch.mockResolvedValue({ + simulation: undefined, + signAndSend, + }) + const { result } = renderFulfillEpoch() + + 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.fulfill_epoch).toHaveBeenCalledTimes(1) + }) + + it("refreshes the cycle's history, reads, events and figures once the settlement confirms", async () => { + vaultMock.fulfill_epoch.mockResolvedValue(successful(sharePrice)) + const { result, queryClient } = renderFulfillEpoch() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } + }) + + it("does not touch the cached cycle when the vault refuses at simulation", async () => { + vaultMock.fulfill_epoch.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #6044)" }, + signAndSend: vi.fn(), + }) + const { result, queryClient } = renderFulfillEpoch() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(epochId)) + + expect(invalidateQueries).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/hooks/useFulfillEpoch.ts b/app/src/hooks/useFulfillEpoch.ts new file mode 100644 index 00000000..03322d97 --- /dev/null +++ b/app/src/hooks/useFulfillEpoch.ts @@ -0,0 +1,31 @@ +import { type Price, 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 FulfillEpochStatus = TransactionStatus<{ sharePrice: Price }> + +export interface UseFulfillEpoch { + status: FulfillEpochStatus + submit: (epochId: bigint) => boolean + reset: () => void +} + +export function useFulfillEpoch(): UseFulfillEpoch { + const call = useCallback( + (signer: Signer, epochId: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.fulfill_epoch({ epoch_id: epochId }), + ), + [], + ) + const toConfirmed = useCallback( + (sharePrice: bigint) => ({ sharePrice: sharePrice as Price }), + [], + ) + return useContractTransaction(call, toConfirmed, cycleWriteKeys) +} diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 1dd97b4c..ad3ef9b3 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -17,7 +17,9 @@ vi.mock("../hooks/useAdminAuthority", async (importOriginal) => { vi.mock("../hooks/useWallet", () => ({ useWallet: useWalletMock })) vi.mock("../components/admin/CycleSection", () => ({ - default: () =>
Cycle surface
, + default: ({ grants }: { grants: Grant[] }) => ( +
Cycle surface for {grants.map((grant) => grant.role).join(", ")}
+ ), })) const connectedAddress = "GCONNECTEDADDRESS1234567890" @@ -191,6 +193,24 @@ describe("AdminPanel", () => { ).toBeTruthy() }) + it("hands the Cycle card every grant that opened it, so its actions follow the wallet's roles", () => { + const manager = holderOf("vault manager") + const treasury = holderOf("vault treasury") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle"]), + grantedBy: { ...emptyGrantedBy, cycle: [manager, treasury] }, + grants: [manager, treasury], + }), + ) + render() + + expect( + screen.getByText("Cycle surface for vault manager, vault treasury"), + ).toBeTruthy() + }) + it("says nothing extra when the wallet is the holder itself", () => { const manager = holderOf("vault manager") useAdminAuthorityMock.mockReturnValue( @@ -259,8 +279,10 @@ describe("AdminPanel", () => { const cycleCard = cards.find( (card) => within(card).queryByRole("heading", { name: "Cycle" }) !== null, ) - expect(within(cycleCard!).getByText("Cycle surface")).toBeTruthy() - expect(screen.getAllByText("Cycle surface")).toHaveLength(1) + expect( + within(cycleCard!).getByText("Cycle surface for vault manager"), + ).toBeTruthy() + expect(screen.getAllByText(/Cycle surface/)).toHaveLength(1) }) it("shows no cycle surface to a wallet with no Cycle grant", () => { diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 3961b1b5..92fa75a0 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -123,7 +123,9 @@ const AdminPanel: React.FC = () => { .map((grant) => describeCardGrant(grant, address ?? "")) .join(", ")} - {surface === "cycle" && } + {surface === "cycle" && ( + + )} ), )} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 48ba059b..7d0c5ad4 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -8,6 +8,7 @@ import { type CycleGroup, type ListState, } from "../components/admin/CycleSurface" +import { type Grant } from "../hooks/useAdminAuthority" import { type CycleEpoch, type CycleOracle, @@ -20,8 +21,10 @@ import { type EpochRecord, } from "../hooks/useEpochHistory" import { + type CycleAction, formatDuration, toActivityList, + toCycleActions, toCycleRows, toEpochList, } from "./cycle" @@ -100,7 +103,9 @@ const windDown: CycleWindDown = { supply: amount(0n), } -const ready = (overrides: Partial>) => +type ReadyState = Extract + +const ready = (overrides: Partial) => ({ status: "ready", ledgerTime: NOW, @@ -588,3 +593,251 @@ describe("toActivityList", () => { expect(rows).toEqual([]) }) }) + +const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ + role, + authority: "GAUTHORITY1234567890", + standing, +}) + +const manager = grant("vault manager") +const treasury = grant("vault treasury") + +const actionOf = (actions: CycleAction[], id: CycleAction["id"]) => + actions.find((action) => action.id === id) + +const conditionsOf = (action: CycleAction | undefined) => + Object.fromEntries( + (action?.conditions ?? []).map(({ label, met }) => [label, met]), + ) + +describe("toCycleActions", () => { + it("offers the manager both actions and any other cycle role only the settlement", () => { + expect( + toCycleActions(ready({}), [manager]).map((action) => action.id), + ).toEqual(["close-epoch", "fulfill-epoch"]) + expect( + toCycleActions(ready({}), [treasury]).map((action) => action.id), + ).toEqual(["fulfill-epoch"]) + }) + + it("offers nothing while the cycle is still being read or could not be", () => { + expect(toCycleActions({ status: "checking" }, [manager])).toEqual([]) + expect(toCycleActions({ status: "unreadable" }, [manager])).toEqual([]) + }) + + it("enables the close when the wind-down is not active and an epoch is open, naming what it seals and opens", () => { + const close = actionOf(toCycleActions(ready({}), [manager]), "close-epoch") + + expect(close?.label).toBe("Close epoch") + expect(conditionsOf(close)).toEqual({ + "Wind-down not active": true, + "Epoch open": true, + }) + expect(close?.outcome).toBe("Seals epoch 5 and opens epoch 6.") + expect(close?.epochId).toBe(5n) + expect(close?.enabled).toBe(true) + expect(close?.unavailable).toBeUndefined() + }) + + it("disables the close while the wind-down is active", () => { + const close = actionOf( + toCycleActions(ready({ windDown: { ...windDown, phase: "active" } }), [ + manager, + ]), + "close-epoch", + ) + + expect(conditionsOf(close)["Wind-down not active"]).toBe(false) + expect(close?.enabled).toBe(false) + }) + + it("disables the close when no open epoch could be read", () => { + const close = actionOf( + toCycleActions(ready({ epoch: { ...epoch, open: null } }), [manager]), + "close-epoch", + ) + + expect(conditionsOf(close)["Epoch open"]).toBe(false) + expect(close?.enabled).toBe(false) + }) + + it("does not count an unreadable wind-down as inactive", () => { + const close = actionOf( + toCycleActions(ready({ windDown: { ...windDown, phase: null } }), [ + manager, + ]), + "close-epoch", + ) + + expect(conditionsOf(close)["Wind-down not active"]).toBe(false) + }) + + it("makes the close unavailable when the manager's authority needs more signatures than the wallet's", () => { + const close = actionOf( + toCycleActions(ready({}), [grant("vault manager", { needs: 2 })]), + "close-epoch", + ) + + expect(close?.unavailable).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + expect(close?.enabled).toBe(false) + }) + + it("reads the threshold as the signatures needed for a weighted authority", () => { + const close = actionOf( + toCycleActions(ready({}), [ + grant("vault manager", { weight: 1, threshold: 3 }), + ]), + "close-epoch", + ) + + expect(close?.unavailable).toBe( + "Needs 3 signatures; collecting them is not supported yet.", + ) + }) + + it("enables the settlement when every condition is met, pricing the oldest sealed epoch at the current price", () => { + const fulfill = actionOf( + toCycleActions(ready({}), [treasury]), + "fulfill-epoch", + ) + + expect(fulfill?.label).toBe("Fulfill epoch") + expect(conditionsOf(fulfill)).toEqual({ + "Sealed epoch awaiting a price": true, + "Notice elapsed": true, + "Price valid": true, + "Attested after the close": true, + "Vault not paused": true, + "Wind-down not active": true, + }) + expect(fulfill?.outcome).toBe( + "Prices epoch 4 at 1.0000 and settles its requests.", + ) + expect(fulfill?.epochId).toBe(4n) + expect(fulfill?.enabled).toBe(true) + expect(fulfill?.unavailable).toBeUndefined() + }) + + it("never asks a settlement for signatures, since anyone may run it", () => { + const fulfill = actionOf( + toCycleActions(ready({}), [grant("vault treasury", { needs: 2 })]), + "fulfill-epoch", + ) + + expect(fulfill?.unavailable).toBeUndefined() + expect(fulfill?.enabled).toBe(true) + }) + + it("disables the settlement with no outcome when nothing sealed awaits a price", () => { + const fulfill = actionOf( + toCycleActions(ready({ epoch: { ...epoch, awaiting: null } }), [ + treasury, + ]), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Sealed epoch awaiting a price"]).toBe(false) + expect(conditionsOf(fulfill)["Notice elapsed"]).toBe(false) + expect(conditionsOf(fulfill)["Attested after the close"]).toBe(false) + expect(fulfill?.outcome).toBeNull() + expect(fulfill?.epochId).toBeNull() + expect(fulfill?.enabled).toBe(false) + }) + + it("disables the settlement while the notice has not elapsed", () => { + const fulfill = actionOf( + toCycleActions(ready({ ledgerTime: sealed.priceableAt - 1n }), [ + treasury, + ]), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Notice elapsed"]).toBe(false) + expect(fulfill?.enabled).toBe(false) + }) + + it("treats the notice as elapsed the second it is due, and unknown without a ledger time", () => { + expect( + conditionsOf( + actionOf( + toCycleActions(ready({ ledgerTime: sealed.priceableAt }), [treasury]), + "fulfill-epoch", + ), + )["Notice elapsed"], + ).toBe(true) + expect( + conditionsOf( + actionOf( + toCycleActions(ready({ ledgerTime: null }), [treasury]), + "fulfill-epoch", + ), + )["Notice elapsed"], + ).toBe(false) + }) + + it.each(["stale", "paused", "never", "unreadable"])( + "disables the settlement while the price is %s", + (state) => { + const fulfill = actionOf( + toCycleActions(ready({ oracle: { ...oracle, state } }), [treasury]), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Price valid"]).toBe(false) + expect(fulfill?.enabled).toBe(false) + }, + ) + + it("disables the settlement when the price was attested before the epoch was sealed", () => { + const fulfill = actionOf( + toCycleActions( + ready({ oracle: { ...oracle, attestedAt: sealed.closedAt - 1n } }), + [treasury], + ), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Attested after the close"]).toBe(false) + expect(fulfill?.enabled).toBe(false) + }) + + it("accepts a price attested in the same second the epoch was sealed", () => { + const fulfill = actionOf( + toCycleActions( + ready({ oracle: { ...oracle, attestedAt: sealed.closedAt } }), + [treasury], + ), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Attested after the close"]).toBe(true) + }) + + it.each(["paused", "checking", "unreadable"])( + "disables the settlement while the vault's pause reads %s", + (paused) => { + const fulfill = actionOf( + toCycleActions(ready({ paused }), [treasury]), + "fulfill-epoch", + ) + + expect(conditionsOf(fulfill)["Vault not paused"]).toBe(false) + expect(fulfill?.enabled).toBe(false) + }, + ) + + it("keeps the outcome without a price figure when the oracle has none to show", () => { + const fulfill = actionOf( + toCycleActions( + ready({ oracle: { ...oracle, state: "never", price: null } }), + [treasury], + ), + "fulfill-epoch", + ) + + expect(fulfill?.outcome).toBeNull() + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 056c89fa..0dd89026 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -17,6 +17,7 @@ import { } from "../components/admin/CycleSurface" import { type FigureRow } from "../components/vault/AboutVault" import { type Metric } from "../components/vault/MetricsStrip" +import { type Grant, type Standing } from "../hooks/useAdminAuthority" import { type CycleEventsRead, type CycleOracle, @@ -325,6 +326,116 @@ function windDownGroup(windDown: CycleWindDown): CycleGroup { } } +export type CycleActionId = "close-epoch" | "fulfill-epoch" + +export type Condition = { label: string; met: boolean } + +export type CycleAction = { + id: CycleActionId + label: string + conditions: Condition[] + outcome: string | null + epochId: bigint | null + enabled: boolean + unavailable?: string +} + +const MANAGER_ROLE = "vault manager" + +const signaturesNeeded = (standing: Standing): number | null => { + if (standing === "signs-alone") return null + return "needs" in standing ? standing.needs : standing.threshold +} + +const windDownInactive = (state: ReadyState): Condition => ({ + label: "Wind-down not active", + met: state.windDown.phase !== null && state.windDown.phase !== "active", +}) + +const toAction = ( + id: CycleActionId, + label: string, + conditions: Condition[], + outcome: string | null, + epochId: bigint | null, + unavailable?: string, +): CycleAction => ({ + id, + label, + conditions, + outcome, + epochId, + enabled: + unavailable === undefined && + epochId !== null && + conditions.every((condition) => condition.met), + ...(unavailable === undefined ? {} : { unavailable }), +}) + +function toCloseAction(state: ReadyState, grant: Grant): CycleAction { + const { epoch } = state + const needed = signaturesNeeded(grant.standing) + return toAction( + "close-epoch", + "Close epoch", + [ + windDownInactive(state), + { label: "Epoch open", met: epoch.open?.status === "Open" }, + ], + `Seals epoch ${epoch.id} and opens epoch ${epoch.id + 1n}.`, + epoch.id, + needed === null + ? undefined + : `Needs ${needed} signatures; collecting them is not supported yet.`, + ) +} + +function toFulfillAction(state: ReadyState): CycleAction { + const { awaiting } = state.epoch + const { oracle, ledgerTime } = state + const price = formatPriceValue(oracle.price) + return toAction( + "fulfill-epoch", + "Fulfill epoch", + [ + { label: "Sealed epoch awaiting a price", met: awaiting !== null }, + { + label: "Notice elapsed", + met: + awaiting !== null && + ledgerTime !== null && + ledgerTime >= awaiting.priceableAt, + }, + { label: "Price valid", met: oracle.state === "valid" }, + { + label: "Attested after the close", + met: + awaiting !== null && + oracle.attestedAt !== null && + oracle.attestedAt >= awaiting.closedAt, + }, + { label: "Vault not paused", met: state.paused === "open" }, + windDownInactive(state), + ], + awaiting === null || price === null + ? null + : `Prices epoch ${awaiting.id} at ${price} and settles its requests.`, + awaiting?.id ?? null, + ) +} + +export function toCycleActions( + state: CycleState, + grants: Grant[], +): CycleAction[] { + if (state.status !== "ready" || grants.length === 0) return [] + const manager = grants.find((grant) => grant.role === MANAGER_ROLE) + const actions: CycleAction[] = [] + if (manager !== undefined) actions.push(toCloseAction(state, manager)) + actions.push(toFulfillAction(state)) + return actions +} + export function toCycleRows(state: CycleState): CycleGroup[] { if (state.status === "unreadable") return [] if (state.status === "checking") { From e87457f69ae465c9aac2a69fe501dfd0647aecde Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 8 Oct 2026 21:30:14 -0300 Subject: [PATCH 2/3] fix(app): keep submit identity stable and stop parsing the modal label --- .../components/vault/TransactionModal.test.tsx | 18 +++++++++++++++--- app/src/components/vault/TransactionModal.tsx | 2 +- app/src/hooks/useContractTransaction.test.ts | 13 +++++++++++++ app/src/hooks/useContractTransaction.ts | 4 +++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 9ccfff46..876ab91b 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -122,14 +122,14 @@ const renderClaimRedeemModal = (status: ClaimRedeemStatus) => { return { ...view, onClose, onRetry } } -const renderCloseEpochModal = (status: CloseEpochStatus) => { +const renderCloseEpochModal = (status: CloseEpochStatus, epoch = "5") => { const onClose = vi.fn() const onRetry = vi.fn() const view = render( , @@ -1238,11 +1238,23 @@ describe("TransactionModal, closing an epoch", () => { ).toBeTruthy() expect( screen.getByText( - "Signing seals epoch 5 and opens epoch 6. Requests in epoch 5 wait for a price.", + "Signing seals epoch 5 and opens the next one. Requests in epoch 5 wait for a price.", ), ).toBeTruthy() }) + it.each(["", "unknown"])( + "renders the signing prompt without throwing when the label is %j", + (epoch) => { + expect(() => + renderCloseEpochModal({ status: "awaiting-signature" }, epoch), + ).not.toThrow() + expect( + screen.getByRole("heading", { name: "Confirm in your wallet" }), + ).toBeTruthy() + }, + ) + it("tells the manager the close is on its way", () => { renderCloseEpochModal({ status: "submitted", hash: "a".repeat(64) }) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 810478db..261243db 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -524,7 +524,7 @@ const describeCloseEpochStatus = ( case "awaiting-signature": return { heading: "Confirm in your wallet", - body: `Signing seals epoch ${epoch} and opens epoch ${BigInt(epoch) + 1n}. Requests in epoch ${epoch} wait for a price.`, + body: `Signing seals epoch ${epoch} and opens the next one. Requests in epoch ${epoch} wait for a price.`, } case "submitted": return { diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts index b52e59a5..1f3a2162 100644 --- a/app/src/hooks/useContractTransaction.test.ts +++ b/app/src/hooks/useContractTransaction.test.ts @@ -56,6 +56,19 @@ describe("useContractTransaction", () => { expect(invalidateQueries).not.toHaveBeenCalled() }) + it("keeps the same submit across rerenders when no extra keys are given", () => { + const call = async () => transaction() + const toConfirmed = (result: bigint) => ({ result }) + const { result, rerender } = renderWithWallet(() => + useContractTransaction(call, toConfirmed), + ) + const { submit } = result.current + + rerender() + + expect(result.current.submit).toBe(submit) + }) + it("refreshes only the investor's caches when no extra keys are given", async () => { const { result, queryClient } = renderWithWallet(() => useContractTransaction( diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 84fbc6c5..568eac90 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -27,6 +27,8 @@ export type TransactionStatus< | ({ status: "confirmed"; hash?: string } & TConfirmed) | { status: "failed"; failure: TransactionFailure; hash?: string } +const NO_EXTRA_KEYS: readonly QueryKey[] = [] + export interface UseContractTransaction { status: TransactionStatus submit: (arg: TArg) => boolean @@ -40,7 +42,7 @@ export function useContractTransaction< >( call: (signer: Signer, arg: TArg) => Promise>, toConfirmed: (result: TResult) => TConfirmed, - extraKeys: readonly QueryKey[] = [], + extraKeys: readonly QueryKey[] = NO_EXTRA_KEYS, ): UseContractTransaction { const { address, signTransaction } = useWallet() const queryClient = useQueryClient() From 70988a2fada50f084c827d83c1acde39fbe38818 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Fri, 9 Oct 2026 12:55:50 -0300 Subject: [PATCH 3/3] fix(app): gate actions on the wallet holding the role --- .../components/admin/CycleSection.test.tsx | 49 +++++++--- app/src/components/admin/CycleSection.tsx | 7 +- app/src/pages/AdminPanel.tsx | 5 +- app/src/pages/cycle.test.ts | 97 ++++++++++++------- app/src/pages/cycle.ts | 37 +++++-- 5 files changed, 137 insertions(+), 58 deletions(-) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index efa824ac..8026ad15 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -109,9 +109,11 @@ const ready = (overrides: Partial = {}): CycleState => ({ ...overrides, }) +const WALLET = "GAUTHORITY1234567890" + const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ role, - authority: "GAUTHORITY1234567890", + authority: WALLET, standing, }) @@ -144,7 +146,7 @@ describe("CycleSection", () => { }) it("offers the manager the close and the settlement, with their conditions and resulting state", () => { - render() + render() expect(button("Close epoch").disabled).toBe(false) expect(button("Fulfill epoch").disabled).toBe(false) @@ -159,7 +161,7 @@ describe("CycleSection", () => { }) it("offers the treasury only the settlement", () => { - render() + render() expect(screen.queryByRole("button", { name: "Close epoch" })).toBeNull() expect(button("Fulfill epoch").disabled).toBe(false) @@ -169,7 +171,7 @@ describe("CycleSection", () => { useCycleStateMock.mockReturnValue({ cycle: ready({ ledgerTime: sealed.priceableAt - 1n }), }) - render() + render() expect(button("Fulfill epoch").disabled).toBe(true) const notice = screen.getByText("Notice elapsed").closest("li")! @@ -177,7 +179,12 @@ describe("CycleSection", () => { }) it("tells a manager signing through a multisig that collecting signatures is not supported yet", () => { - render() + render( + , + ) expect(button("Close epoch").disabled).toBe(true) expect( @@ -188,21 +195,39 @@ describe("CycleSection", () => { expect(button("Fulfill epoch").disabled).toBe(false) }) + it("tells a signer of another account that acting on its behalf is not supported yet", () => { + render( + , + ) + + expect(button("Close epoch").disabled).toBe(true) + expect( + screen.getByText( + "Signs for GOTH...3210; acting on its behalf is not supported yet.", + ), + ).toBeTruthy() + }) + it("shows no actions while the cycle is still being read", () => { useCycleStateMock.mockReturnValue({ cycle: { status: "checking" } }) - render() + render() expect(screen.queryByRole("button")).toBeNull() }) it("submits the close and follows it in the modal, labelled with the epoch it seals", () => { - const { rerender } = render() + const { rerender } = render( + , + ) fireEvent.click(button("Close epoch")) expect(closeEpochMock.submit).toHaveBeenCalledTimes(1) closeEpochMock.status = { status: "preparing" } - rerender() + rerender() expect( screen.getByRole("heading", { name: "Preparing to close epoch 5" }), @@ -210,13 +235,15 @@ describe("CycleSection", () => { }) it("submits the settlement for the oldest sealed epoch and follows it in the modal", () => { - const { rerender } = render() + const { rerender } = render( + , + ) fireEvent.click(button("Fulfill epoch")) expect(fulfillEpochMock.submit).toHaveBeenCalledWith(4n) fulfillEpochMock.status = { status: "awaiting-signature" } - rerender() + rerender() expect( screen.getByText( @@ -230,7 +257,7 @@ describe("CycleSection", () => { status: "failed", failure: { kind: "declined" }, } - render() + render() fireEvent.click(screen.getByRole("button", { name: "Close" })) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 8c309070..139e5965 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -19,7 +19,10 @@ import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" const EPOCH_GROUP = "Epoch" -const CycleSection: React.FC<{ grants: Grant[] }> = ({ grants }) => { +const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ + grants, + wallet, +}) => { const { cycle } = useCycleState() const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() @@ -33,7 +36,7 @@ const CycleSection: React.FC<{ grants: Grant[] }> = ({ grants }) => { else fulfillEpoch.submit(action.epochId, label) } - const actions = toCycleActions(cycle, grants) + const actions = toCycleActions(cycle, grants, wallet) const withActions = (group: CycleGroup): CycleGroup => group.title === EPOCH_GROUP && actions.length > 0 ? { ...group, actions: } diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 92fa75a0..f1c558af 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -124,7 +124,10 @@ const AdminPanel: React.FC = () => { .join(", ")} {surface === "cycle" && ( - + )} ), diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 7d0c5ad4..92051568 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -594,11 +594,16 @@ describe("toActivityList", () => { }) }) -const grant = (role: string, standing: Grant["standing"] = "signs-alone") => ({ - role, - authority: "GAUTHORITY1234567890", - standing, -}) +const WALLET = "GAUTHORITY1234567890" + +const grant = ( + role: string, + standing: Grant["standing"] = "signs-alone", + authority = WALLET, +) => ({ role, authority, standing }) + +const actionsFor = (state: CycleState, grants: Grant[]) => + toCycleActions(state, grants, WALLET) const manager = grant("vault manager") const treasury = grant("vault treasury") @@ -613,21 +618,21 @@ const conditionsOf = (action: CycleAction | undefined) => describe("toCycleActions", () => { it("offers the manager both actions and any other cycle role only the settlement", () => { + expect(actionsFor(ready({}), [manager]).map((action) => action.id)).toEqual( + ["close-epoch", "fulfill-epoch"], + ) expect( - toCycleActions(ready({}), [manager]).map((action) => action.id), - ).toEqual(["close-epoch", "fulfill-epoch"]) - expect( - toCycleActions(ready({}), [treasury]).map((action) => action.id), + actionsFor(ready({}), [treasury]).map((action) => action.id), ).toEqual(["fulfill-epoch"]) }) it("offers nothing while the cycle is still being read or could not be", () => { - expect(toCycleActions({ status: "checking" }, [manager])).toEqual([]) - expect(toCycleActions({ status: "unreadable" }, [manager])).toEqual([]) + expect(actionsFor({ status: "checking" }, [manager])).toEqual([]) + expect(actionsFor({ status: "unreadable" }, [manager])).toEqual([]) }) it("enables the close when the wind-down is not active and an epoch is open, naming what it seals and opens", () => { - const close = actionOf(toCycleActions(ready({}), [manager]), "close-epoch") + const close = actionOf(actionsFor(ready({}), [manager]), "close-epoch") expect(close?.label).toBe("Close epoch") expect(conditionsOf(close)).toEqual({ @@ -642,7 +647,7 @@ describe("toCycleActions", () => { it("disables the close while the wind-down is active", () => { const close = actionOf( - toCycleActions(ready({ windDown: { ...windDown, phase: "active" } }), [ + actionsFor(ready({ windDown: { ...windDown, phase: "active" } }), [ manager, ]), "close-epoch", @@ -654,7 +659,7 @@ describe("toCycleActions", () => { it("disables the close when no open epoch could be read", () => { const close = actionOf( - toCycleActions(ready({ epoch: { ...epoch, open: null } }), [manager]), + actionsFor(ready({ epoch: { ...epoch, open: null } }), [manager]), "close-epoch", ) @@ -664,9 +669,7 @@ describe("toCycleActions", () => { it("does not count an unreadable wind-down as inactive", () => { const close = actionOf( - toCycleActions(ready({ windDown: { ...windDown, phase: null } }), [ - manager, - ]), + actionsFor(ready({ windDown: { ...windDown, phase: null } }), [manager]), "close-epoch", ) @@ -675,7 +678,7 @@ describe("toCycleActions", () => { it("makes the close unavailable when the manager's authority needs more signatures than the wallet's", () => { const close = actionOf( - toCycleActions(ready({}), [grant("vault manager", { needs: 2 })]), + actionsFor(ready({}), [grant("vault manager", { needs: 2 })]), "close-epoch", ) @@ -685,9 +688,36 @@ describe("toCycleActions", () => { expect(close?.enabled).toBe(false) }) + it("makes the close unavailable when the manager's authority is an account the wallet only signs for", () => { + const close = actionOf( + actionsFor(ready({}), [ + grant("vault manager", "signs-alone", "GOTHERACCOUNT9876543210"), + ]), + "close-epoch", + ) + + expect(close?.unavailable).toBe( + "Signs for GOTH...3210; acting on its behalf is not supported yet.", + ) + expect(close?.enabled).toBe(false) + }) + + it("prefers the grant whose authority is the wallet over an earlier one for the same role", () => { + const close = actionOf( + actionsFor(ready({}), [ + grant("vault manager", "signs-alone", "GOTHERACCOUNT9876543210"), + grant("vault manager"), + ]), + "close-epoch", + ) + + expect(close?.unavailable).toBeUndefined() + expect(close?.enabled).toBe(true) + }) + it("reads the threshold as the signatures needed for a weighted authority", () => { const close = actionOf( - toCycleActions(ready({}), [ + actionsFor(ready({}), [ grant("vault manager", { weight: 1, threshold: 3 }), ]), "close-epoch", @@ -699,10 +729,7 @@ describe("toCycleActions", () => { }) it("enables the settlement when every condition is met, pricing the oldest sealed epoch at the current price", () => { - const fulfill = actionOf( - toCycleActions(ready({}), [treasury]), - "fulfill-epoch", - ) + const fulfill = actionOf(actionsFor(ready({}), [treasury]), "fulfill-epoch") expect(fulfill?.label).toBe("Fulfill epoch") expect(conditionsOf(fulfill)).toEqual({ @@ -723,7 +750,7 @@ describe("toCycleActions", () => { it("never asks a settlement for signatures, since anyone may run it", () => { const fulfill = actionOf( - toCycleActions(ready({}), [grant("vault treasury", { needs: 2 })]), + actionsFor(ready({}), [grant("vault treasury", { needs: 2 })]), "fulfill-epoch", ) @@ -733,9 +760,7 @@ describe("toCycleActions", () => { it("disables the settlement with no outcome when nothing sealed awaits a price", () => { const fulfill = actionOf( - toCycleActions(ready({ epoch: { ...epoch, awaiting: null } }), [ - treasury, - ]), + actionsFor(ready({ epoch: { ...epoch, awaiting: null } }), [treasury]), "fulfill-epoch", ) @@ -749,9 +774,7 @@ describe("toCycleActions", () => { it("disables the settlement while the notice has not elapsed", () => { const fulfill = actionOf( - toCycleActions(ready({ ledgerTime: sealed.priceableAt - 1n }), [ - treasury, - ]), + actionsFor(ready({ ledgerTime: sealed.priceableAt - 1n }), [treasury]), "fulfill-epoch", ) @@ -763,7 +786,7 @@ describe("toCycleActions", () => { expect( conditionsOf( actionOf( - toCycleActions(ready({ ledgerTime: sealed.priceableAt }), [treasury]), + actionsFor(ready({ ledgerTime: sealed.priceableAt }), [treasury]), "fulfill-epoch", ), )["Notice elapsed"], @@ -771,7 +794,7 @@ describe("toCycleActions", () => { expect( conditionsOf( actionOf( - toCycleActions(ready({ ledgerTime: null }), [treasury]), + actionsFor(ready({ ledgerTime: null }), [treasury]), "fulfill-epoch", ), )["Notice elapsed"], @@ -782,7 +805,7 @@ describe("toCycleActions", () => { "disables the settlement while the price is %s", (state) => { const fulfill = actionOf( - toCycleActions(ready({ oracle: { ...oracle, state } }), [treasury]), + actionsFor(ready({ oracle: { ...oracle, state } }), [treasury]), "fulfill-epoch", ) @@ -793,7 +816,7 @@ describe("toCycleActions", () => { it("disables the settlement when the price was attested before the epoch was sealed", () => { const fulfill = actionOf( - toCycleActions( + actionsFor( ready({ oracle: { ...oracle, attestedAt: sealed.closedAt - 1n } }), [treasury], ), @@ -806,7 +829,7 @@ describe("toCycleActions", () => { it("accepts a price attested in the same second the epoch was sealed", () => { const fulfill = actionOf( - toCycleActions( + actionsFor( ready({ oracle: { ...oracle, attestedAt: sealed.closedAt } }), [treasury], ), @@ -820,7 +843,7 @@ describe("toCycleActions", () => { "disables the settlement while the vault's pause reads %s", (paused) => { const fulfill = actionOf( - toCycleActions(ready({ paused }), [treasury]), + actionsFor(ready({ paused }), [treasury]), "fulfill-epoch", ) @@ -831,7 +854,7 @@ describe("toCycleActions", () => { it("keeps the outcome without a price figure when the oracle has none to show", () => { const fulfill = actionOf( - toCycleActions( + actionsFor( ready({ oracle: { ...oracle, state: "never", price: null } }), [treasury], ), diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 0dd89026..9c8b3892 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -347,6 +347,27 @@ const signaturesNeeded = (standing: Standing): number | null => { return "needs" in standing ? standing.needs : standing.threshold } +export const grantFor = ( + grants: Grant[], + role: string, + wallet: string, +): Grant | undefined => { + const ofRole = grants.filter((grant) => grant.role === role) + return ofRole.find((grant) => grant.authority === wallet) ?? ofRole[0] +} + +export const unavailableFor = ( + grant: Grant, + wallet: string, +): string | undefined => { + const needed = signaturesNeeded(grant.standing) + if (needed !== null) + return `Needs ${needed} signatures; collecting them is not supported yet.` + if (grant.authority !== wallet) + return `Signs for ${shortAddress(grant.authority)}; acting on its behalf is not supported yet.` + return undefined +} + const windDownInactive = (state: ReadyState): Condition => ({ label: "Wind-down not active", met: state.windDown.phase !== null && state.windDown.phase !== "active", @@ -372,9 +393,12 @@ const toAction = ( ...(unavailable === undefined ? {} : { unavailable }), }) -function toCloseAction(state: ReadyState, grant: Grant): CycleAction { +function toCloseAction( + state: ReadyState, + grant: Grant, + wallet: string, +): CycleAction { const { epoch } = state - const needed = signaturesNeeded(grant.standing) return toAction( "close-epoch", "Close epoch", @@ -384,9 +408,7 @@ function toCloseAction(state: ReadyState, grant: Grant): CycleAction { ], `Seals epoch ${epoch.id} and opens epoch ${epoch.id + 1n}.`, epoch.id, - needed === null - ? undefined - : `Needs ${needed} signatures; collecting them is not supported yet.`, + unavailableFor(grant, wallet), ) } @@ -427,11 +449,12 @@ function toFulfillAction(state: ReadyState): CycleAction { export function toCycleActions( state: CycleState, grants: Grant[], + wallet: string, ): CycleAction[] { if (state.status !== "ready" || grants.length === 0) return [] - const manager = grants.find((grant) => grant.role === MANAGER_ROLE) + const manager = grantFor(grants, MANAGER_ROLE, wallet) const actions: CycleAction[] = [] - if (manager !== undefined) actions.push(toCloseAction(state, manager)) + if (manager !== undefined) actions.push(toCloseAction(state, manager, wallet)) actions.push(toFulfillAction(state)) return actions }